← All guides

Capture Email Verification Codes in Playwright Tests

Real emails in E2E tests — no mocks, no SMTP server

Most E2E tests stop at "click submit" because the next step is "check your email." You can stub the email service, but stubs lie — the bug that bites you in production is usually the SMTP misconfig your stub never had. myagentinbox gives every test a real, disposable inbox in one HTTP call. Real emails, real verification codes, real magic links. No mail server to run.

The pattern

  1. Create a disposable inbox at the start of the test.
  2. Submit the address through your signup / login form like a user would.
  3. Poll the inbox until the verification email arrives (typically 5–30 seconds).
  4. Extract the code or magic link from the message body with a regex.
  5. Submit it back to your app and assert success.

A reusable helper

Drop this in a tests/helpers/inbox.ts file. It wraps the four endpoints you'll actually use:

// tests/helpers/inbox.ts
const BASE = "https://myagentinbox.com";

export async function createInbox(): Promise<string> {
  const r = await fetch(`${BASE}/api/inboxes`, { method: "POST" });
  if (!r.ok) throw new Error(`createInbox failed: ${r.status}`);
  const { data } = await r.json();
  return data.address;
}

export async function waitForMessage(
  address: string,
  opts: { timeoutMs?: number; pollMs?: number; matching?: RegExp } = {}
): Promise<{ id: string; from: string; subject: string }> {
  const timeout = opts.timeoutMs ?? 60_000;
  const poll = opts.pollMs ?? 3_000;
  const deadline = Date.now() + timeout;

  while (Date.now() < deadline) {
    const r = await fetch(`${BASE}/api/inboxes/${address}/messages`);
    if (r.ok) {
      const { data } = await r.json();
      const match = opts.matching
        ? data.find((m: any) => opts.matching!.test(m.subject))
        : data[0];
      if (match) return match;
    }
    await new Promise((res) => setTimeout(res, poll));
  }
  throw new Error(`No message arrived in ${address} within ${timeout}ms`);
}

export async function readMessage(address: string, id: string) {
  const r = await fetch(`${BASE}/api/inboxes/${address}/messages/${id}`);
  if (!r.ok) throw new Error(`readMessage failed: ${r.status}`);
  const { data } = await r.json();
  return data; // { from, subject, text, html, attachments, ... }
}

export function extractCode(body: string, pattern = /\b(\d{6})\b/): string {
  const m = body.match(pattern);
  if (!m) throw new Error("No verification code found in message body");
  return m[1];
}

export function extractMagicLink(body: string, host: string): string {
  const re = new RegExp(`https?://${host.replace(/\./g, "\\.")}/[^\\s"<>]+`, "i");
  const m = body.match(re);
  if (!m) throw new Error(`No magic link for host ${host} found`);
  return m[0];
}

Test 1: signup with a 6-digit code

import { test, expect } from "@playwright/test";
import {
  createInbox,
  waitForMessage,
  readMessage,
  extractCode,
} from "./helpers/inbox";

test("user can sign up and verify with a 6-digit code", async ({ page }) => {
  const email = await createInbox();

  await page.goto("https://app.example.com/signup");
  await page.getByLabel("Email").fill(email);
  await page.getByLabel("Password").fill("Test-Password-1234");
  await page.getByRole("button", { name: "Create account" }).click();

  await expect(page.getByText("Check your email")).toBeVisible();

  const msg = await waitForMessage(email, {
    matching: /verify|verification|confirm/i,
    timeoutMs: 60_000,
  });
  const full = await readMessage(email, msg.id);
  const code = extractCode(full.text);

  await page.getByLabel("Verification code").fill(code);
  await page.getByRole("button", { name: "Verify" }).click();

  await expect(page).toHaveURL(/\/dashboard/);
});

Test 2: passwordless login via magic link

test("user can log in via magic link", async ({ page, context }) => {
  const email = await createInbox();

  await page.goto("https://app.example.com/login");
  await page.getByLabel("Email").fill(email);
  await page.getByRole("button", { name: "Send magic link" }).click();

  const msg = await waitForMessage(email, {
    matching: /sign in|magic|login/i,
  });
  const full = await readMessage(email, msg.id);

  const link = extractMagicLink(full.html ?? full.text, "app.example.com");

  // Open the magic link in a fresh tab to mimic the user clicking from email
  const newPage = await context.newPage();
  await newPage.goto(link);
  await expect(newPage).toHaveURL(/\/dashboard/);
});
Why open the link in a new tab? Some auth flows tie the magic-link session to the browser context that requested it. Opening it in the same context (via context.newPage()) replicates how the user actually clicks the link — navigating page directly works for most apps but fails on a few.

Test 3: end-to-end onboarding with attachments

If your app emails the user a PDF receipt or onboarding doc, you can pull that down inside the test too:

test("welcome email includes onboarding PDF", async ({ page }) => {
  const email = await createInbox();

  await page.goto("https://app.example.com/signup");
  // ... fill form, submit ...

  const msg = await waitForMessage(email, { matching: /welcome/i });
  const full = await readMessage(email, msg.id);

  expect(full.attachments.length).toBeGreaterThan(0);
  const pdf = full.attachments.find((a: any) => a.filename.endsWith(".pdf"));
  expect(pdf).toBeDefined();

  const r = await fetch(
    `https://myagentinbox.com/api/inboxes/${email}/messages/${msg.id}/attachments/${pdf.filename}`
  );
  const buf = Buffer.from(await r.arrayBuffer());
  expect(buf.slice(0, 4).toString()).toBe("%PDF");
});

Running tests in parallel

Each createInbox() call returns a unique address, so parallel test workers don't collide. The relevant rate limits, scoped per IP:

If your CI is hammering the rate limits, the simplest fix is to point a couple of staggered workers at the same suite rather than maxing out parallelism.

Handling flakiness

What about Cypress, WebdriverIO, Puppeteer?

The helper module is plain fetch — no Playwright dependency. Drop the same file into a Cypress support/, a WebdriverIO custom command, or a Puppeteer script and it works as-is. Only the test-runner-specific glue (test(), page.goto, etc.) changes.

Limits