Blog

Test email OTP in Playwright — no polling, no shared Gmail

2026-07-30

Every OTP test starts the same way: the app under test emails a code, and your test needs to read it. The classic answers all hurt. A shared Gmail account works until Google decides your CI datacenter looks suspicious. An IMAP library adds a flaky dependency and a polling loop. And waitForTimeout(5000) is just a coin flip with extra steps.

Here's the whole thing with a disposable inbox you name on the spot.

The recipe

Your account has a handle — a namespace like witty-otter-7 (generated free; custom names like bluepine are a paid option). Every scenario under it is an inbox with no creation step: witty-otter-7.otp_code_on_login@mailmatter.dev starts receiving the moment anything sends to it, and the part after the dot names the test case so parallel flows stay separate.

import { test, expect } from "@playwright/test";

const INBOX = "witty-otter-7.otp_code_on_login"; // <handle>.<scenario>
const API = "https://mailmatter.dev/api";

test("user can log in with an emailed OTP", async ({ page, request }) => {
  const since = Date.now();

  // 1. Trigger the send in the app under test
  await page.goto("https://staging.bluepine.app/login");
  await page.fill("#email", `${INBOX}@mailmatter.dev`);
  await page.click("text=Send code");

  // 2. Block until the mail lands — long-poll, not a poll loop
  const res = await request.get(
    `${API}/mailbox/${INBOX}/wait?since=${since}&timeout=30`,
    { headers: { Authorization: `Bearer ${process.env.MAILMATTER_KEY}` } },
  );
  const { message } = await res.json();
  expect(message, "OTP email never arrived").not.toBeNull();

  // 3. Extract and use the code
  const otp = message.text?.match(/\b(\d{6})\b/)?.[1];
  expect(otp, `no 6-digit code in: ${message.subject}`).toBeTruthy();
  await page.fill("#otp", otp!);
  await page.click("text=Verify");
  await expect(page).toHaveURL(/dashboard/);
});

Three details do the heavy lifting:

  • since=Date.now() — the wait only resolves for mail newer than the moment you triggered the send, so a code from the previous run can never satisfy this run's assertion.
  • /wait long-polls server-side (up to 55s per request; loop it for longer). Your test blocks exactly as long as delivery takes — no retry loop, no timing window, no flakiness budget.
  • The scenario name is the test case. When the run fails, the address in the trace tells you which flow broke.

When you're debugging instead of asserting, the same mail is one click away in the browser — here's the OTP the test above just read:

The OTP email in the MailMatter web inbox — the same message the Playwright test reads over the API

MAILMATTER_KEY is an org API key — free accounts get API access, so this costs nothing. (Anonymous inboxes work too — the mint response's secret authorizes the same endpoints — but a key you set once in CI secrets is the comfortable path.)

Parallel runs without crosstalk

Sharded CI hitting the same scenario? Tag the outgoing mail with a header in your app's test config (X-Test-Run: ${RUN_ID}) and filter the listing by it:

GET /api/mailbox/bluepine.otp_code_on_login?header=X-Test-Run:42

That's a durable link from a CI run to exactly the mail it produced — assert on it, or pull it up when archaeology is needed.

Magic links instead of codes?

Same recipe, one change: the parsed message exposes links[], already extracted from the HTML body. page.goto(message.links[0]) and you're through the flow — MailMatter never visits links on its own (nothing is fetched, no tracking pixel fires), so the token is still unconsumed when your test uses it.

If your suite would rather be pushed than poll at all, webhooks on the Pro plan POST the moment a message lands. And if an AI agent is driving the browser instead of a test runner, the same inbox speaks MCP.

Open an inbox and run the recipe — no signup, no card.