If you've ever tried to write a Playwright test that covers a full sign-up → email verification → login flow, you've hit the same wall: how do you actually read the email your app sends during a test?
This guide covers three approaches — from the classic self-hosted SMTP trap to a zero-infrastructure option — with working Playwright code for each.
The problem
Your app sends a verification email. Your Playwright test needs to:
- Intercept that email
- Extract the verification link
- Navigate to it
- Assert the account is now verified
Mocking the email at the API level works for unit tests, but it doesn't test the real delivery path. For true end-to-end coverage you need to catch a real email.
Option 1: MailHog
MailHog was the go-to for years — a fake SMTP server with a web UI and HTTP API. The problem: it's unmaintained and requires a running Docker container in your CI environment.
Setup:
Add to your docker-compose.yml:
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # HTTP API
Playwright test:
import { test, expect } from '@playwright/test';
test('email verification flow', async ({ page }) => {
const testEmail = `test-${Date.now()}@example.com`;
// Sign up
await page.goto('/signup');
await page.fill('[name="email"]', testEmail);
await page.fill('[name="password"]', 'TestPassword123!');
await page.click('[type="submit"]');
// Poll MailHog API for the email
let verificationUrl: string | null = null;
for (let i = 0; i < 10; i++) {
await page.waitForTimeout(1000);
const res = await fetch('http://localhost:8025/api/v2/messages');
const data = await res.json();
const message = data.items?.find((m: any) =>
m.Content?.Headers?.To?.[0]?.includes(testEmail)
);
if (message) {
const body = message.Content.Body;
const match = body.match(/https?:\/\/\S+verify\S+/);
verificationUrl = match?.[0] ?? null;
break;
}
}
if (!verificationUrl) throw new Error('Verification email not received');
// Click the verification link
await page.goto(verificationUrl);
await expect(page).toHaveURL('/dashboard');
});
The catch: MailHog needs to be running in your CI pipeline. That means a Docker service in your GitHub Actions workflow, added startup time, and another thing to maintain.
Option 2: Mailpit
Mailpit is the modern, maintained replacement for MailHog. Single static binary, cleaner API, actively developed. Same concept — local SMTP trap — but better.
Setup:
mailpit:
image: axllent/mailpit
ports:
- "1025:1025"
- "8025:8025"
Playwright test:
import { test, expect } from '@playwright/test';
test('email verification flow', async ({ page }) => {
const testEmail = `test-${Date.now()}@example.com`;
await page.goto('/signup');
await page.fill('[name="email"]', testEmail);
await page.fill('[name="password"]', 'TestPassword123!');
await page.click('[type="submit"]');
// Poll Mailpit API
let verificationUrl: string | null = null;
for (let i = 0; i < 10; i++) {
await page.waitForTimeout(1000);
const res = await fetch('http://localhost:8025/api/v1/messages');
const data = await res.json();
const message = data.messages?.find((m: any) =>
m.To?.[0]?.Address === testEmail
);
if (message) {
const detail = await fetch(
`http://localhost:8025/api/v1/message/${message.ID}`
);
const full = await detail.json();
const match = full.Text?.match(/https?:\/\/\S+verify\S+/);
verificationUrl = match?.[0] ?? null;
break;
}
}
if (!verificationUrl) throw new Error('Verification email not received');
await page.goto(verificationUrl);
await expect(page).toHaveURL('/dashboard');
});
Better than MailHog, but you still need Docker in CI. If your pipeline already uses Docker Compose this is the right choice.
Option 3: ZeroDrop — no Docker, no SMTP, no config
If you don't want to run any infrastructure at all, ZeroDrop generates a disposable inbox at the edge (Cloudflare + Redis) and gives you an SDK to poll it directly from your test.
No SMTP server. No Docker container. No CI config changes. Just an npm package.
Install:
npm install zerodrop-client
Playwright test:
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';
test('email verification flow', async ({ page }) => {
const mail = new ZeroDrop();
const inbox = mail.generateInbox();
const testEmail = inbox; // e.g. swift-x7k2m@zerodrop-sandbox.online
await page.goto('/signup');
await page.fill('[name="email"]', testEmail);
await page.fill('[name="password"]', 'TestPassword123!');
await page.click('[type="submit"]');
// Wait for the verification email — no polling loop needed
const email = await mail.waitForLatest(inbox, { timeout: 10000 });
// Extract the verification link
const match = email.body.match(/https?:\/\/\S+verify\S+/);
if (!match) throw new Error('No verification link found in email');
await page.goto(match[0]);
await expect(page).toHaveURL('/dashboard');
});
The difference: waitForLatest handles the polling internally. Your test reads like synchronous code. No Docker service, no extra CI config, no SMTP port to expose.
Free tier: shared domain, 30-minute email TTL, no signup required.
Comparison
| MailHog | Mailpit | ZeroDrop | |
|---|---|---|---|
| Maintained | ✗ | ✓ | ✓ |
| Docker required | ✓ | ✓ | ✗ |
| CI config changes | ✓ | ✓ | ✗ |
| npm SDK | ✗ | ✗ | ✓ |
| Real edge delivery | ✗ | ✗ | ✓ |
| Free | ✓ | ✓ | ✓ |
| Custom domains | ✗ | ✗ | ✓ (paid) |
Which should you use?
- Already using Docker Compose in CI → Mailpit. It's the best self-hosted option and integrates cleanly with your existing setup.
- No Docker in CI / want zero infrastructure → ZeroDrop. Drop in the SDK and your test works in any environment with no config.
- MailHog → migrate away. It's unmaintained and Mailpit does everything it does better.
GitHub Actions example (ZeroDrop)
Since there's no container to spin up, your workflow stays clean:
- name: Install dependencies
run: npm ci
- name: Run Playwright tests
run: npx playwright test
env:
BASE_URL: http://localhost:3000
No services: block. No health checks. No port mappings. The ZeroDrop SDK handles everything over HTTPS.
ZeroDrop is open source — SDK at npmjs.com/package/zerodrop-client, live sandbox at zerodrop.dev.
Top comments (0)