TEST RUNNERS

Verify OTP in Playwright without writing a single regex

The worst bug in a Playwright suite is usually the email part of the signup test. You pick an address your test owns, submit the form, and then sit there polling Gmail, parsing \d{6} out of an HTML body, and hoping the message shows up before the timeout. This post shows a cleaner path: three HTTP calls, no regex, no inbox of your own.

The problem Playwright cannot solve on its own

Playwright drives the browser. The verification email lives in someone else’s inbox. You can:

  1. Share a Gmail across the team, parse with IMAP + regex. Works, fragile, leaks credentials.
  2. Mock the provider. Breaks when the provider changes their template. Not a real test.
  3. Use a shared “plus-addressing” inbox like [email protected]. Rate-limited, not isolated, still requires IMAP.
  4. Use a disposable-inbox API. The address is real, the test owns it, the API returns the code.

This post takes option 4 with MailSink. Any similar service (MailSlurp, Mailosaur) works with the same shape; the endpoint names differ.

Three endpoints you need

From your Playwright test, you’re going to hit three endpoints on api.mailsink.dev:

# 1. Create an inbox for this test
POST /v1/inboxes

# 2. Use the returned address in the signup flow
# (this is Playwright clicking around)

# 3. Block until the verification code arrives
GET /v1/inboxes/:id/wait-for-code?timeout=30

That’s the whole integration. No regex. The OTP extractor runs server-side at ingest.

Setup

Sign in at mailsink.dev with GitHub, copy the API key. Add it to your test env:

# .env.test
MAILSINK_API_KEY=msk_your_key_here

Install no new runtime deps; Playwright’s request helper handles HTTP.

A Playwright fixture

Writing this as a fixture keeps tests clean. Drop it in tests/fixtures/inbox.ts:

import { test as base, request as playwrightRequest } from "@playwright/test";

type InboxFixtures = {
  inbox: {
    id: string;
    address: string;
    waitForCode: (timeoutSeconds?: number) => Promise<string>;
  };
};

export const test = base.extend<InboxFixtures>({
  inbox: async ({}, use) => {
    const apiKey = process.env.MAILSINK_API_KEY;
    if (!apiKey) throw new Error("MAILSINK_API_KEY is required");

    const api = await playwrightRequest.newContext({
      baseURL: "https://api.mailsink.dev",
      extraHTTPHeaders: { Authorization: `Bearer ${apiKey}` },
    });

    const created = await api.post("/v1/inboxes");
    const inbox = await created.json();

    await use({
      id: inbox.id,
      address: inbox.address,
      waitForCode: async (timeoutSeconds = 30) => {
        const r = await api.get(
          `/v1/inboxes/${inbox.id}/wait-for-code?timeout=${timeoutSeconds}`,
        );
        const body = await r.json();
        if (!body.code) {
          throw new Error(`No verification code after ${timeoutSeconds}s`);
        }
        return body.code;
      },
    });

    await api.delete(`/v1/inboxes/${inbox.id}`);
    await api.dispose();
  },
});

export { expect } from "@playwright/test";

The fixture provisions a fresh inbox for each test, exposes the address, provides a waitForCode helper, and cleans up when the test exits. The delete call is a courtesy; TTL would drop it automatically.

The actual test

Here’s a full signup test for an imaginary product at https://example.com/signup:

import { test, expect } from "./fixtures/inbox";

test("new user signs up with email verification", async ({ page, inbox }) => {
  await page.goto("https://example.com/signup");

  await page.getByLabel("Email").fill(inbox.address);
  await page.getByLabel("Password").fill("TestPassword123!");
  await page.getByRole("button", { name: "Create account" }).click();

  // Waits for the OTP to arrive at MailSink. No regex, no polling.
  const code = await inbox.waitForCode(45);

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

  await expect(page).toHaveURL(/\/dashboard/);
  await expect(page.getByText("Welcome")).toBeVisible();
});

Run it: npx playwright test. Test owns its own inbox. No shared state with other tests. No IMAP. No regex.

Handling timeouts

wait-for-code blocks up to 60 seconds. Most verification emails arrive within 3-5 seconds, so 30-45 is a comfortable default.

If you hit the timeout in CI, the common culprits are:

GitHub Actions

The pattern in CI:

# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]

jobs:
  playwright:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          MAILSINK_API_KEY: ${{ secrets.MAILSINK_API_KEY }}

Store the API key as a GitHub Actions secret. The fixture reads it from the environment.

Parallel tests

Playwright’s default is to run tests in parallel. Each test calls the fixture independently, so each test gets a unique inbox. No collision risk.

On the MailSink free tier you get 50 inboxes/month total, which is tight if you run a full suite locally and in CI. The Pro plan’s 2,000 inboxes/month covers about 60-80 CI runs per day for a mid-size suite.

What to assert

A few things worth checking beyond “the code arrived”:

test("welcome email content looks right", async ({ page, inbox }) => {
  await page.goto("https://example.com/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Create account" }).click();

  const code = await inbox.waitForCode();

  // Fetch the full message to assert on subject, from, preview
  const r = await page.request.get(
    `https://api.mailsink.dev/v1/inboxes/${inbox.id}/messages`,
    {
      headers: {
        Authorization: `Bearer ${process.env.MAILSINK_API_KEY}`,
      },
    },
  );
  const { messages } = await r.json();

  expect(messages[0].subject).toBe("Verify your email");
  expect(messages[0].from_address).toBe("[email protected]");
  expect(messages[0].has_code).toBe(1);
});

Use /v1/messages/:id if you need the full body for richer assertions (HTML structure, tracking pixels, etc.).

FAQ

Why not just use Gmail with IMAP?

You can, but you own credentials and rate limits. Parallel CI runs against a single Gmail account fight over IMAP sessions. Each Playwright test getting a fresh disposable inbox is simpler and isolated.

Does MailSink work with other frameworks?

Yes. There’s nothing Playwright-specific. The API is HTTP, so Cypress, Puppeteer, Selenium, Vitest, Jest, Mocha all work. Cypress guide is coming.

Can I receive emails at a custom domain I already own?

Not yet. BYOD is on the roadmap for Pro+. Join the waitlist.

Same pattern. Swap wait-for-code for wait-for-link. The response has link instead of code. You can click it directly with page.goto(link).

How do I avoid leaking the API key in logs?

Playwright does not print env vars by default. If you log inbox anywhere, only id and address show up. The API key never leaves the server-side fixture context. As extra protection, store the key in a GitHub secret and never echo it from workflows.

Next