Steven's Knowledge

Patterns

Templates, scheduled sends, webhooks, transactional vs marketing, multi-channel orchestration

Patterns

The patterns every product reaches for once "I can send an email" stops being interesting.

Templates and Variables

Hand-coded HTML per email gets unmaintainable fast. Two approaches:

Provider-Hosted Templates

Most providers let you create templates in their dashboard and pass variables:

// SendGrid
await sgMail.send({
  from: 'noreply@example.com',
  to: 'alice@example.com',
  templateId: 'd-abc123...',
  dynamicTemplateData: {
    name: 'Alice',
    activation_url: 'https://example.com/activate?token=...',
  },
});

Pros: marketing / support can edit without code changes. Versioning, preview, send-test in the UI.

Cons: templates live outside your repo. Hard to test in CI. Drift between code references and template variables.

Templates as React Email / MJML / handlebars in your codebase:

// emails/order-shipped.tsx
export default function OrderShipped({ name, trackingUrl, items }) {
  return (
    <Html>
      <Body>
        <Heading>Hi {name}, your order is on the way</Heading>
        <Text>Items:</Text>
        <ul>
          {items.map(i => <li key={i.id}>{i.name}</li>)}
        </ul>
        <Button href={trackingUrl}>Track shipment</Button>
      </Body>
    </Html>
  );
}

Pros: versioned with code, testable, type-checked, reviewable.

Cons: every change needs a deploy.

In 2026, React Email is the default for new projects using a Node stack. Other languages have analogs (mjml-* templates).

Multi-Language / Localization

import { useTranslation } from '@/i18n';

export default function WelcomeEmail({ name, locale = 'en' }) {
  const t = useTranslation(locale);
  return (
    <Html lang={locale}>
      <Body>
        <Heading>{t('welcome.heading', { name })}</Heading>
        <Text>{t('welcome.body')}</Text>
      </Body>
    </Html>
  );
}

Three concerns:

  • Translations — your i18n system serves email like any other UI.
  • RTL languages — Arabic, Hebrew need dir="rtl" and right-aligned content.
  • Date / number formatting — locale-aware via Intl.

Test rendering in each language before launch — a translation that overflows looks broken.

Scheduled Sends

Send at a specific time, not now. Two ways:

Provider-Native (when supported)

await resend.emails.send({
  // ...
  scheduledAt: '2025-06-01T09:00:00Z',
});

Resend, Postmark, SendGrid all support delayed send.

Via Job Queue (more flexible)

Schedule a job in Background Jobs that calls the email API when due:

await emailQueue.add('send-welcome', { userId }, {
  delay: 60 * 60 * 1000,     // 1 hour
});

// Worker
async function sendWelcome(job) {
  const user = await getUser(job.data.userId);
  await sendEmail(user, ...);
}

The queue approach is more flexible — you can re-evaluate state when the job runs ("is this user still active?" before sending).

Transactional vs Marketing

Different beasts:

TransactionalMarketing
TriggerUser actionTime-based / segment-based
Volume per sendPer-user, 1 at a timeBatches, often very large
UnsubscribeUsually exempt (account-related)Required, prominent
Deliverability strategyHigh volume from steady IP, simple flowReputation per campaign
ComplianceCAN-SPAM, GDPR consent for related processingCAN-SPAM, GDPR explicit consent
Suppression listHard bounces onlyHard bounces + unsubscribes + complaints
Common providersResend, Postmark, SendGrid, SESMailchimp, Customer.io, Brevo, Loops

Use separate sending domains / IP pools for the two. A bad marketing batch should not poison your transactional reputation.

Customer.io / Loops Pattern

These tools blur the line — they handle both transactional sends and behavioral marketing campaigns ("send X if user does Y within Z days"). For teams that want both in one place with less code, they're a good fit.

Webhooks: React to Email Events

Every transactional provider sends webhooks. Common shape:

// POST from provider
{
  type: 'email.delivered' | 'email.bounced' | 'email.complained' | ...
  data: {
    email_id: '...',
    to: 'alice@example.com',
    reason: 'mailbox full',
    timestamp: '2025-05-21T14:32:00Z',
  }
}

Verify the Signature

Always verify the webhook came from the provider, not an attacker:

import { Webhook } from 'svix';

app.post('/webhooks/resend', (req, res) => {
  const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET);
  let event;
  try {
    event = wh.verify(req.rawBody, req.headers);
  } catch {
    return res.sendStatus(401);
  }
  // process event...
});

Handle Idempotently

Providers retry on 5xx. Process events idempotently — keep a processed_event_ids table or set; ignore duplicates.

Update Your Data

switch (event.type) {
  case 'email.bounced':
    if (event.data.bounceType === 'hard') {
      await db.suppressions.upsert({ email: event.data.to, reason: 'hard-bounce' });
    }
    break;

  case 'email.complained':
    await db.suppressions.upsert({ email: event.data.to, reason: 'complaint' });
    break;

  case 'email.unsubscribed':
    await db.users.update({ where: { email: event.data.to }, data: { unsubscribed: true } });
    break;
}

Honor suppressions before every send:

async function sendEmail(to, ...) {
  if (await isSuppressed(to)) return;   // silently skip
  await provider.send({ to, ... });
}

Multi-Channel (Email + SMS + Push)

For critical notifications (OTPs, urgent alerts), you might fan out across channels:

async function notifyUrgent(userId, message) {
  const user = await getUser(userId);

  await Promise.allSettled([
    sendEmail(user.email, message),
    user.phone && sendSMS(user.phone, message.short),
    user.pushToken && sendPush(user.pushToken, message.short),
  ]);
}

For more sophisticated multi-channel (with fallback, preferences, frequency capping), Knock and Courier are dedicated platforms. They wrap all the providers and add:

  • Per-user channel preferences ("don't send me push notifications")
  • Frequency capping ("at most 3 emails per day")
  • Cross-channel deduplication
  • Workflow orchestration

For most teams, the Promise.allSettled approach is fine until it isn't.

SMS via Twilio

npm install twilio
import Twilio from 'twilio';

const twilio = Twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

await twilio.messages.create({
  from: '+15555551234',
  to: '+12015555678',
  body: 'Your verification code is 123456',
});

Concerns:

  • Pricing per-message, per-country. A SaaS sending OTPs to India and the Philippines costs differently than to the US.
  • Long codes vs short codes vs alphanumeric senders vary by country.
  • Toll-free numbers and 10DLC registration required for US bulk SMS.
  • Apple Messages for Business / RCS / WhatsApp are channel-specific.

For OTPs specifically, Twilio Verify is a higher-level API that handles delivery, fallback channels, and code validation in one call.

Push Notifications

For mobile / web apps:

ServiceWhat for
Firebase Cloud Messaging (FCM)Android, web; can do iOS via APNs proxy
Apple Push Notification service (APNs)iOS native; via FCM or directly
Web PushBrowser push without an app; uses VAPID + service worker
OneSignal / AirshipCross-platform push platforms; UI + API
// FCM example
import admin from 'firebase-admin';

await admin.messaging().send({
  token: deviceToken,
  notification: {
    title: 'New message',
    body: 'Alice sent you a message',
  },
  data: { conversationId: '42' },
  apns: { payload: { aps: { sound: 'default' } } },
});

Push tokens expire and change; store them tied to a user + device, clean up failed sends.

In-App Notifications

Notifications inside your product (the bell icon with a red dot):

  • Build it: a notifications table, your app polls or uses WebSocket.
  • Use a platform: Knock, Courier, Magic Bell, Novu — they handle multi-channel + in-app for you.

For B2B products with notification preferences and complex workflows, the platforms pay off. For a small app, building it is straightforward.

What's Next

You know the patterns. Best Practices covers operations — deliverability, unsubscribe, observability, compliance.

On this page