Automated Email Testing with Playwright and IMAP: OTP Flows, Attachments, and Inbox Assertions

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:

  1. IMAP on a real inbox — use a dedicated test Gmail/Outlook account, connect via IMAP
  2. 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/mailpit

IMAP 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');
});

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: 1143

Using 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 imapflow and mailparser to 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 beforeEach to prevent test pollution between runs

Read more

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Testing Atlantis Terraform PR Automation: Workflows, Plan Verification, and Policy Enforcement

Atlantis automates Terraform plan and apply through pull requests. But Atlantis itself needs testing: workflow configuration, plan output validation, policy enforcement, and server health checks. This guide covers testing Atlantis workflows locally with atlantis-local, validating plan outputs with custom scripts, enforcing Terraform policies with OPA and Conftest, and monitoring Atlantis

By HelpMeTest