DEV Community

Cover image for From MailHog to Zero Infrastructure: Migrating Your CI Email Tests
zerodrop
zerodrop

Posted on

From MailHog to Zero Infrastructure: Migrating Your CI Email Tests

MailHog was the right answer for a long time. A fake SMTP server, a web UI to inspect emails, zero dependencies beyond Docker. It worked.

Then it stopped being maintained. The last commit was in 2020. The Docker image hasn't been updated. The issues tab is a graveyard of unanswered questions.

If you're still running MailHog in your CI pipeline, this guide shows you how to migrate to ZeroDrop — no Docker, no SMTP server, no infrastructure to maintain.


What MailHog costs you in CI

Before migrating, it's worth being honest about what MailHog actually costs.

The Docker tax

Every MailHog setup in GitHub Actions looks like this:

services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025
Enter fullscreen mode Exit fullscreen mode

That service block spins up a Docker container on every CI run. It takes 15-30 seconds to pull the image on a cold runner. It takes another few seconds to start. For a pipeline that runs on every push, that's minutes of wasted CI time per week.

On GitHub Actions, you're billed by the minute. MailHog's startup time costs real money.

The parallel testing nightmare

MailHog runs as a single instance. All your tests share one inbox.

Run 10 parallel Playwright tests that all sign up with different email addresses, and you'll have 10 emails sitting in the same MailHog inbox. Your test needs to find its email among 9 others.

The typical workaround:

// Fragile — depends on subject line matching
const emails = await mailhog.messages();
const myEmail = emails.items.find(e => e.To[0].Mailbox === username);
Enter fullscreen mode Exit fullscreen mode

This breaks when tests run fast enough that emails arrive out of order. It breaks when the subject line format changes. It breaks when two tests use similar usernames.

The real solution is inbox isolation — each test gets its own inbox that only receives its own emails. MailHog has no concept of this.

The maintenance vacuum

MailHog is unmaintained. When Node.js updates break something, there's no fix coming. When the Docker image has a security vulnerability, there's no patch. When you need a feature, there's nobody to ask.

Mailpit is the maintained fork — it's better than MailHog. But it still requires Docker in CI, still has the parallel testing problem, and still means infrastructure you're responsible for.


What the migration looks like

Here's a typical MailHog test:

// Before — MailHog
import { MailhogService } from './mailhog';

const mailhog = new MailhogService('http://localhost:8025');

test('email verification flow', async ({ page }) => {
  const username = `user_${Date.now()}`;
  const email = `${username}@example.com`;

  await page.goto('/signup');
  await page.fill('[name="email"]', email);
  await page.click('[type="submit"]');

  // Wait and poll MailHog
  await page.waitForTimeout(2000);
  const messages = await mailhog.getMessagesFor(email);
  expect(messages.length).toBeGreaterThan(0);

  // Parse OTP with regex — fragile
  const body = messages[0].Content.Body;
  const otp = body.match(/\b(\d{6})\b/)?.[0];
  expect(otp).toBeDefined();

  await page.fill('[name="otp"]', otp!);
  await page.click('[type="submit"]');
  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

Here's the same test after migration:

// After — ZeroDrop
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

test('email verification flow', async ({ page }) => {
  const inbox = mail.generateInbox();
  // → "swift-x7k2m@zerodrop-sandbox.online"

  await page.goto('/signup');
  await page.fill('[name="email"]', inbox);
  await page.click('[type="submit"]');

  // SSE delivery — sub-second, no polling needed
  const email = await mail.waitForLatest(inbox, { timeout: 30000 });

  // OTP auto-extracted — no regex
  expect(email.otp).not.toBeNull();

  await page.fill('[name="otp"]', email.otp!);
  await page.click('[type="submit"]');
  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

No Docker. No SMTP config. No regex. No shared inbox.


Step 1 — Remove MailHog from your stack

GitHub Actions — remove the service block:

# Remove this entire block
services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025
Enter fullscreen mode Exit fullscreen mode

Your app config — remove the SMTP override:

// Remove this from your test environment config
SMTP_HOST=localhost
SMTP_PORT=1025
Enter fullscreen mode Exit fullscreen mode

Your app needs to send real emails now — use your transactional email provider (Resend, SendGrid, Postmark) with a live API key pointed at your staging environment.


Step 2 — Install zerodrop-client

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode

Step 3 — Generate the GitHub Actions inbox

name: E2E Tests

on: [push, pull_request]

jobs:
  test:
    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

      - name: Generate test inbox
        id: inbox
        uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0

      - name: Run E2E tests
        run: npx playwright test
        env:
          TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
          RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

Step 4 — Update your tests

import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

// Use CI inbox or generate locally
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
Enter fullscreen mode Exit fullscreen mode

Parallel tests — zero configuration

With MailHog you had to work around the shared inbox. With ZeroDrop, parallel isolation is the default.

// Each test generates its own inbox — no shared state
test.describe.parallel('Auth flows', () => {
  test('signup verification', async ({ page }) => {
    const inbox = mail.generateInbox(); // unique per test
    // ...
  });

  test('password reset', async ({ page }) => {
    const inbox = mail.generateInbox(); // different inbox
    // ...
  });
});
Enter fullscreen mode Exit fullscreen mode

50 parallel tests. 50 inboxes. Zero collisions.


The before and after

MailHog ZeroDrop
Docker required ✅ always ❌ never
CI startup time 15-30s 0s
Parallel isolation ❌ shared inbox ✅ per-test inbox
OTP extraction ❌ regex in tests ✅ auto-extracted
Maintained ❌ last commit 2020 ✅ active
Real email delivery ❌ fake SMTP ✅ real infrastructure
Cost Docker compute Free tier

What you're actually testing now

The biggest difference isn't the developer experience — it's what you're testing.

MailHog tests that your app calls an SMTP server. ZeroDrop tests that your app sends a real email that a real user could receive.

If your email provider goes down, MailHog passes. If your email template has a broken link, MailHog passes. If your DKIM is misconfigured, MailHog passes.

ZeroDrop fails on all of these. Which is the point.


ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
zerodrop.dev · docs · npm

Top comments (0)