TempMaily.co
Developers

Playwright Email Testing: Verify Signups End-to-End

TempMaily Team11 min read

Playwright can drive your signup form, but it cannot open the inbox that your app emails a code to. To test email verification end-to-end, you pair Playwright with a disposable-mail API: mint a real address per test, use it in the form, poll the API for the message, extract the code, type it back into the browser, and assert. This post is the working setup — a reusable helper module and a full @playwright/test spec — with the trade-offs spelled out.

Quick answer

Playwright email testing works like this: before the test runs, call a disposable-inbox API to create a real, unique address. Fill your signup form with that address and submit. The app sends its verification email; your test polls the mail API until the message lands, extracts the one-time code (OTP) with a scoped regex, types it into the verification field, and asserts the account is confirmed. Tear the inbox down afterward.

Poll from inside the test rather than wiring up webhooks — the test is already blocking, so a bounded expect.poll loop is the natural fit. Give each test its own inbox so parallel workers never collide. The rest of this guide is the code and the hardening that keeps it green in CI.

Why you can't test email with Playwright alone

Playwright is a browser automation tool. It clicks, types, navigates, and asserts on the DOM — and that is the whole of its world. The verification email your app sends lives entirely outside the browser: it goes to a mail server, gets delivered to a mailbox, and never touches the page Playwright is looking at. So the moment your signup flow says "check your email for a code," an unaided Playwright test hits a wall it has no way over.

Teams usually try one of three workarounds, and each one bites:

The fix is to keep the flow real and give the test a mailbox it can actually read from code. A disposable-mail API does exactly that: each test creates its own throwaway inbox, uses the address in the browser, and reads the delivered message back over HTTP. Playwright owns the browser half; the API owns the email half; nothing is mocked and nothing is shared.

The worked example

Here is a setup you can drop into a @playwright/test project. It has two parts: a small helper module that talks to the TempMaily API, and a spec that uses it to test a signup-and-verify flow end to end.

The helper is intentionally self-contained — plain fetch, no SDK — so you can read every request. Every endpoint, header, and field name below matches the API reference; reconcile against that page if anything changes.

// tests/helpers/mailbox.ts
const API = "https://tempmaily.co/api/v1";
const KEY = process.env.XTM_KEY; // Premium API key, xtm_-prefixed

if (!KEY) throw new Error("XTM_KEY is not set");

const headers = {
  Authorization: `Bearer ${KEY}`,
  "Content-Type": "application/json",
};

export interface Inbox {
  id: string;
  address: string;
  expiresAt: string;
}

// Create a fresh disposable inbox. Each call returns a unique address, so
// every test (and every parallel worker) gets its own mailbox.
export async function createInbox(): Promise<Inbox> {
  const res = await fetch(`${API}/inboxes`, {
    method: "POST",
    headers,
    body: JSON.stringify({ expiry: "1d" }),
  });
  if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
  const { id, address, expiresAt } = await res.json();
  return { id, address, expiresAt };
}

// Poll the inbox until a message matching `match` arrives, then return its
// full body. The rate limit is 300 requests / 60s per key, so a 2s interval
// leaves plenty of headroom even with many workers polling at once.
export async function waitForMessage(
  inboxId: string,
  match: (subject: string) => boolean,
  { timeoutMs = 30_000, intervalMs = 2_000 } = {},
): Promise<{ bodyText: string; bodyHtml: string; subject: string }> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const list = await fetch(`${API}/inboxes/${inboxId}/messages`, { headers });
    if (!list.ok) throw new Error(`list messages failed: ${list.status}`);
    const { messages = [] } = await list.json();
    const hit = messages.find((m: { subject: string }) => match(m.subject));
    if (hit) {
      // Fetch the full message — the list endpoint returns summaries only.
      const full = await fetch(`${API}/messages/${hit.id}`, { headers });
      if (!full.ok) throw new Error(`get message failed: ${full.status}`);
      return full.json(); // { subject, bodyText, bodyHtml, ... }
    }
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`No matching message in inbox ${inboxId} within ${timeoutMs}ms`);
}

// Pull a six-digit OTP out of the message. Prefer the text part; fall back to
// HTML. Anchor to nearby copy so a footer number never matches first.
export function extractCode(message: {
  bodyText: string;
  bodyHtml: string;
}): string {
  const body = message.bodyText || message.bodyHtml || "";
  const anchored = body.match(/code(?:\s+is)?[:\s]+(\d{6})/i);
  if (anchored) return anchored[1];
  const bare = body.match(/\b(\d{6})\b/);
  if (bare) return bare[1];
  throw new Error("No 6-digit code found in message body");
}

// Delete the inbox and its mail once the test is done.
export async function deleteInbox(inboxId: string): Promise<void> {
  const res = await fetch(`${API}/inboxes/${inboxId}`, {
    method: "DELETE",
    headers,
  });
  // 204 on success; 404 means it was already gone — both are fine in teardown.
  if (!res.ok && res.status !== 404) {
    throw new Error(`deleteInbox failed: ${res.status}`);
  }
}

Now the spec. It creates an inbox in beforeEach, runs the real signup, waits for the code, verifies, and asserts — then cleans up in afterEach. The one subtlety worth calling out is the poll: rather than build a second loop inside the test, we let expect.poll retry the "did the code arrive and did verification succeed" step, and delegate the actual mail wait to waitForMessage.

// tests/signup-verification.spec.ts
import { test, expect } from "@playwright/test";
import { createInbox, waitForMessage, extractCode, deleteInbox } from "./helpers/mailbox";
import type { Inbox } from "./helpers/mailbox";

test.describe("signup email verification", () => {
  let inbox: Inbox;

  test.beforeEach(async () => {
    inbox = await createInbox();
  });

  test.afterEach(async () => {
    if (inbox) await deleteInbox(inbox.id);
  });

  test("user can sign up and confirm their email", async ({ page }) => {
    // 1. Drive the real signup form with the disposable address.
    await page.goto("/signup");
    await page.getByLabel("Email").fill(inbox.address);
    await page.getByLabel("Password").fill("Sufficiently-Long-Pw-9!");
    await page.getByRole("button", { name: "Create account" }).click();

    await expect(page.getByText(/check your email/i)).toBeVisible();

    // 2. Wait for the verification email and pull out the OTP.
    const message = await waitForMessage(
      inbox.id,
      (subject) => /verify|confirm|code/i.test(subject),
      { timeoutMs: 30_000, intervalMs: 2_000 },
    );
    const code = extractCode(message);
    expect(code).toMatch(/^\d{6}$/);

    // 3. Complete verification in the browser and assert the result.
    await page.getByLabel("Verification code").fill(code);
    await page.getByRole("button", { name: "Verify" }).click();

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByText(/email verified/i)).toBeVisible();
  });
});

That is the whole flow: a real inbox, a real email, a real code typed into a real browser, asserted end to end. No mock, no shared mailbox, no human copying a code out of Gmail.

Hardening against flaky email tests

The example above passes on a good day. The difference between that and a suite that stays green for a year is in the edges.

Set the timeout against real mail latency, not a guess. Verification mail usually lands in a second or two, but a cold sender, a queued job, or a busy CI runner can push it to ten or more. A 30-second timeoutMs gives comfortable headroom; a 5-second one turns a slightly slow delivery into a red build. Measure your own p99 delivery time and set the bound above it, not at it.

Isolate an inbox per worker, not per suite. Because createInbox runs in beforeEach, every test — and therefore every parallel worker Playwright spins up — gets a unique address. That is the whole reason parallel email tests stop colliding. Never move inbox creation up to a shared beforeAll that hands one address to many tests; you will reintroduce exactly the race condition disposable inboxes exist to kill.

Tear inboxes down. The afterEach calls DELETE /inboxes/{id}, which removes the inbox and its mail and frees your account's storage quota. Treat 404 as success — if a test failed before the inbox was created, the delete is a no-op, and teardown should never throw over that.

Keep the key in a CI secret. The API key is a Bearer credential; it belongs in an environment variable your CI injects, never in the repo. In GitHub Actions, store it as a repository secret and expose it to the job:

# .github/workflows/e2e.yml
name: e2e
on: [push]
jobs:
  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          XTM_KEY: ${{ secrets.XTM_KEY }}

Know when to switch to webhooks. Polling is right for a test runner because the test is already waiting. But if you are building something that reacts to inbound mail outside a test loop — a staging service, a background worker, a demo bot — polling is the wrong shape and a webhook is the right one. Register a public HTTPS endpoint once and TempMaily POSTs each message to it as it arrives, verified with an X-Xtm-Signature header. The API reference covers webhook registration and signature verification; inside a Playwright spec, stay with polling.

Common mistakes

An honest caveat

Two things worth being straight about. First, free inboxes expire — the default lifetime is a day, which is plenty for a test that runs and asserts in seconds, but it means you should never hardcode an address and expect it to survive between runs. Create a fresh one each time; that is the pattern here anyway. Second, some staging environments block known disposable domains at signup, which is the whole point of that defense — and it means your own app may reject a free TempMaily address before the flow even starts. If that is happening to you, it is working as designed; we wrote up the mechanics in why websites block temp mail. For tests, the fix is to use a Premium dedicated or custom domain that your app treats as legitimate, so the verification path behaves exactly as it would for a real user.

Next steps

Pairing Playwright with a disposable-mail API turns email verification from the flakiest step in your suite into one more assertion. Create an isolated inbox per test, drive the real form, poll for the message, extract the OTP with a scoped regex, verify in the browser, and tear the inbox down. If you want the framework-agnostic version of this pattern — the general poll-and-extract flow and the webhook deep-dive — the companion guide on automating email verification with the temp mail API covers it.

Programmatic access is part of TempMaily Premium, and the API reference is the source of truth for every endpoint, field, and limit used above. Start from the homepage if you are new to disposable inboxes, then wire the helper into your @playwright/test project and delete the "we skip email in CI" line from your test plan.

Frequently asked questions

How do I test email verification in Playwright?

Playwright drives the browser, so it can fill and submit your signup form, but it cannot read the inbox. Bridge the gap with a disposable-mail API: create a real inbox before the test, use its address in the form, then poll the API for the verification message, extract the code, type it back into the browser, and assert the account reaches a verified state. All of that runs inside a normal @playwright/test spec.

Can Playwright read emails?

Not on its own. Playwright automates a browser and has no access to a mail server, IMAP, or an inbox. To read a verification email in a Playwright test you call a mail API from inside the test (with fetch or Playwright's request context) that returns the message your app just sent. Playwright handles the browser half of the flow; the mail API handles the email half.

How do I get a test email address in Playwright?

Call a disposable-inbox API at the start of the test and use the address it returns. TempMaily's POST /inboxes endpoint mints a fresh, unique address on every call, so each test and each parallel worker gets its own inbox with no collisions. Do not hardcode an address — a reused inbox carries stale state and breaks parallel runs.

How do I extract an OTP code from an email in Playwright?

Fetch the full message from the API, then run a scoped regular expression over its bodyText (falling back to bodyHtml). For a six-digit OTP, anchor the pattern to nearby copy such as 'your code is' rather than matching any six digits, otherwise a year or a support-line number in the footer can match first. Return the captured group and type it into the verification field.

Should I poll or use webhooks for email in Playwright tests?

Poll. A Playwright test is already blocking and waiting for a result, so a bounded retry loop with expect.poll fits the model and keeps everything in one process. Webhooks are for push-based automation outside a test runner — background jobs or services that react to inbound mail. Reach for webhooks when there is no test loop to hold open, not inside a spec.

How do I stop email tests from being flaky?

Give every test its own inbox so parallel workers never cross wires, bound the poll with a timeout that comfortably exceeds real mail latency, scope the code regex tightly, and tear the inbox down in an afterEach. Load the API key from a CI secret, never a committed file, and use a domain your app accepts so its own validation does not reject the address.

Get a free disposable inbox

A live throwaway address, no signup, real-time delivery. Upgrade to Premium for custom domains, forwarding, and no expiry.