Guides / Playwright email verification

Testing email verification in Playwright

Tested 13 Sep 2026 · Playwright 1.63 · Node 23 · inboxsink 0.1.0

Your signup form sends a six-digit code, and the test has to type it in. Most suites deal with that using await page.waitForTimeout(5000) and a look into a shared inbox. It holds until the mail provider has a slow minute, or until two workers read the same inbox.

This page shows the version we run instead. Each test gets its own address, the test blocks until the email is actually stored, and the code comes back already pulled out of the message. Everything below ran on 13 September 2026 against a small signup app sending real email through Mailjet.

What the run looked like

Three signups in parallel on three workers, plus two magic-link sign-ins. Unedited output:

Running 5 tests using 3 workers

#1 1f6f1624c080@mailhusk.com code=360492 after 1619 ms
#2 59694fd86195@mailhusk.com code=102927 after 1622 ms
  ✓  tests/signup.spec.js › a new user can sign up with an emailed code #1 (2.9s)
  ✓  tests/signup.spec.js › a new user can sign up with an emailed code #2 (2.9s)
magic link extracted: http://localhost:4173/magic-link?token=ba0d7a21230255a664c02a9681dc8dc1
  ✓  tests/magic.spec.js › a user can sign in with a magic link (3.5s)
#3 da6140eb68be@mailhusk.com code=330805 after 1285 ms
short link — extracted link field: null
  ✓  tests/signup.spec.js › a new user can sign up with an emailed code #3 (1.8s)
  ✓  tests/magic.spec.js › a link with no keyword in its URL is not extracted: parse the body (1.8s)

  5 passed (6.1s)

The figure to look at is the time between the click and the code: 1.3 to 1.6 seconds, Mailjet's own sending time included (1.8 s on an earlier run). A fixed five-second sleep wastes three to four seconds per test when delivery is quick, and fails when it isn't.

Setup

  1. Install both packages: npm i -D @playwright/test inboxsink
  2. Create a free account and an API key. 1,000 calls a month, no card.
  3. Expose the key as INBOXSINK_API_KEY: in a local .env, and as a secret in CI.

Every authenticated request counts as one call. The test below makes three (create, wait, delete), so the free tier covers about 330 runs of it a month.

Signup with a 6-digit code

The spec from the run, without the loop and the timing log:

import { test, expect } from '@playwright/test';
import { InboxSink } from 'inboxsink';

const sink = new InboxSink(); // reads INBOXSINK_API_KEY

test('a new user can sign up with an emailed code', async ({ page }) => {
  const inbox = await sink.createInbox({ ttlSeconds: 900 });

  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Create account' }).click();

  const code = await sink.waitForOtp(inbox.id, { timeoutMs: 60_000 });

  await page.getByLabel('Verification code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByRole('heading', { name: 'Welcome aboard' })).toBeVisible();

  await sink.deleteInbox(inbox.id);
});

Keep the test timeout above the wait

Playwright gives each test 30 seconds by default, and waitForOtp also waits 30 seconds by default. The page load and the clicks have already used part of the test's budget, so when no email comes the test timeout fires first. We checked: the report says Test timeout of 30000ms exceeded, which tells you nothing about the email, instead of No message arrived before the timeout.

// playwright.config.js
export default defineConfig({
  timeout: 90_000,   // above the longest waitForOtp in the suite
  use: { baseURL: 'http://localhost:3000' },
});

The server holds a single wait for 120 seconds at most. When the delay runs out it answers 204 with no body, and the client turns that into the error above.

Why one inbox per test

With a shared inbox — a public Mailinator name, one Gmail account for the whole suite — two workers that sign up in the same second both read "the latest message", and one of them types the other's code. With a single worker you never see it. It starts when the suite runs in parallel or gets sharded.

In the run above, three workers created three addresses and got three different codes (360492, 102927, 330805). Nothing to lock, nothing to clear between tests.

test('a user can sign in with a magic link', async ({ page }) => {
  const inbox = await sink.createInbox({ ttlSeconds: 900 });

  await page.goto('/login');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Email me a sign-in link' }).click();

  const link = await sink.waitForLink(inbox.id, { timeoutMs: 60_000 });
  await page.goto(link);

  await expect(page.getByRole('heading', { name: `Signed in as ${inbox.address}` })).toBeVisible();
});

One limit, and the second magic-link test in the run exists to show it: a link is only picked out when its URL says what it is for — confirm, verify, activate, validate, reset, password, magic, sign-in or login. /magic-link?token=… came back; /t/<token> came back as null. Links rewritten by an email provider's click tracking end up in the same case, because the original path is no longer visible in the URL.

For those, read the message body yourself. Note that waitForMessage returns null on timeout instead of throwing:

const summary = await sink.waitForMessage(inbox.id, { timeoutMs: 60_000 });
expect(summary).not.toBeNull();

const message = await sink.getMessage(summary.id);
const link = message.text.match(/https?:\/\/\S+/)[0];
await page.goto(link);

When the code is not detected

A number is only reported as a code when the email announces it: a word such as code, verification, OTP, PIN, sign in or login in the 60 characters before it, just after it, or in the subject. That rule is what keeps an order total or a phone number from being taken for a code.

If your template says "Here is your number: 482913" and nothing else in the email or its subject says code, waitForOtp throws. Either put the word in the email — your users read it too — or call waitForMessage and apply your own pattern to message.text.

In CI

- name: End-to-end tests
  run: npx playwright test
  env:
    INBOXSINK_API_KEY: ${{ secrets.INBOXSINK_API_KEY }}

Two failures show up in CI and not on your machine:

Without the JavaScript client

The client is a thin layer over plain HTTP, so Python, Java or a shell script can do the same thing. The responses below are real, trimmed:

# create an inbox, private to your key
curl -s -X POST https://inboxsink.com/v1/inboxes \
  -H "Authorization: Bearer $INBOXSINK_API_KEY" -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 900}'
→ {"id":"32433","address":"…@mailhusk.com","expires_at":"…"}

# block until the email lands — 204 if nothing arrives within the timeout
curl -s "https://inboxsink.com/v1/inboxes/32433/wait?timeout=60000" \
  -H "Authorization: Bearer $INBOXSINK_API_KEY"
→ {"message":{"id":"…","subject":"Your Acme verification code","otp":"482913","link":null,…}}

All routes, errors and limits are in the API reference.

Free API key

1,000 calls a month, no card asked. Enough to wire it into your test suite and see if it holds.

Then, when your tests start getting refused: a domain of your own is published nowhere, so it lands on no blocklist — 29 € a month.