Introduction
Email verification is one of the trickiest parts of end-to-end testing. When users sign up, confirm their account, or reset their password, they receive emails—and your tests need to verify that those emails arrive with the correct content, links, and codes. Testing emails in E2E tests: what developers should know starts with a simple principle: capture what users receive without sending real messages to real mailboxes.
In this guide, we'll walk through setting up a local mail server, querying messages via API, extracting dynamic links and codes, and building reliable email verification into your Playwright test suite.
Why email testing matters in E2E workflows
Email is a critical user journey in most applications. Sign-up confirmation, password resets, two-factor authentication codes, and transactional receipts all depend on email delivery. If your tests skip email verification, you're leaving a significant gap in your coverage.
The challenge is that traditional approaches either:
- Send real emails to test accounts (slow, unreliable, pollutes real inboxes)
- Mock the email service entirely (tests pass but you don't verify the actual email was composed correctly)
- Use external services like Mailgun or SendGrid test modes (adds complexity and external dependencies)
A local mail server running in Docker solves all three problems. You get real emails in a controlled, fast, inspectable environment—no external services required.
Use a test mail server like Mailpit
Mailpit is a lightweight, open-source mail server designed for testing. It accepts SMTP connections, stores emails in memory, and exposes them via a REST API and web UI. It's perfect for local development and CI/CD pipelines.
Setting up Mailpit with Docker:
// docker-compose.yml for your test environment
version: '3.8'
services:
mailpit:
image: axllent/mailpit:latest
ports:
- "1025:1025" // SMTP port
- "8025:8025" // Web UI and API port
environment:
MP_MAX_WORKERS: 4Then update your application's email configuration to point to Mailpit during testing:
// config.ts (test environment)
export const emailConfig = {
host: process.env.MAIL_HOST || 'localhost',
port: process.env.MAIL_PORT || 1025,
secure: false,
auth: undefined, // Mailpit doesn't require auth in test mode
};
// In your NestJS mailer setup or similar
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport(emailConfig);Start the container before your test suite runs, and all emails sent by your application will be captured by Mailpit instead of trying to reach real SMTP servers.
Query messages via API
Once Mailpit is running and capturing emails, you can fetch them through its HTTP API. This is where your Playwright tests connect the dots.
Basic email retrieval:
// mailpit.ts - helper utilities
async function getLatestEmail(recipientEmail: string) {
const response = await fetch('http://localhost:8025/api/v1/messages');
const data = await response.json();
// Filter for the specific recipient
const email = data.messages.find((msg: any) =>
msg.To.some((to: any) => to.Address === recipientEmail)
);
if (!email) {
throw new Error(`No email found for ${recipientEmail}`);
}
return email;
}
async function getAllEmailsForRecipient(recipientEmail: string) {
const response = await fetch('http://localhost:8025/api/v1/messages');
const data = await response.json();
return data.messages.filter((msg: any) =>
msg.To.some((to: any) => to.Address === recipientEmail)
);
}The API returns full email metadata including sender, subject, timestamp, and both HTML and plain-text bodies. You can then assert on any of these fields:
// test.spec.ts - verification example
import { test, expect } from '@playwright/test';
import { getLatestEmail } from './mailpit';
test('user receives confirmation email on signup', async ({ page }) => {
const testEmail = 'user@example.com';
// Navigate and sign up
await page.goto('http://localhost:3000/signup');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', 'SecurePass123!');
await page.click('button:has-text("Sign Up")');
// Give the email server time to process
await page.waitForTimeout(1000);
// Fetch and verify the email
const email = await getLatestEmail(testEmail);
expect(email.Subject).toContain('Confirm your email');
expect(email.From[0].Address).toBe('noreply@myapp.com');
});Extract links and codes
The real power of email testing emerges when you extract dynamic content—confirmation links, reset tokens, OTP codes—and use them in your tests.
Parsing confirmation links from HTML:
// mailpit.ts - link extraction
async function extractConfirmationLink(recipientEmail: string): Promise {
const email = await getLatestEmail(recipientEmail);
// Email body is in email.HTML
const htmlBody = email.HTML;
// Look for href in confirmation button or link
const match = htmlBody.match(/href="([^"]*confirm[^"]*)"/i);
if (!match || !match[1]) {
throw new Error('No confirmation link found in email');
}
return match[1];
}
async function extractOTPCode(recipientEmail: string): Promise {
const email = await getLatestEmail(recipientEmail);
const textBody = email.Text;
// Common OTP patterns: 6-digit code, alphanumeric token, etc.
const match = textBody.match(/code[:\s]+(\d{6})/i);
if (!match || !match[1]) {
throw new Error('No OTP code found in email');
}
return match[1];
}Using extracted links in tests:
test('user can confirm email via link from confirmation email', async ({ page }) => {
const testEmail = 'user@example.com';
// Sign up
await page.goto('http://localhost:3000/signup');
await page.fill('input[name="email"]', testEmail);
await page.fill('input[name="password"]', 'SecurePass123!');
await page.click('button:has-text("Sign Up")');
await page.waitForTimeout(1000);
// Extract confirmation link
const confirmLink = await extractConfirmationLink(testEmail);
// Navigate to confirmation URL
await page.goto(confirmLink);
// Verify account is now confirmed
await expect(page.locator('text=Email confirmed successfully')).toBeVisible();
});For password reset flows, extract the token and construct the reset URL. For multi-factor authentication, extract the code and enter it in the form. The pattern is identical: parse, extract, use.
Never send real emails from tests
This is the non-negotiable rule. Your tests must always use a local mail server or mock. Sending real emails from automated tests causes multiple problems:
- Spam complaints: Test runs generate dozens or hundreds of emails to real addresses, which get marked as spam and damage your sender reputation.
- Rate limiting: Real SMTP services throttle high-volume sends and may block your test infrastructure.
- Test isolation: Tests become flaky if they depend on external services or if multiple test runs interfere with each other.
- Slow execution: Real email delivery takes seconds; local mail servers are instantaneous.
Enforce this at the config level. If your application has an environment variable controlling the mail host, make sure your test configuration explicitly overrides it:
// playwright.config.ts
export default defineConfig({
webServer: {
command: 'npm run dev',
env: {
NODE_ENV: 'test',
MAIL_HOST: 'localhost',
MAIL_PORT: '1025',
MAIL_FROM: 'test@myapp.com',
// Explicitly prevent any real SMTP credentials in test mode
SMTP_USER: '',
SMTP_PASS: '',
},
},
});Better yet, add a guard in your application startup that logs a warning or errors out if real SMTP credentials are detected in test mode:
// app.ts or main.ts
if (process.env.NODE_ENV === 'test' && process.env.SMTP_PASS) {
throw new Error(
'SMTP credentials detected in test mode. This will send real emails. ' +
'Clear SMTP_USER and SMTP_PASS when NODE_ENV=test'
);
}Common mistakes
1. Not waiting for email delivery: Email processing is asynchronous. Your test might query the API before the message is stored. Always add a reasonable delay or poll the API with retry logic:
async function waitForEmail(
recipientEmail: string,
subject: string,
maxWaitMs: number = 5000
): Promise {
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
const emails = await getAllEmailsForRecipient(recipientEmail);
const email = emails.find((e: any) => e.Subject.includes(subject));
if (email) return email;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Email with subject "${subject}" not found after ${maxWaitMs}ms`);
}2. Fragile regex patterns for link/code extraction: If your confirmation links change format, your regex breaks. Use HTML parsing libraries like cheerio or jsdom for robustness:
import { JSDOM } from 'jsdom';
async function extractLinkByTestId(recipientEmail: string, testId: string): Promise {
const email = await getLatestEmail(recipientEmail);
const dom = new JSDOM(email.HTML);
const link = dom.window.document.querySelector(`[data-testid="${testId}"]`);
if (!link || !link.href) {
throw new Error(`Link with data-testid="${testId}" not found`);
}
return link.href;
}3. Not cleaning up old emails between tests: If Mailpit accumulates thousands of emails, queries slow down and tests may grab the wrong message. Use the Mailpit API to delete messages:
async function clearAllEmails() {
await fetch('http://localhost:8025/api/v1/messages', { method: 'DELETE' });
}
// In your test setup
test.beforeEach(async () => {
await clearAllEmails();
});4. Hardcoding email addresses: Tests should use unique, deterministic email addresses (e.g., with a timestamp or test ID). This prevents collision when tests run in parallel:
function generateTestEmail(testName: string): string {
const timestamp = Date.now();
return `test-${testName}-${timestamp}@local.test`;
}Key takeaways
- Use a containerized mail server like Mailpit to capture test emails locally without external dependencies.
- Query the Mailpit API to fetch email metadata, bodies, and verify content in your Playwright tests.
- Extract dynamic links and codes from email HTML or text using robust parsing libraries, not fragile regex.
- Never send real emails from test environments; enforce this with configuration guards and CI/CD environment variables.
- Poll the email API with retry logic to handle asynchronous email delivery; don't rely on fixed delays.
- Clean up old emails between test runs to keep API queries fast and prevent cross-test contamination.
- Use unique, deterministic email addresses for each test to support parallel execution.






Comments
Loading comments…