Automated Email Testing with Playwright and IMAP: OTP Flows, Attachments, and Inbox Assertions
Playwright can automate the browser half of email flows—fill out forms, trigger emails—but it can't read inboxes. Connecting Playwright to IMAP lets you complete the loop: trigger an action in the browser, wait for the email to arrive via IMAP, extract the OTP or link, then continue the test. This guide covers the full pattern for OTP verification, email link clicks, and attachment testing.
Why IMAP Email Testing
Most end-to-end tests stop at "submit form and check confirmation page." But for many flows—signup verification, password reset, invoice delivery—the email itself is part of the user journey. If the email doesn't arrive, or arrives with the wrong link, the test should fail.
Connecting Playwright tests to a real inbox via IMAP closes this gap. The test drives the browser normally, then pauses to read the email, extracts data from it, and continues in the browser with that data.
Two approaches:
- IMAP on a real inbox — use a dedicated test Gmail/Outlook account, connect via IMAP
- Mailpit IMAP — run Mailpit locally/in CI; it exposes port 1143 as an IMAP server
This guide covers both.
Setting Up Mailpit with IMAP
Mailpit is a local email catcher with built-in IMAP support—no need for a real mail provider:
docker run -d \
-p 1025:1025 \ # SMTP
-p 8025:8025 \ <span class="hljs-comment"># Web UI
-p 1143:1143 \ <span class="hljs-comment"># IMAP
axllent/mailpitIMAP credentials for Mailpit:
- Host:
localhost - Port:
1143 - Username: any string (Mailpit accepts everything)
- Password: any string
- No TLS required for local testing
The IMAP Helper
Install imapflow:
npm install --save-dev imapflow mailparser// tests/helpers/imap.js
const { ImapFlow } = require('imapflow');
const { simpleParser } = require('mailparser');
async function createClient(config = {}) {
const client = new ImapFlow({
host: config.host || 'localhost',
port: config.port || 1143,
secure: config.secure || false,
auth: {
user: config.user || 'test',
pass: config.pass || 'test',
},
logger: false,
});
await client.connect();
return client;
}
async function waitForEmail({ to, subject, timeoutMs = 10000 }) {
const start = Date.now();
let client;
try {
client = await createClient();
await client.mailboxOpen('INBOX');
while (Date.now() - start < timeoutMs) {
const messages = [];
for await (const message of client.fetch('1:*', { envelope: true, source: true })) {
const toAddresses = message.envelope.to?.map(a => a.address.toLowerCase()) || [];
const msgSubject = message.envelope.subject || '';
const toMatch = !to || toAddresses.includes(to.toLowerCase());
const subjectMatch = !subject || msgSubject.includes(subject);
if (toMatch && subjectMatch) {
const parsed = await simpleParser(message.source);
messages.push(parsed);
}
}
if (messages.length > 0) {
// Return the most recently received match
return messages[messages.length - 1];
}
await new Promise(r => setTimeout(r, 500));
}
throw new Error(
`No email received for ${to} with subject "${subject}" within ${timeoutMs}ms`
);
} finally {
if (client) await client.logout();
}
}
async function clearInbox() {
let client;
try {
client = await createClient();
await client.mailboxOpen('INBOX');
// Mark all messages for deletion and expunge
const lock = await client.getMailboxLock('INBOX');
try {
await client.messageDelete('1:*', { uid: false });
} finally {
lock.release();
}
} finally {
if (client) await client.logout();
}
}
function extractOtp(emailText, pattern = /\b\d{6}\b/) {
const match = emailText.match(pattern);
if (!match) throw new Error(`OTP not found in email. Body: ${emailText}`);
return match[0];
}
function extractLink(emailHtml, linkPattern) {
const matches = emailHtml.match(linkPattern);
if (!matches) throw new Error(`Link matching ${linkPattern} not found in email`);
return matches[0];
}
module.exports = { waitForEmail, clearInbox, extractOtp, extractLink };Testing OTP Verification Flows
One-time passwords are a prime target for email testing—if the OTP doesn't arrive or your code doesn't parse it correctly, users can't log in.
// tests/otp-login.test.js
const { test, expect } = require('@playwright/test');
const { waitForEmail, clearInbox, extractOtp } = require('./helpers/imap');
test.beforeEach(async () => {
await clearInbox();
});
test('user can log in with OTP from email', async ({ page }) => {
// Step 1: Navigate to login
await page.goto('http://localhost:3000/login');
// Step 2: Enter email to trigger OTP
await page.fill('[name="email"]', 'test@example.com');
await page.click('button[type="submit"]');
// Step 3: Verify the "check your email" UI state
await expect(page.locator('[data-testid="otp-sent-message"]')).toBeVisible();
// Step 4: Wait for the OTP email
const email = await waitForEmail({
to: 'test@example.com',
subject: 'Your login code',
timeoutMs: 8000,
});
// Step 5: Extract the OTP
const otp = extractOtp(email.text);
// Step 6: Enter the OTP in the browser
await page.fill('[name="otp"]', otp);
await page.click('button[type="submit"]');
// Step 7: Assert successful login
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('[data-testid="user-greeting"]')).toContainText('test@example.com');
});
test('expired OTP shows error', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'test@example.com');
await page.click('button[type="submit"]');
// Enter a wrong OTP (simulating expired or incorrect code)
await page.fill('[name="otp"]', '000000');
await page.click('button[type="submit"]');
await expect(page.locator('[data-testid="otp-error"]')).toContainText('Invalid or expired code');
});Testing Email Verification Links
For signup flows where users click a link in the email to verify their address:
// tests/signup-verification.test.js
const { test, expect } = require('@playwright/test');
const { waitForEmail, clearInbox, extractLink } = require('./helpers/imap');
test('signup sends verification email and link activates account', async ({ page }) => {
await clearInbox();
// Complete signup form
await page.goto('http://localhost:3000/signup');
await page.fill('[name="email"]', 'newuser@example.com');
await page.fill('[name="password"]', 'SecurePass123!');
await page.click('button[type="submit"]');
// Wait for email
const email = await waitForEmail({
to: 'newuser@example.com',
subject: 'Verify your email',
timeoutMs: 10000,
});
// Extract verification link
const verifyLink = extractLink(
email.html,
/https?:\/\/[^"'\s]+\/verify\?token=[a-zA-Z0-9_-]+/
);
expect(verifyLink).toContain('/verify?token=');
// Navigate to the link in the same browser context (preserves session if needed)
await page.goto(verifyLink);
// Assert account is verified
await expect(page.locator('[data-testid="verification-success"]')).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
});
test('verification link is single-use', async ({ page, context }) => {
await clearInbox();
// Trigger signup
await page.goto('http://localhost:3000/signup');
await page.fill('[name="email"]', 'once@example.com');
await page.fill('[name="password"]', 'SecurePass123!');
await page.click('button[type="submit"]');
const email = await waitForEmail({ to: 'once@example.com', timeoutMs: 10000 });
const verifyLink = extractLink(email.html, /\/verify\?token=[a-zA-Z0-9_-]+/);
// First use — should succeed
await page.goto(`http://localhost:3000${verifyLink}`);
await expect(page.locator('[data-testid="verification-success"]')).toBeVisible();
// Second use — should fail
const page2 = await context.newPage();
await page2.goto(`http://localhost:3000${verifyLink}`);
await expect(page2.locator('[data-testid="link-expired"]')).toBeVisible();
});Testing Password Reset Flows
// tests/password-reset.test.js
const { test, expect } = require('@playwright/test');
const { waitForEmail, clearInbox, extractLink } = require('./helpers/imap');
test('full password reset flow', async ({ page }) => {
await clearInbox();
// Request password reset
await page.goto('http://localhost:3000/forgot-password');
await page.fill('[name="email"]', 'user@example.com');
await page.click('button[type="submit"]');
await expect(page.locator('[data-testid="reset-email-sent"]')).toBeVisible();
// Get the reset email
const email = await waitForEmail({
to: 'user@example.com',
subject: 'Reset your password',
timeoutMs: 10000,
});
const resetLink = extractLink(email.html, /\/reset-password\?token=[a-zA-Z0-9_-]+/);
// Follow reset link
await page.goto(`http://localhost:3000${resetLink}`);
// Set new password
await page.fill('[name="password"]', 'NewSecurePass456!');
await page.fill('[name="confirm_password"]', 'NewSecurePass456!');
await page.click('button[type="submit"]');
await expect(page.locator('[data-testid="password-updated"]')).toBeVisible();
// Verify old password no longer works
await page.goto('http://localhost:3000/login');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'OldPassword123!');
await page.click('button[type="submit"]');
await expect(page.locator('[data-testid="login-error"]')).toBeVisible();
// Verify new password works
await page.fill('[name="password"]', 'NewSecurePass456!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
});Testing Attachments
For flows that send documents (invoices, reports, exports):
// tests/invoice-download.test.js
const { test, expect } = require('@playwright/test');
const { waitForEmail, clearInbox } = require('./helpers/imap');
test('invoice email contains PDF attachment', async ({ page }) => {
await clearInbox();
// Trigger invoice generation
await page.goto('http://localhost:3000/billing');
await page.click('[data-testid="download-invoice-btn"]');
const email = await waitForEmail({
to: 'billing@example.com',
subject: 'Your invoice',
timeoutMs: 15000,
});
// Assert attachment
expect(email.attachments).toHaveLength(1);
expect(email.attachments[0].filename).toBe('invoice.pdf');
expect(email.attachments[0].contentType).toBe('application/pdf');
expect(email.attachments[0].size).toBeGreaterThan(1000); // At least 1KB
// Optionally inspect PDF content
const pdfContent = email.attachments[0].content.toString('binary');
expect(pdfContent).toContain('%PDF'); // Valid PDF header
});CI Configuration
# .github/workflows/e2e.yml
name: E2E Tests
services:
mailpit:
image: axllent/mailpit
ports:
- 1025:1025 # SMTP (app sends here)
- 8025:8025 # Web UI
- 1143:1143 # IMAP (tests read here)
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
APP_SMTP_HOST: localhost
APP_SMTP_PORT: 1025
IMAP_HOST: localhost
IMAP_PORT: 1143Using a Real Gmail Account
For tests that need to verify real inbox delivery (not just capture), use a dedicated Gmail account with IMAP enabled and an app password:
const client = await createClient({
host: 'imap.gmail.com',
port: 993,
secure: true,
user: process.env.TEST_EMAIL_USER, // testaccount@gmail.com
pass: process.env.TEST_EMAIL_APP_PASS, // 16-char app password
});This is useful for testing email deliverability from your actual sending domain—the email goes through real SMTP delivery, spam filtering, and arrives in Gmail, confirming end-to-end delivery.
Scaling With HelpMeTest
The patterns above require writing and maintaining test code. HelpMeTest lets you describe email-based test flows in plain English—"sign up with email X, wait for verification email, click the link, confirm account activated"—and generates the test automation that handles browser interaction, IMAP polling, and assertions. It's useful for teams that need email flow coverage without a dedicated test engineer.
Summary
- Connect Playwright to Mailpit's IMAP port (1143) for local and CI email testing
- Use
imapflowandmailparserto read and parse captured emails - Test OTP flows: extract 6-digit code from email, enter in browser, assert login success
- Test verification links: extract URL from HTML body, navigate, assert account state
- Test attachment delivery: check filename, MIME type, and size assertions
- Use a real Gmail account with IMAP for production-path deliverability verification
- Always clean the inbox in
beforeEachto prevent test pollution between runs