DEV Community

Cover image for Testing Signup Flows Without Polluting Real Inboxes
Waqar Habib
Waqar Habib Subscriber

Posted on Originally published at temppostal.com

Testing Signup Flows Without Polluting Real Inboxes

Every team that ships authentication eventually hits the same wall: you can unit-test the token generator, you can integration-test the mailer, but you cannot honestly claim the signup flow works until something has clicked a real verification link from a real message.

The usual workarounds all degrade:

  • A shared QA mailbox (qa@company.com) collects thousands of messages, tests race each other for the newest one, and someone eventually deletes the inbox.
  • Plus-addressing on a personal account works until you need parallel test runs, or until the provider rate-limits your polling.
  • Mocking the mailer entirely tests your code and nothing about the delivery path, the exact layer that breaks in production.

The clean answer is to give every test run its own throwaway mailbox, fetched programmatically. Here's how to build that.

The shape of the solution

1. Test starts → request a fresh disposable address via API
2. Test drives signup UI/API with that address
3. Test polls the mailbox until the verification message arrives
4. Test extracts the link/code from the message body
5. Test completes verification and asserts the account is active
6. Mailbox is abandoned, nothing to clean up
Enter fullscreen mode Exit fullscreen mode

Each run is hermetic. Runs can execute in parallel because no two share a mailbox. Nothing lands in a human's inbox. And critically, the test exercises the whole delivery path: template rendering, SPF/DKIM signing, provider handoff, receipt.

Step 1: Get an address

Using a temporary email API, address creation is a single call. Wrap it in a helper so tests never talk to HTTP directly:

// test/support/mailbox.ts
const API = "https://api.temppostal.com/v1";

type Mailbox = { address: string; id: string };

export async function createMailbox(): Promise<Mailbox> {
  const res = await fetch(`${API}/addresses`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TEMP_MAIL_KEY!}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ ttlMinutes: 30 }),
  });
  if (!res.ok) throw new Error(`mailbox create failed: ${res.status}`);
  const { address, id } = await res.json();
  return { address, id };
}
Enter fullscreen mode Exit fullscreen mode

Two details that matter more than they look:

  • Set a TTL. Thirty minutes is plenty for a test and it means abandoned mailboxes clean themselves up. Long TTLs turn your test infrastructure into a data retention problem.
  • Fail loudly on non-2xx. A silently undefined address produces a test failure fifteen seconds later in a completely unrelated assertion. Blow up at the source.

Step 2: Poll with a real backoff

The naive version: setTimeout(5000) then fetch once, is the single most common source of flaky email tests. Mail delivery latency is a distribution, not a constant. Poll with a deadline instead:

export async function waitForMessage(
  id: string,
  match: (subject: string) => boolean,
  timeoutMs = 45_000,
) {
  const deadline = Date.now() + timeoutMs;
  let delay = 500;

  while (Date.now() < deadline) {
    const res = await fetch(`${API}/addresses/${id}/messages`, {
      headers: { Authorization: `Bearer ${process.env.TEMP_MAIL_KEY!}` },
    });
    const { messages } = await res.json();
    const hit = messages.find((m: { subject: string }) => match(m.subject));
    if (hit) return hit;

    await new Promise((r) => setTimeout(r, delay));
    delay = Math.min(delay * 1.5, 4_000); // exponential, capped
  }

  throw new Error(`no matching message within ${timeoutMs}ms`);
}
Enter fullscreen mode Exit fullscreen mode

Exponential backoff capped at a few seconds gives you fast passes on the common case (mail arrives in under two seconds) without hammering the API when a provider is slow.

Step 3: Extract the token, not the whole link

Verification emails are HTML. Regexing the raw body for href="..." works right up until your template adds a tracking wrapper or a second CTA button. Be specific:

export function extractVerifyUrl(html: string): string {
  // match only your own verification path
  const m = html.match(/https?:\/\/[^"'\s]*\/auth\/verify\?token=[A-Za-z0-9._-]+/);
  if (!m) throw new Error("verification URL not found in message body");
  return m[0];
}
Enter fullscreen mode Exit fullscreen mode

Better still, if you control the template, emit a machine-readable marker your tests can key off, a hidden <span data-test-token="..."> or a plain-text section with a stable prefix. Tests that depend on marketing copy break when marketing changes copy.

Step 4: The full Playwright test

import { test, expect } from "@playwright/test";
import { createMailbox, waitForMessage, extractVerifyUrl } from "./support/mailbox";

test("new user can sign up and verify by email", async ({ page }) => {
  const { address, id } = await createMailbox();

  await page.goto("/auth");
  await page.getByLabel("Email").fill(address);
  await page.getByLabel("Password").fill("Correct-Horse-9!");
  await page.getByRole("button", { name: "Create account" }).click();
  await expect(page.getByText("Check your email")).toBeVisible();

  const message = await waitForMessage(id, (s) => /confirm your email/i.test(s));
  await page.goto(extractVerifyUrl(message.html));

  await expect(page.getByText("Email confirmed")).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

That is an honest end-to-end test. It fails if the template breaks, if DKIM signing breaks, if the token TTL is misconfigured, if the verify route regresses, every failure mode that actually pages you at 3am.

Step 5: Wire it into CI without leaking keys

# .github/workflows/e2e.yml
- name: Run e2e
  env:
    TEMP_MAIL_KEY: ${{ secrets.TEMP_MAIL_KEY }}
  run: npx playwright test
Enter fullscreen mode Exit fullscreen mode

Use a key scoped to testing only, with its own rate limit, so a runaway loop in CI cannot affect production quota. Rotate it on the same cadence as everything else.

Caveats worth knowing before you adopt this

Some products deliberately block disposable domains. If your own signup form rejects disposable addresses (many do, for anti-fraud reasons), you need an allowlist for your test domain, or a dedicated custom domain routed to your test mailboxes. Do this consciously: An allowlist entry that ships to production is a fraud vector.

Disposable mailboxes are not private for secrets. Never send a real credential, a production reset link, or customer data to a throwaway inbox. Test fixtures only.

Delivery latency is a real signal. If your suite starts needing 40-second timeouts where it used to pass in three, that is not test flakiness, that's your mail provider degrading. Log the observed latency and alert on the trend. Several teams I've talked to discovered a deliverability problem from their test suite before their monitoring caught it.

Rate limits are per-key, not per-test. A suite that creates a mailbox per test case across 400 tests will hit a limit. Create mailboxes per flow, reuse within a flow, and consider a shared fixture for read-only assertions.

Why this beats a mail-catching container

Self-hosted SMTP catchers (MailHog, Mailpit, and friends) are excellent for local development and I use them daily. They test that your app sent something. They do not test that a real MTA accepted it, that SPF/DKIM/DMARC align, or that the message renders in a real client. For staging and production smoke tests, you want mail that traversed the actual internet. That's the gap a hosted disposable inbox fills.

Summary

  • One mailbox per test run, created via API, TTL'd short.
  • Poll with exponential backoff and a hard deadline. Never a fixed sleep.
  • Match on a stable machine-readable marker, not marketing copy.
  • Keep a test-scoped API key in CI secrets.
  • Treat rising delivery latency in your suite as a production signal.

I build Temp Postal and this is the pattern our own suite uses; the developer docs have the full endpoint reference if you want to try it. But the pattern is the point. It works against any provider with a read API, and it turns the flakiest test in most suites into one of the most reliable.

Top comments (0)