DEV Community

Cover image for How to test email verification flows in Playwright (Mailpit, MailHog, and a no-setup alternative)
zerodrop
zerodrop

Posted on

How to test email verification flows in Playwright (Mailpit, MailHog, and a no-setup alternative)

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:

  1. Intercept that email
  2. Extract the verification link
  3. Navigate to it
  4. 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
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)