Test email verification with Playwright

Email verification is one of the harder flows to cover in E2E tests. You need a real inbox, a real email to arrive, and a way to extract the link or OTP from it — all inside a test that must be fast, repeatable, and leave no state behind.

The tempy.email API gives you programmable disposable inboxes. Create one per test, point your signup flow at it, fetch the verification email, and assert on it. The inbox expires automatically; no teardown needed.

Prerequisites

The pattern

Every email verification test follows the same shape:

  1. Create a disposable mailbox before the test
  2. Use that address in your signup or verification flow
  3. Poll for the email (or receive it over a webhook)
  4. Extract the verification link or OTP from the email body
  5. Complete the flow and assert the outcome

A small helper

You can call the API directly with fetch. A thin wrapper keeps tests readable:

// tests/helpers/tempy.ts

const API_BASE = 'https://api.tempy.email/v1';
const API_KEY  = process.env.TEMPY_API_KEY!;

export interface Mailbox {
  email: string;
  expires_at: string;
  seconds_remaining: number;
}

export interface Message {
  id: string;
  subject: string;
  from: string;
  text: string;
  html: string;
  received_at: string;
}

/** Create a fresh disposable inbox. */
export async function createMailbox(): Promise<Mailbox> {
  const res = await fetch(`${API_BASE}/mailbox`, {
    method: 'POST',
    headers: { 'X-Api-Key': API_KEY },
  });
  if (!res.ok) throw new Error(`tempy: failed to create mailbox (${res.status})`);
  return res.json();
}

/**
 * Poll for the first message in a mailbox.
 * Throws if no message arrives within `timeout` ms.
 */
export async function waitForMessage(
  email: string,
  { timeout = 30_000, interval = 1_500 }: { timeout?: number; interval?: number } = {},
): Promise<Message> {
  const deadline = Date.now() + timeout;
  while (Date.now() < deadline) {
    const res = await fetch(
      `${API_BASE}/mailbox/${encodeURIComponent(email)}/messages`,
      { headers: { 'X-Api-Key': API_KEY } },
    );
    if (!res.ok) throw new Error(`tempy: failed to fetch messages (${res.status})`);
    const messages: Message[] = await res.json();
    if (messages.length > 0) return messages[0];
    await new Promise(r => setTimeout(r, interval));
  }
  throw new Error(`tempy: no message arrived in ${email} within ${timeout}ms`);
}

Testing a verification link flow

// tests/signup.spec.ts

import { test, expect } from '@playwright/test';
import { createMailbox, waitForMessage } from './helpers/tempy';

test('user can sign up and verify their email', async ({ page }) => {
  // 1. Fresh inbox for this test run
  const { email } = await createMailbox();

  // 2. Fill in the signup form
  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('hunter2-but-longer');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your email')).toBeVisible();

  // 3. Wait for the verification email to arrive
  const message = await waitForMessage(email, { timeout: 30_000 });

  // 4. Extract the verification link from the HTML body
  const match = message.html.match(/href="(https?:\/\/[^"]*verif[^"]*)"/i);
  expect(match, 'No verification link found in email').toBeTruthy();
  const verifyUrl = match![1];

  // 5. Follow it and assert the account is active
  await page.goto(verifyUrl);
  await expect(page.getByText('Email verified')).toBeVisible();
  await expect(page).toHaveURL('/dashboard');
});

Testing an OTP flow

For flows that send a numeric code instead of a link:

test('user can log in with an OTP', async ({ page }) => {
  const { email } = await createMailbox();

  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByRole('button', { name: 'Send code' }).click();

  const message = await waitForMessage(email);

  // Extract the OTP — adjust the pattern to match your format
  const otp = message.text.match(/\b(\d{6})\b/)?.[1];
  expect(otp, 'OTP not found in email body').toBeDefined();

  await page.getByLabel('Code').fill(otp!);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page).toHaveURL('/dashboard');
});

Using webhooks instead of polling

If you can expose a local server in your test environment, webhooks eliminate the polling loop entirely. Create the mailbox with a webhook_url and the email is pushed to you the moment it arrives:

const { email } = await fetch(`${API_BASE}/mailbox`, {
  method: 'POST',
  headers: {
    'X-Api-Key': API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ webhook_url: 'https://your-tunnel.example.com/webhook' }),
}).then(r => r.json());

Tools like ngrok or localtunnel can expose a local endpoint during a CI run. This approach is faster than polling and removes the retry loop entirely.

Setting the API key in CI

Add TEMPY_API_KEY as a secret in your CI environment and pass it through to Playwright:

GitHub Actions:

- name: Run Playwright tests
  run: npx playwright test
  env:
    TEMPY_API_KEY: ${{ secrets.TEMPY_API_KEY }}

GitLab CI:

test:
  script: npx playwright test
  variables:
    TEMPY_API_KEY: $TEMPY_API_KEY

The helper reads process.env.TEMPY_API_KEY at runtime, so no other changes are needed.

Tips

One mailbox per test. Create a fresh inbox in each test case, not a shared one across the suite. Isolation prevents flaky failures when tests run in parallel.

Keep the timeout generous. Email delivery usually takes under three seconds, but give yourself 30 seconds in CI to account for queue conditions — and set Playwright's timeout for the test itself to something higher than your waitForMessage timeout.

Skip teardown. Mailboxes expire automatically. You do not need to delete them in afterEach.

Use text for OTP extraction, html for link extraction. The plain-text body is easier to regex against for codes; the HTML body preserves full link URLs with their query strings.

Check subject to target the right email. If your flow sends multiple emails (welcome + verification), match on message.subject to make sure you are acting on the right one.


Next: Test email verification with Cypress · API reference