Testing Email Verification

Automating registration flows reliably with Playwright or Cypress.

your address

-

OTP codessignup confirmationspassword resetsmagic linksverification emailsnewsletter testswebhook receipts
OTP codessignup confirmationspassword resetsmagic linksverification emailsnewsletter testswebhook receipts

Live inbox

Auto-refreshing every 10 seconds.

Nothing here yet

Send a test mail or use the address on a signup form. New messages land here automatically.

The problem

Email verification is the step where most end-to-end suites stop. Everything up to it is ordinary browser automation. Then the flow leaves your application, travels through a mail server, and the next thing your test needs is sitting in an inbox it cannot open.

Automating a real mail provider to get it back is a losing approach. Providers actively defend against headless browsers with CAPTCHAs and device checks, and both Cypress and Playwright have real constraints around second tabs and cross-origin navigation. Teams that go this way end up maintaining the workaround instead of testing their product.

The reliable pattern is to stop using a browser for that step and read the message over HTTP instead.

The shape of the solution

Every implementation follows the same four steps:

  1. Provision an inbox over HTTP and keep its address and access token.
  2. Drive the UI through signup using that address.
  3. Poll for the message until one matching your criteria arrives, or a deadline passes.
  4. Extract and use the code or link, then finish the flow in the browser.

The only genuinely tricky step is the third, and almost every flaky email test is a step three that was written as a single read.

Step 1: provision the inbox

A single POST returns an address and a token. Do this inside the test rather than in global setup, so every test gets its own inbox and tests stay independent of each other.

const BASE = 'https://test-emails.com/hcgi/api';

const inboxRes = await request.post(BASE + '/mailbox');
const { address, token } = await inboxRes.json();

Step 2: run the signup

await page.goto('https://staging.your-app.com/signup');
await page.fill('input[name="email"]', address);
await page.click('button[type="submit"]');

Step 3: poll, do not read once

This is the part that decides whether the test is reliable. Delivery is asynchronous, and its timing varies with queue depth, greylisting, and how loaded the staging environment happens to be. A read immediately after submit passes on a fast local run and fails intermittently in CI, which is the worst possible failure mode because it looks like a product bug.

Poll on an interval against a deadline, and match on something specific.

async function waitForMessage(request, token, match, { timeoutMs = 30000, intervalMs = 1000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let lastSeen = 0;

  while (Date.now() < deadline) {
    const res = await request.get(BASE + '/mailbox/messages', {
      headers: { 'x-mail-token': token }
    });

    if (res.status() === 429) {
      await new Promise(r => setTimeout(r, 5000));
      continue;
    }

    if (res.ok()) {
      const { messages } = await res.json();
      lastSeen = messages.length;
      const hit = messages.find(match);
      if (hit) return hit;
    }

    await new Promise(r => setTimeout(r, intervalMs));
  }

  throw new Error('No matching message after ' + timeoutMs + 'ms (saw ' + lastSeen + ' messages)');
}

Including the number of messages actually seen in the failure text is a small detail that saves real debugging time. It immediately separates "nothing was delivered" from "something arrived but did not match", and those have completely different causes.

Step 4a: extracting a one-time code

The message list includes a short intro preview, which is often enough to pull a numeric code without a second request.

const msg = await waitForMessage(request, token, m => /verify|confirm/i.test(m.subject));
const otp = msg.intro.match(/\b(\d{6})\b/)?.[1];
if (!otp) throw new Error('No 6-digit code in: ' + msg.intro);

await page.fill('input[name="otp"]', otp);
await page.click('button:has-text("Verify")');

Anchor the pattern with a word boundary so it cannot match six digits inside a longer number, such as an order reference or a year in the footer.

Step 4b: extracting a magic link

For a link you need the full body, which means fetching the message by id. Prefer the plain-text part when one exists, because it is far more stable than markup that changes whenever someone edits the template.

const full = await (await request.get(BASE + '/mailbox/messages/' + msg.id, {
  headers: { 'x-mail-token': token }
})).json();

const body = full.text || (full.html || []).join(' ');
const link = body.match(/https:\/\/staging\.your-app\.com\/verify\?[^\s"'<>]+/)?.[0];
if (!link) throw new Error('No verification link found in message body');

await page.goto(link);
await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();

Navigate to the URL directly rather than trying to click it inside a rendered email. What you are testing is that the link resolves and authenticates the right account, and a direct navigation tests exactly that.

Scope the pattern to your own host. A permissive URL regex will happily match an unsubscribe link, a tracking pixel, or a logo URL, and the resulting failure is confusing to debug.

The Cypress version

Cypress commands are not promises, so a while loop will not work as written. Recursion is the idiomatic equivalent.

Cypress.Commands.add('waitForMessage', (token, match, attempts = 30) => {
  if (attempts === 0) throw new Error('verification email never arrived');
  return cy.request({
    url: BASE + '/mailbox/messages',
    headers: { 'x-mail-token': token },
    failOnStatusCode: false
  }).then(res => {
    const hit = (res.body.messages || []).find(match);
    if (hit) return hit;
    return cy.wait(1000).then(() => cy.waitForMessage(token, match, attempts - 1));
  });
});

Running in parallel

Per-test inboxes make parallelism safe from a data standpoint, but there is an infrastructure limit to respect. The API allows 100 requests per 5 minute window per IP, and CI runners usually share one egress address across the whole pool.

Ten workers each polling once a second will exhaust that budget in under a minute and start failing every test on the runner. Two adjustments prevent it: use an interval of a second or more rather than a tight loop, and treat a 429 as a signal to back off rather than as a test failure. The helper above does both.

Common causes of flakiness

  • Reading once instead of polling. The single biggest cause. Always poll against a deadline.
  • Taking the newest message. It is not necessarily yours. Match on subject, sender, or a value you injected into the flow.
  • Running a regex over HTML. Read the text part where available; markup changes without warning.
  • Sharing an inbox across tests. This couples tests together and makes failures depend on execution order.
  • Asserting on the domain. Addresses come from a rotating pool, so the domain will change underneath you.
  • Silent timeouts. A test that proceeds without the email produces a confusing downstream failure instead of a clear one. Throw with context.

When not to use this approach

A disposable inbox is not always the right tool, and knowing when to reach for something else saves time.

  • Your application blocks disposable domains. Many do, deliberately. Use a catch-all on a domain you control, or exempt a single test domain from that validation outside production. Do not weaken the production rule to make a test pass.
  • You only need to know that send was called. A stub or a local SMTP catcher is faster and more appropriate for that.
  • The pipeline is business-critical. A free third-party service with no SLA is a fragile dependency for a release gate.
  • Real data is involved. Never route production data, live credentials, or unmasked customer information through a disposable inbox.

Next steps

The email testing guide compares this approach against SMTP catchers and stubs, so you can decide which layer each of your tests belongs in.