Skip to content

Test transactional email

Use case: signup sends a confirmation link, password reset sends a token, an order sends a receipt. The user journey leaves the browser — and your test must follow it into the inbox and back.

Playwright ends at the browser. The email leaves your app via SMTP, and there’s nothing to locator() on. Sending real email from CI is worse: slow, rate-limited, spam-filtered, and it leaks test traffic to real providers.

Run a capturing SMTP server and read what your app sent through its REST API. Mailpit is the modern standard (successor to MailHog):

docker-compose.test.yml
services:
mailpit:
image: axllent/mailpit
ports:
- '1025:1025' # SMTP — point your app here
- '8025:8025' # Web UI + REST API

Point the app under test at smtp://localhost:1025, then:

import { test, expect } from '@playwright/test';
const MAILPIT = 'http://localhost:8025/api/v1';
async function waitForEmail(request, to: string, subject: RegExp) {
return expect
.poll(async () => {
const res = await request.get(`${MAILPIT}/search?query=to:${to}`);
const { messages } = await res.json();
return messages?.find((m) => subject.test(m.Subject)) ?? null;
}, { timeout: 15_000 })
.not.toBeNull()
.then(async () => {
const res = await request.get(`${MAILPIT}/search?query=to:${to}`);
const { messages } = await res.json();
return messages.find((m) => subject.test(m.Subject));
});
}
test('signup confirmation link works', async ({ page, request }) => {
const email = `user-${Date.now()}@example.test`;
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Sign up' }).click();
// Find the message…
const msg = await waitForEmail(request, email, /confirm your account/i);
// …extract the link from the HTML body…
const detail = await (await request.get(`${MAILPIT}/message/${msg.ID}`)).json();
const link = detail.HTML.match(/href="([^"]*\/confirm[^"]*)"/)?.[1];
expect(link).toBeTruthy();
// …and complete the journey in the browser.
await page.goto(link!);
await expect(page.getByText('Account confirmed')).toBeVisible();
});
  • Delivery + addressing — right recipient, right subject; unique addresses per test (user-${Date.now()}@…) keep runs independent.
  • The link round-trip — the token in the email actually works, once, and expires.
  • Content — assert on data (names, amounts) with contains-matchers, not full-body snapshots; templates churn.
  • Negative cases — unsubscribe honored, no email on failed signup. GET /api/v1/messages after a short wait proves silence.

Mailpit can screenshot the HTML body — pipe it into your visual-testing flow for template regressions. For real-client rendering (Outlook…), that’s a specialized service (Litmus etc.), not CI territory.

  • Reset Mailpit between tests (DELETE /api/v1/messages) or scope every query by recipient.
  • If the app is in a container, the SMTP host is mailpit:1025, not localhost.
  • Staging environments that send through a real provider (SES, SendGrid) can often be pointed at Mailpit’s SMTP instead — same assertion code, before and after deploy.