Email testing guides

Hands-on email testing guides for developers and QA teams

← All testing guides
Developer Guides

Email Testing with Playwright: Sign Up, Read the Inbox, Click the Link

October 28, 2026·10 min read
A happy developer at a laptop says the confirmation email is tested too, next to a terminal showing three passing Playwright tests.

Most Playwright suites stop at the "Check your inbox" screen. The test signs up, sees the message and calls it a day, while the part that actually activates the account (the email and the link inside it) never runs. Then someone edits the email template, the link breaks, and nobody notices until sign-ups drop.

In this guide we close that gap with Playwright Test, TypeScript and Mailpit. Each test signs up with its own address, waits for the confirmation email, opens the link and checks that the user is signed in. Along the way we build a small helper module you can reuse for password resets, invitations and magic links.

What we are building

  1. In tests and CI, the app under test sends its email to Mailpit, an open-source mail catcher, on SMTP port 1025.
  2. Each test makes up a unique address, such as signup-3f2b…@example.test, and signs up with it.
  3. The helper searches Mailpit's REST API (port 8025) for mail to that address until the confirmation email shows up.
  4. It reads the full message and pulls out the confirmation link.
  5. The test opens the link in the same browser page and asserts the logged-in state.

Nothing leaves your machine or the CI runner, and mail lands in Mailpit within milliseconds. The .test domain is reserved for testing by RFC 2606, so even a misconfigured run can't email a real person. If you are still deciding which email cases are worth automating, our guide on testing email verification has a checklist.

Project setup

Any recent Playwright version works. If you're starting fresh, npm init playwright@latest gives you a working project. Locally, start Mailpit with Docker (docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit) and open http://localhost:8025 to watch mail arrive while the tests run. The config uses Playwright's webServer option to start the app with its SMTP settings pointed at Mailpit. Use whatever setting names your app reads.

TypeScript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 1 : 0,
  reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    env: { SMTP_HOST: 'localhost', SMTP_PORT: '1025', APP_URL: 'http://localhost:3000' },
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

The helper module

All the Mailpit logic lives in one file, so your specs stay readable. It uses three calls from the Mailpit API: GET /api/v1/search with a query such as to:"…" subject:"…" (newest first, not case sensitive), GET /api/v1/message/{ID} for the full message, and DELETE /api/v1/search to tidy up. First, the types and the search call:

TypeScript
// tests/support/mailpit.ts
import { expect, type APIRequestContext } from '@playwright/test';

const MAILPIT = process.env.MAILPIT_URL ?? 'http://localhost:8025';

export interface MailpitAddress { Name: string; Address: string }

export interface MailpitMessage {
  ID: string;
  From: MailpitAddress;
  To: MailpitAddress[];
  Subject: string;
  HTML: string; // '' when the email has no HTML part
  Text: string;
}

export function newAddress(prefix: string): string {
  return `${prefix}-${crypto.randomUUID()}@example.test`;
}

async function search(request: APIRequestContext, query: string): Promise<{ ID: string }[]> {
  const res = await request.get(`${MAILPIT}/api/v1/search`, { params: { query } });
  expect(res.ok(), `Mailpit search failed: HTTP ${res.status()}`).toBe(true);
  return (await res.json()).messages; // newest first
}

export async function deleteEmailsTo(request: APIRequestContext, to: string) {
  await request.delete(`${MAILPIT}/api/v1/search`, { params: { query: `to:"${to}"` } });
}

Next, waiting and link extraction. waitForEmail is built on expect.poll, which calls a function again and again until its result passes the matcher or the timeout runs out. Mailpit's message IDs are random strings, not counters, so the count option handles "the next email": after a resend, wait until there are two matching emails and take the newest.

TypeScript
export interface WaitOptions {
  subject?: string; // Mailpit matches this case-insensitively
  count?: number; // e.g. 2 for the second matching email
  timeout?: number;
}

export async function waitForEmail(
  request: APIRequestContext, to: string, opts: WaitOptions = {},
): Promise<MailpitMessage> {
  const { subject, count = 1, timeout = 10_000 } = opts;
  const query = subject ? `to:"${to}" subject:"${subject}"` : `to:"${to}"`;
  let newest = '';
  await expect.poll(async () => {
    const found = await search(request, query);
    newest = found[0]?.ID ?? '';
    return found.length;
  }, {
    message: `Waiting for ${count} email(s) matching ${query}`,
    timeout,
    intervals: [250, 500, 1_000],
  }).toBeGreaterThanOrEqual(count);
  const res = await request.get(`${MAILPIT}/api/v1/message/${newest}`);
  expect(res.ok()).toBe(true);
  return res.json();
}

export function extractLink(email: MailpitMessage, pattern: RegExp): string {
  const hrefs = [...email.HTML.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
  const plain = email.Text.match(/https?:\/\/[^\s<>"')]+/g) ?? [];
  const link = [...hrefs, ...plain]
    .map((url) => url.replaceAll('&amp;', '&'))
    .find((url) => pattern.test(url));
  if (!link) throw new Error(`No link matching ${pattern} in "${email.Subject}"`);
  return link;
}

Two small things in extractLink save real debugging time. Links in HTML encode & as &amp;, so a token followed by &amp;lang=en would break without the replace. And it looks at both the HTML and the plain-text body, because some emails have no HTML part and some templates only put the full URL in one of the two.

A fixture that gives every test its own address

Playwright fixtures are the natural home for the address. A test that asks for inbox gets a fresh one; a test that doesn't ask pays nothing. The address goes into the test's annotations, so it shows up in the HTML report. After a passing test the fixture deletes that address's mail. After a failing one it leaves the mail in Mailpit, so you can open the web UI and see what arrived.

TypeScript
// tests/support/fixtures.ts
import { test as base } from '@playwright/test';
import { deleteEmailsTo, newAddress } from './mailpit';

export const test = base.extend<{ inbox: string }>({
  inbox: async ({ request }, use, testInfo) => {
    const address = newAddress('signup');
    testInfo.annotations.push({ type: 'inbox', description: address });
    await use(address);
    if (testInfo.status === testInfo.expectedStatus) {
      await deleteEmailsTo(request, address); // only this test's mail
    }
  },
});

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

The sign-up test

Here is the full flow against our pretend bookshop app. Swap the labels, button names and URLs for your own; the structure stays the same.

TypeScript
// tests/signup-confirmation.spec.ts
import type { Page } from '@playwright/test';
import { test, expect } from './support/fixtures';
import { extractLink, waitForEmail } from './support/mailpit';

async function signUp(page: Page, address: string) {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(address);
  await page.getByLabel('Password', { exact: true }).fill('c0rrect-Horse-battery');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your inbox')).toBeVisible();
}

test('new user confirms their email and lands signed in',
  async ({ page, request, inbox, baseURL }) => {
    await signUp(page, inbox);

    const email = await waitForEmail(request, inbox, { subject: 'Confirm your email' });
    expect(email.From.Address).toBe('[email protected]');

    const link = extractLink(email, /\/verify\?token=/);
    expect(new URL(link).origin).toBe(new URL(baseURL!).origin); // not prod

    await page.goto(link);
    await expect(page).toHaveURL(/\/welcome/);
    await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible();

    // A spent link must not sign anyone in a second time.
    await page.context().clearCookies();
    await page.goto(link);
    await expect(page.getByText(/already (used|confirmed)/i)).toBeVisible();
    await expect(page.getByRole('button', { name: 'Account menu' })).toBeHidden();
  });

A few choices worth explaining. The Mailpit calls go through the request fixture, not page.request, so they don't share cookies with the browser session you are testing. The origin check catches the classic "staging emails link to production" bug, because the link is built from the app's APP_URL setting. And the last block checks that the link is single-use, which takes four lines and catches a surprising number of real bugs.

Read the link the way a user sees it. Instead of a regex, you can open a second page, call setContent(email.HTML) and use getByRole('link', { name: 'Confirm email' }) to read the button's href. That also fails the test if someone renames the button to "Click here".

Timeouts: short is fine now

  • Poll timeout. expect.poll uses the normal expect timeout, 5 seconds, unless you pass one. waitForEmail defaults to 10 seconds, which is generous for a local catcher. If mail hasn't arrived by then, the app didn't send it, and you get a clear message ("Waiting for 1 email(s) matching to:… ") instead of a generic "Test timeout exceeded".
  • Poll interval. intervals: [250, 500, 1_000] checks right away, then after 250 ms, 500 ms more, and every second after that. Most emails are already there on the second check.
  • Test timeout. The default 30 seconds is plenty. You don't need test.slow() for email tests any more, because nothing waits on real delivery.

Parallel workers, one shared Mailpit

All workers send to the same Mailpit, and that's fine. Every test has its own address and every search filters on it, so two workers signing up at the same moment never see each other's mail. The resend test below goes in the same spec file and shows the count option in action:

TypeScript
test('resending makes the first link useless', async ({ page, request, inbox }) => {
  await signUp(page, inbox);
  const first = await waitForEmail(request, inbox, { subject: 'Confirm your email' });

  await page.getByRole('button', { name: 'Resend email' }).click();
  const second = await waitForEmail(request, inbox, { subject: 'Confirm your email', count: 2 });
  expect(second.ID).not.toBe(first.ID);

  await page.goto(extractLink(first, /\/verify\?token=/));
  await expect(page.getByText(/link is invalid|link has expired/i)).toBeVisible();

  await page.goto(extractLink(second, /\/verify\?token=/));
  await expect(page).toHaveURL(/\/welcome/);
});
  • Don't wipe the whole mailbox between tests. DELETE /api/v1/messages with no IDs deletes every message, including mail another worker is waiting for right now. Delete by search, as the fixture does.
  • Mind the message cap. Mailpit keeps 500 messages by default and deletes the oldest beyond that. A big suite can pass that mark, so raise it with MP_MAX_MESSAGES in CI.
  • Prefixes help debugging. newAddress('reset') or newAddress('invite') makes the Mailpit web UI easy to scan when you are hunting for one test's mail.

Running it in CI

In GitHub Actions, Mailpit runs as a service container next to the job. The official axllent/mailpit image has a built-in health check, so the runner waits until it is ready before your steps start.

YAML
# .github/workflows/e2e.yml
name: e2e
on:
  push:
    branches: [main]
  pull_request:
jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    services:
      mailpit:
        image: axllent/mailpit:v1.32
        ports:
          - 1025:1025
          - 8025:8025
        env:
          MP_MAX_MESSAGES: 5000
    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
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7
  • Hostnames: the job runs straight on the runner, so the mapped ports are on localhost and the config above works unchanged. If your job runs inside a container:, use the service name instead (mailpit:1025 and http://mailpit:8025).
  • SMTP settings: Mailpit needs no TLS and no login by default. If your mail library insists on authenticating, set MP_SMTP_AUTH_ACCEPT_ANY: 1 and MP_SMTP_AUTH_ALLOW_INSECURE: 1 on the service.
  • HTTP-only mail providers: if your app sends through a provider's HTTP API instead of SMTP, give it an SMTP transport for the test environment. Most mail libraries make that a one-line config switch.
  • Retries: one retry with trace: 'on-first-retry' gives you a full trace when something flakes. A retry gets a fresh address from the fixture, so it is a clean rerun.

Before a release: one real delivery check by hand

Mailpit tells you that your app builds the right email with a working link. It can't tell you that the email gets delivered. In CI nothing touches your real provider, your DNS records or a spam filter, so a revoked API key, a sandbox flag left on in staging, or a missing DKIM record all pass straight through a green suite.

So before each release, spend five minutes on staging with a real inbox. Open temp-email.ai in a new tab, sign up with the address it gives you, and watch the confirmation email arrive (no refresh needed). Check the sender name, the subject and the link's host, then click it. Need a second user? Open another tab or a private window for a second inbox. If your app blocks disposable domains at sign-up, allow snapmailnow.com on staging.

Keep this part manual: a person looking at a real email once per release catches things no assertion is looking for. Keep it to test data, too, since anyone with an inbox's link can read it. If the email is slow, lands in spam or never shows up, check your provider's logs and your SPF, DKIM and DMARC records; Google's email sender guidelines are a good checklist even if your users aren't on Gmail.

When a test fails: quick troubleshooting

  • The poll times out and Mailpit's web UI is empty. The app isn't sending to Mailpit. Check the SMTP settings in webServer.env, and whether reuseExistingServer picked up a dev server you started earlier with different settings.
  • The email is in the web UI, but the poll doesn't find it. Compare the address in the report annotation with the one in the email. A typo in the fill step, or an app that rewrites addresses (lowercasing, trimming), are the usual causes.
  • The token is rejected as invalid. Look for a leftover &amp;, or a trailing ) or > picked up from the plain-text body.
  • A test finds someone else's email. Two tests share an address. Make sure every address comes from newAddress(), never from a constant.

The same helpers carry over to other flows with almost no changes. A reset test is "request, wait, extract, open, assert", and our guide to testing password reset flows lists the security cases worth covering. Cypress users can follow the Cypress version of this guide.

That's the whole setup: one helper file, one fixture, a spec and a Mailpit service in CI, plus a few minutes with a real inbox before each release. Start with the sign-up test, watch it go green, then add the next flow your users depend on. Happy testing!