All posts
Architecture·By Deepak··10 min read

Transactional Email Pipeline: OTPs, Magic Links & Routing

TL;DR

A transactional email pipeline for AI-powered apps needs more than an SMTP relay. OTP delivery requires sub-5s latency and idempotency guards. Magic links need HMAC signing and short TTLs. Notification routing belongs in a priority queue with per-event template logic — not a single send path.

Transactional Email Pipeline: OTPs, Magic Links & Routing

Most developers wire up transactional email in an afternoon and move on. Then production hits: an OTP arrives 90 seconds late, a magic link gets replayed, a notification queue blocks auth emails. Each failure is a user who can't log in, complete a payment, or trust the product.

AI-powered apps add new wrinkles. An agent might trigger a notification on behalf of a user. An inbound reply to that notification might need to route back into an agent workflow. The pipeline needs to handle both cases cleanly — different sending paths, different latency requirements, different failure modes.

This post covers the full stack: OTP mechanics, magic link security, notification routing, and the infrastructure decisions that determine whether emails actually reach inboxes.


The three lanes of transactional email

Transactional email splits into three distinct lanes, each with different SLAs and failure tolerances. OTPs need delivery in under 5 seconds or users abandon the flow. Magic links can tolerate 10–15 seconds but require strict security controls. Notifications are latency-tolerant but high-volume, and they must never block the first two lanes.

Collocating these lanes on a single queue and IP pool is a common mistake. A spike in notification volume can starve OTP delivery. A bounce storm from a batch notification can suppress your sender reputation — the same reputation your OTP emails depend on.

Separate them like this:

Lane         | Max Latency | Volume Profile  | IP Strategy
-------------|-------------|-----------------|------------------
OTP          | 5s p99      | Low, spiky      | Dedicated IP, warmed
Magic Link   | 15s p99     | Low, steady     | Dedicated IP, warmed
Notification | 60s p99     | High, bursty    | Separate IP pool

Separate IP pools mean a notification deliverability issue — high unsubscribes, bounces — doesn't contaminate your auth email reputation. Dedicated IP configuration for automated senders becomes essential at any meaningful volume. Shared IP pools expose you to the behavior of other senders.


OTP delivery: latency, idempotency, and expiry

OTP delivery is latency-sensitive and security-critical. Get the implementation wrong in either direction — too slow or too permissive — and you have either a broken auth flow or a security hole.

Generating and storing OTPs

Use crypto.randomInt(100000, 999999) (Node.js) or an equivalent CSPRNG. Never Math.random(). Store a bcrypt or Argon2 hash of the OTP in your database, not the plaintext. The email is the delivery channel, not the storage layer.

import { randomInt } from 'crypto';
import { hash } from 'argon2';

async function generateOTP(userId: string): Promise<string> {
  const otp = randomInt(100000, 999999).toString();
  const otpHash = await hash(otp);
  
  await db.otpTokens.upsert({
    where: { userId },
    create: {
      userId,
      hash: otpHash,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5-minute TTL
      attempts: 0,
    },
    update: {
      hash: otpHash,
      expiresAt: new Date(Date.now() + 5 * 60 * 1000),
      attempts: 0,
    },
  });
  
  return otp;
}

The upsert pattern handles the race condition where a user clicks "resend" before the first email arrives — you invalidate the previous token atomically.

Idempotency on the send path

If your OTP send function can be called twice in quick succession — double-click, retried request, agent re-trigger — you need idempotency guards at the API layer, not just the database layer.

Most email APIs accept an idempotency key as a header. Cache the key with a 30-second TTL in Redis:

async function sendOTPEmail(userId: string, email: string): Promise<void> {
  const idempotencyKey = `otp:${userId}:${Math.floor(Date.now() / 30000)}`;
  
  const alreadySent = await redis.set(
    idempotencyKey,
    '1',
    'EX', 30,
    'NX' // Only set if not exists
  );
  
  if (!alreadySent) {
    return; // Deduplicated — email already sent in this window
  }
  
  const otp = await generateOTP(userId);
  
  await emailClient.send({
    to: email,
    subject: 'Your verification code',
    text: `Your code is ${otp}. Expires in 5 minutes.`,
    headers: { 'X-Idempotency-Key': idempotencyKey },
  });
}

The 30-second window maps to the time bucket — duplicate sends within that window get dropped silently. Outside it, a legitimate resend goes through.

Verification with timing-safe comparison

Use timingSafeEqual when comparing tokens, and enforce attempt limits:

import { timingSafeEqual } from 'crypto';
import { verify } from 'argon2';

async function verifyOTP(userId: string, candidate: string): Promise<boolean> {
  const token = await db.otpTokens.findUnique({ where: { userId } });
  
  if (!token || token.expiresAt < new Date()) return false;
  if (token.attempts >= 5) return false; // Rate limit
  
  await db.otpTokens.update({
    where: { userId },
    data: { attempts: { increment: 1 } },
  });
  
  const valid = await verify(token.hash, candidate);
  
  if (valid) {
    await db.otpTokens.delete({ where: { userId } });
  }
  
  return valid;
}

Magic links are stateless OTPs embedded in a URL. The security requirements are stricter because the token lives in logs, browser history, and Referer headers by default.

Signing the token

Sign with HMAC-SHA256 using a secret key. Include the user ID and expiry timestamp in the payload — don't rely on a database lookup alone to enforce expiry.

import { createHmac, timingSafeEqual } from 'crypto';

const MAGIC_LINK_SECRET = process.env.MAGIC_LINK_SECRET!; // 32+ bytes

function createMagicToken(userId: string, expiresAt: number): string {
  const payload = `${userId}:${expiresAt}`;
  const sig = createHmac('sha256', MAGIC_LINK_SECRET)
    .update(payload)
    .digest('base64url');
  return `${Buffer.from(payload).toString('base64url')}.${sig}`;
}

function verifyMagicToken(token: string): { userId: string } | null {
  const [encodedPayload, sig] = token.split('.');
  if (!encodedPayload || !sig) return null;
  
  const payload = Buffer.from(encodedPayload, 'base64url').toString();
  const expectedSig = createHmac('sha256', MAGIC_LINK_SECRET)
    .update(payload)
    .digest('base64url');
  
  const sigBuf = Buffer.from(sig);
  const expectedBuf = Buffer.from(expectedSig);
  
  if (sigBuf.length !== expectedBuf.length) return null;
  if (!timingSafeEqual(sigBuf, expectedBuf)) return null;
  
  const [userId, expiresAt] = payload.split(':');
  if (parseInt(expiresAt) < Date.now()) return null;
  
  return { userId };
}

This is stateless verification — no database round-trip to check the signature. The expiry is embedded in the token itself.

Single-use enforcement

HMAC verification is stateless, but single-use enforcement requires state. Store a used-token set in Redis with a TTL matching the token's expiry:

async function consumeMagicToken(token: string): Promise<string | null> {
  const result = verifyMagicToken(token);
  if (!result) return null;
  
  const usedKey = `magic:used:${token}`;
  const isNew = await redis.set(usedKey, '1', 'EX', 900, 'NX'); // 15min TTL
  
  if (!isNew) return null; // Already used
  
  return result.userId;
}

Preventing link prefetching

Email clients (Gmail, Outlook, Apple Mail) and security gateways pre-fetch links to scan for malware. A magic link consumed by a prefetch leaves the user locked out.

Two defenses:

  1. Require a POST request to consume the link — prefetchers only issue GETs.
  2. On the GET, render a "Click to confirm" button that POSTs the token. Adds one click but eliminates prefetch consumption.

The POST approach is more reliable. The confirmation page also gives you somewhere to surface error messages when a token is expired or already used.


Notification routing: priority queues and template logic

Notifications are the high-volume lane — password change confirmations, billing alerts, agent action summaries, anything the system generates proactively. The routing logic needs to handle event type → template mapping, user preferences, and in AI-powered apps, agent-generated content that varies per send.

Queue architecture

Don't send notifications synchronously in the request path. Use a queue:

Request → Enqueue event → Worker picks up → Render template → Send email

The queue provides back-pressure when email API rate limits are hit, and it decouples your application's latency from email delivery latency.

type NotificationEvent = {
  type: 'BILLING_ALERT' | 'AGENT_ACTION' | 'PASSWORD_CHANGED' | 'REPORT_READY';
  userId: string;
  payload: Record<string, unknown>;
  priority: 'high' | 'normal' | 'low';
};

async function enqueueNotification(event: NotificationEvent): Promise<void> {
  await queue.add('notification', event, {
    priority: event.priority === 'high' ? 1 : event.priority === 'normal' ? 5 : 10,
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 },
  });
}

BullMQ (Redis-backed) handles priority natively. High-priority notifications — security alerts, billing failures — get a lower numeric priority and jump the queue.

Template routing by event type

const TEMPLATE_MAP: Record<NotificationEvent['type'], string> = {
  BILLING_ALERT: 'billing-alert-v2',
  AGENT_ACTION: 'agent-action-summary-v1',
  PASSWORD_CHANGED: 'security-password-changed-v1',
  REPORT_READY: 'report-ready-v1',
};

async function processNotification(event: NotificationEvent): Promise<void> {
  const user = await db.users.findUnique({ where: { id: event.userId } });
  if (!user || !user.emailVerified) return;
  
  const prefs = await db.notificationPrefs.findUnique({ where: { userId: event.userId } });
  if (prefs?.disabled.includes(event.type)) return; // Respect user prefs
  
  const templateId = TEMPLATE_MAP[event.type];
  const rendered = await renderTemplate(templateId, { user, ...event.payload });
  
  await emailClient.send({
    to: user.email,
    subject: rendered.subject,
    html: rendered.html,
    text: rendered.text,
    headers: {
      'X-Event-Type': event.type,
      'X-User-Id': event.userId,
    },
  });
}

Custom headers (X-Event-Type, X-User-Id) are indexed by most email providers and make debugging delivery issues significantly faster.

Agent-generated notifications

When an AI agent triggers a notification, the content is dynamic — the agent might be summarizing actions taken, reporting on a workflow result, or surfacing an anomaly. The routing logic is the same, but template rendering receives structured agent output:

await enqueueNotification({
  type: 'AGENT_ACTION',
  userId: event.userId,
  payload: {
    agentId: 'data-monitor-v2',
    summary: agentOutput.summary,         // Agent-generated text
    actionsCount: agentOutput.actions.length,
    anomaliesDetected: agentOutput.anomalies,
    timestamp: new Date().toISOString(),
  },
  priority: agentOutput.anomalies.length > 0 ? 'high' : 'normal',
});

Priority escalates when the agent detects anomalies. Same routing code, different queue position.


Deliverability mechanics for auth emails

SPF, DKIM, and DMARC aren't optional for auth emails. A failed DMARC check means your OTP goes to spam, which means your user can't log in.

  • SPF: Authorize your email provider's sending IPs in your DNS TXT record. v=spf1 include:_spf.yourprovider.com -all
  • DKIM: Sign outbound mail with a 2048-bit RSA key. Publish the public key as a DNS TXT record at selector._domainkey.yourdomain.com.
  • DMARC: Start with p=none for monitoring, move to p=quarantine then p=reject once your alignment is clean. v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com

Alignment is what trips people up: the From header domain must match the DKIM signing domain and/or the SPF-authorized domain. Misalignment is the most common reason DMARC fails on otherwise properly configured infrastructure.

For AI-powered apps sending at scale, sender reputation management — monitoring bounce rates, complaint rates, and inbox placement — needs to be a first-class concern, not an afterthought.

Mails.ai's transactional email infrastructure is built for exactly this pattern: dedicated IPs for auth email lanes, per-send pricing at $0.001, and inbound parsing for notification replies — all designed for apps where email is part of the product, not just a delivery mechanism.


Flow: OTP request to verification

sequenceDiagram
  participant App as App Server
  participant Redis as Redis
  participant DB as Database
  participant ESP as Email Provider
  participant User as User

  App->>Redis: SET idempotency key NX EX 30
  Redis-->>App: OK or nil
  App->>DB: upsert OTP hash and expiry
  App->>ESP: send OTP email
  ESP-->>User: deliver email
  User->>App: submit OTP code
  App->>DB: fetch token record
  App->>DB: increment attempt count
  App->>DB: verify argon2 hash
  App->>DB: delete token on success
  App-->>User: session created

Frequently Asked Questions

How long should OTP tokens be valid?

Five minutes is the standard for SMS-style numeric OTPs. Longer TTLs improve UX for users who check email slowly, but they widen the window for replay attacks. If your app has a mobile component where email latency is predictable, 3 minutes is defensible. Never exceed 10 minutes for a 6-digit OTP — at 6 digits, the keyspace is 900,000 combinations, and 5 failed attempts per window doesn't adequately protect a 10-minute token without aggressive rate limiting.

Should I use the same email address for OTPs and notifications?

No. Use separate From addresses — auth@yourdomain.com for OTPs and magic links, notifications@yourdomain.com for everything else. This lets you configure separate DKIM selectors and sending IPs per address, and keeps notification bounce and complaint rates from contaminating your auth email reputation. Users also learn to look for auth emails from the auth address specifically.

How do I handle notification emails that users reply to?

Set the Reply-To header to a parseable inbound address — e.g., reply+{userId}+{notificationId}@inbound.yourdomain.com. When a reply arrives, your inbound email parser extracts the user ID and notification ID from the address, routes the reply content to the appropriate handler, and can feed it back into an agent workflow. That's how you build email as a two-way interface, not just a delivery pipe.

What causes magic links to be consumed before the user clicks them?

Email security gateways (Proofpoint, Mimecast, Microsoft Defender) and some email clients prefetch links for malware scanning. The fix is to require a POST to consume the token — prefetch requests are always GETs. Render a confirmation page on GET that shows a "Sign in" button, then POST the token on submit. One extra click, zero prefetch-triggered consumption.

How should AI agents trigger transactional emails differently from user-initiated sends?

Agent-triggered sends should always include a triggered-by: agent:{agentId} custom header and pass through the same idempotency layer as user-initiated sends — agents can retry on failure in ways users can't. The more important difference is priority assignment: agent notifications are typically lower priority than user-initiated auth emails, and that should be enforced at the queue level, not handled ad-hoc. Keep agent sends in the notification lane, never in the OTP lane.

How do I test email delivery in CI without sending real emails?

Use a Mailhog or MailPit instance in your test environment — both expose an SMTP interface and a REST API for reading delivered messages. For integration tests, point your email client at the local SMTP server and assert on the delivered message's headers, subject, and body. For production smoke tests, maintain a set of seed email addresses at a domain you control and verify delivery latency and content programmatically via IMAP or a provider-specific API.

Live now

Ship agent email in ~6 lines.

Free tier, no card. Mint a key and drop the SDK into your agent.

Get your API key
Live now

Built for agents.
Self-serve in minutes.

The API is live and self-serve. Drop ~6 lines into your agent and ship.

npmpnpmbunnpx
$ npm install @mailsai/sdk
Live on npm today · @mailsai/sdk + @mailsai/mcp-server