Stubbing Email and Notification Providers

Transactional messages — password resets, receipts, verification codes, shipping notices — are among the most user-visible things an application does and among the least tested. The reason is obvious: nobody wants a test suite that sends real email. The usual response, a global stub that swallows every send, removes the risk and every opportunity to test what was sent: the recipient, the template, the variables, the link in the password reset. This guide covers an outbox fake that records messages for precise assertions, testing templates as rendered output, handling provider errors and bounces, and using a local mail catcher for the end-to-end flows where a user must click a link in an email. It applies to SendGrid, Postmark, Resend, SES and SMS providers alike, and sits under third-party SDK isolation.

Root Cause Analysis

Email bugs are distinctive because they are invisible to the sender. A receipt with the wrong total, a password-reset link pointing at a staging domain, a verification code sent to the previous email address after a change — none of these throws an error, and none appears in logs. They are discovered by customers, usually after a support ticket, and often after many have already gone out.

The global stub is the reason. Swallowing every send makes tests safe and blind simultaneously: a test can assert that sign-up succeeded but not that the welcome email went to the right person with a working link. The information needed to catch every bug above is in the send call’s arguments, and the stub discards it.

The second gap is failure handling. Providers reject messages for invalid addresses, rate-limit bursts, and report bounces asynchronously hours later. Code that does not handle a rejected send may mark a user verified who never received the code, or retry a permanently invalid address forever. None of that can be tested against a stub that always succeeds.

A swallowing stub versus a recording outbox A stub that discards every send makes tests safe but unable to see the recipient, template, variables or links; an outbox fake records each message so tests can assert on exactly what a customer would receive. swallowing stub send() → resolves, forgets wrong recipient: passes broken reset link: passes safe and blind outbox fake send() → records the message assert recipient and template extract and follow the link safe and observant
Both send nothing; only one lets a test see what the customer would have received.

Reproducible Setup

Define messages in your own terms — a template name and its variables — so the port is independent of the provider’s API.

// src/ports/mailer.ts
export type Message =
  | { template: 'password-reset'; to: string; vars: { resetUrl: string; expiresInMinutes: number } }
  | { template: 'receipt'; to: string; vars: { orderId: string; totalFormatted: string } }
  | { template: 'verify-email'; to: string; vars: { code: string } };

export class PermanentDeliveryError extends Error {}

export interface Mailer {
  send(message: Message, opts?: { idempotencyKey?: string }): Promise<{ messageId: string }>;
}
// test/fakes/outbox.ts
import type { Mailer, Message } from '../../src/ports/mailer';
import { PermanentDeliveryError } from '../../src/ports/mailer';

export function outbox() {
  const sent: Array<Message & { idempotencyKey?: string }> = [];
  const seenKeys = new Set<string>();
  let failNext: Error | undefined;

  const mailer: Mailer = {
    async send(message, opts) {
      if (failNext) { const e = failNext; failNext = undefined; throw e; }
      if (opts?.idempotencyKey && seenKeys.has(opts.idempotencyKey)) return { messageId: 'dedup' };
      if (opts?.idempotencyKey) seenKeys.add(opts.idempotencyKey);
      sent.push({ ...message, idempotencyKey: opts?.idempotencyKey });
      return { messageId: `msg_${sent.length}` };
    },
  };

  return {
    mailer, sent,
    to: (address: string) => sent.filter((m) => m.to === address),
    last: () => sent.at(-1),
    rejectNextAsInvalid: () => { failNext = new PermanentDeliveryError('invalid recipient'); },
    rejectNextAsTransient: () => { failNext = new Error('rate limited'); },
  };
}

Implementation

Step 1 — Assert on recipient, template and variables. This is the assertion a swallowing stub cannot support, and it catches the bugs customers report.

// src/auth/request-password-reset.test.ts
import { test, expect } from 'vitest';
import { outbox } from '../../test/fakes/outbox';
import { requestPasswordReset } from './request-password-reset';

test('sends a reset link to the account’s current address', async () => {
  const box = outbox();
  await requestPasswordReset({ email: 'ada@example.test' }, { mailer: box.mailer, users: usersWith({ email: 'ada@example.test' }) });

  expect(box.last()).toMatchObject({
    template: 'password-reset',
    to: 'ada@example.test',
    vars: { resetUrl: expect.stringMatching(/^https:\/\/app\.example\.com\/reset\?token=[\w-]{32,}$/), expiresInMinutes: 30 },
  });
});

Step 2 — Follow the link the email contains. Extracting the URL from the recorded message and using it proves the token in the email is the token the server will accept.

test('the link in the reset email completes the reset', async () => {
  const box = outbox();
  const deps = { mailer: box.mailer, users: usersWith({ email: 'ada@example.test' }) };
  await requestPasswordReset({ email: 'ada@example.test' }, deps);

  const token = new URL(box.last()!.vars.resetUrl as string).searchParams.get('token')!;
  await expect(completeReset({ token, newPassword: 'correct horse battery staple' }, deps)).resolves.toEqual({ ok: true });
});

Step 3 — Test that nothing is sent when nothing should be. Account-enumeration protection means a reset request for an unknown address must succeed silently — and send nothing.

test('does not reveal whether an address exists', async () => {
  const box = outbox();
  const result = await requestPasswordReset({ email: 'nobody@example.test' }, { mailer: box.mailer, users: usersWith() });
  expect(result).toEqual({ ok: true });
  expect(box.sent).toEqual([]);
});

Step 4 — Distinguish permanent from transient provider failures. A permanently invalid address should be recorded and not retried; a rate limit should be retried later.

test('marks the address undeliverable on a permanent rejection', async () => {
  const box = outbox();
  box.rejectNextAsInvalid();
  const users = usersWith({ email: 'typo@exmaple.test' });
  await sendVerification({ userId: 'u1' }, { mailer: box.mailer, users });
  expect(await users.get('u1')).toMatchObject({ emailStatus: 'undeliverable' });
});

test('schedules a retry on a transient failure', async () => {
  const box = outbox();
  box.rejectNextAsTransient();
  const queue = memoryQueue();
  await sendVerification({ userId: 'u1' }, { mailer: box.mailer, users: usersWith({ email: 'a@example.test' }), queue });
  expect(queue.added).toContainEqual(expect.objectContaining({ name: 'retry-verification' }));
});
How to respond to each provider outcome An accepted message is recorded, a permanent rejection marks the address undeliverable without retrying, a transient failure schedules a retry with backoff, and an asynchronous bounce reported later by webhook updates the address status. Provider outcome Application response accepted record message id rejected — invalid address mark undeliverable, never retry rejected — rate limited retry with backoff bounced hours later, via webhook update address status
Four outcomes, four responses — an always-succeeding stub exercises only the first.

Step 5 — Render templates in their own tests. Whether the template produces correct HTML is a separate question from whether the right template was chosen, and it is best answered by rendering with fixed variables and checking the output.

// src/emails/receipt.test.ts
import { renderEmail } from './render';

test('the receipt shows the order and total, and has a plain-text part', async () => {
  const { subject, html, text } = await renderEmail('receipt', { orderId: 'o1', totalFormatted: '£49.99' });
  expect(subject).toBe('Your receipt for order o1');
  expect(html).toContain('£49.99');
  expect(text).toContain('Order o1');
  expect(html).not.toMatch(/\{\{|undefined/);          // no unrendered placeholders
});

Step 6 — Use a mail catcher for end-to-end flows. When a browser journey requires clicking a link in an email, point the real adapter at a local SMTP catcher and read the message through its API.

# docker-compose.test.yml
services:
  mailpit:
    image: axllent/mailpit:v1.20
    ports: ["1025:1025", "8025:8025"]
// e2e/journeys/verify-email.spec.ts
test('a new user verifies their address from the email', async ({ page, request }) => {
  await page.goto('/sign-up');
  await page.getByLabel('Email').fill('new@example.test');
  await page.getByRole('button', { name: 'Create account' }).click();

  const list = await (await request.get('http://localhost:8025/api/v1/search?query=to:new@example.test')).json();
  const msg = await (await request.get(`http://localhost:8025/api/v1/message/${list.messages[0].ID}`)).json();
  const link = msg.Text.match(/https?:\/\/\S+verify\S+/)[0];

  await page.goto(link);
  await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});

Verification

Confirm no real message can leave a test run: the provider’s API key is absent from the test environment, and MSW errors on any request to the provider’s host.

env | grep -iE "sendgrid|postmark|resend" || echo "no provider keys in the test environment"

Then confirm the outbox assertions have teeth by changing the reset URL’s domain in the code to a staging host. The recipient-and-link test must fail — that is precisely the bug that otherwise reaches customers.

Three tools for three questions The outbox fake answers which message was sent to whom, template rendering tests answer whether it looks right, and a local mail catcher answers whether a user can complete a journey that goes through their inbox. outbox fake which message, to whom, with which variables template render does it read correctly, no placeholders left mail catcher can a user finish a journey via their inbox
Each tool answers a different question, and none of them sends a real message.

Troubleshooting

Symptom: tests assert on email HTML and break on every copy change. Diagnosis: behaviour tests are checking presentation. Fix: assert on template name and variables in behaviour tests, and keep HTML assertions in the dedicated rendering tests, where a copy change is expected to update them.

Symptom: the mail catcher sometimes has no message yet. Diagnosis: the application sends asynchronously, and the test reads the inbox before delivery. Fix: poll the catcher’s API until the message appears or a timeout passes, rather than reading once.

Symptom: duplicate emails appear after retries. Diagnosis: the send is retried without an idempotency key, or the key changes between attempts. Fix: derive the key from the business event — the order, the reset request — and assert in a test that a second send with the same key does not add a message.

Symptom: SMS tests need a real phone number format. Diagnosis: validation rejects obviously fake numbers. Fix: use the ranges regulators reserve for fiction and testing, and keep the SMS port’s fake as simple as the email outbox — the same recording pattern works for any notification channel.

FAQ

Should the outbox fake render templates?

Keep them separate. The fake records the template name and variables; rendering is tested on its own. Coupling them means every behaviour test pays for template rendering and breaks on copy changes, which makes them slower and noisier without catching anything extra.

Is a mail catcher enough on its own?

It is excellent for end-to-end flows and far too slow for the bulk of behaviour tests. Starting a container and polling an inbox to check which template a receipt used costs seconds per test; the outbox fake answers the same question in microseconds.

How do I test bounce handling?

Bounces arrive as webhooks from the provider, often long after the send. Test the webhook handler with a signed bounce payload, as in testing webhook handlers with signed payloads, and assert that the address status updates and future sends are suppressed.

What about preview or staging environments?

Point them at a mail catcher or at the provider’s sandbox mode, never at real delivery, and make the configuration refuse a production key outside production — the same guard used for payment keys in testing Stripe payment flows without live keys.