Testing Email Verification Flows in Development

Email verification is a surprisingly tricky part of the sign-up flow to test correctly. Shared test inboxes cause race conditions, mocked services miss real-world edge cases, and personal inboxes are unprofessional. Here is a better approach.

The problem with shared test inboxes

A shared address like test@yourcompany.com that all developers and CI jobs read causes race conditions. Parallel test runs pick up each other's emails. Test A triggers a sign-up and waits for the OTP. Test B triggers a different sign-up while A is waiting. One of them gets the other's OTP. The failure is non-deterministic and debugging takes hours.

The problem with mock email services

Services like Mailtrap and Mailhog capture outbound email in a fake SMTP server. This works for local development but has gaps:

  • Email templates only render correctly in real mail clients, not preview panes
  • Deliverability edge cases (SPF/DKIM, greylisting, spam scoring) are invisible
  • CI environments require managing a running SMTP service alongside your test runner

The right approach: real isolated inboxes via API

Create a unique, real inbox per test run via the TempMailGrab API. The inbox receives actual email through real SMTP — so deliverability and template rendering are tested alongside your logic.

const API_BASE = 'https://tempmailgrab.com/api/v1';
const headers = { Authorization: 'Bearer ' + process.env.TMG_API_KEY };

export async function createTestInbox() {
  const res = await fetch(API_BASE + '/inbox', { method: 'POST', headers });
  return res.json();
}

export async function waitForMessage(inboxId, matcher, opts = { timeoutMs: 30000, intervalMs: 2000 }) {
  const deadline = Date.now() + opts.timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(API_BASE + '/inbox/' + inboxId + '/messages', { headers });
    const { messages } = await res.json();
    const match = messages.find(matcher);
    if (match) return match;
    await new Promise(r => setTimeout(r, opts.intervalMs));
  }
  throw new Error('Message not received within ' + opts.timeoutMs + 'ms');
}
it('sends an OTP and completes verification', async () => {
  const { id, address } = await createTestInbox();
  await signUpWithEmail(address);
  const msg = await waitForMessage(id, m => m.extracted_otp !== null);
  await submitOtpOnVerificationPage(msg.extracted_otp);
});

Advantages over mocking

  • Real email delivery — tests fail if your email sending is broken
  • Real template rendering — email client bugs surface in tests
  • No infrastructure to run — no SMTP service in CI
  • Parallel-safe — each test gets its own isolated inbox
  • OTP already extracted — no regex parsing in test code

Related articles

See also: API documentation · Email flows in CI/CD