DEV Community

Cover image for How to E2E Test Mailgun Email Workflows in Playwright
zerodrop
zerodrop

Posted on

How to E2E Test Mailgun Email Workflows in Playwright

Mailgun is a popular transactional email API trusted by developers for reliable delivery. But how do you test that your Mailgun emails actually arrive, contain the right content, and work end-to-end in CI?

This guide covers the full testing progression — from local development to automated Playwright tests in GitHub Actions.


The app we're testing

A Next.js API route that sends a verification email via Mailgun:

// app/api/auth/signup/route.ts
import Mailgun from 'mailgun.js';
import FormData from 'form-data';

const mailgun = new Mailgun(FormData);
const mg = mailgun.client({
  username: 'api',
  key: process.env.MAILGUN_API_KEY!,
});

export async function POST(req: Request) {
  const { email } = await req.json();

  const token = crypto.randomUUID();
  const verifyUrl = `${process.env.NEXT_PUBLIC_URL}/verify?token=${token}`;

  await mg.messages.create(process.env.MAILGUN_DOMAIN!, {
    from: 'noreply@yourapp.com',
    to: email,
    subject: 'Verify your email',
    html: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
  });

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

Stage 1 — Local development: Mailgun sandbox domain

Mailgun provides a sandbox domain for testing. Emails sent to the sandbox domain are captured in your Mailgun dashboard without being delivered to real inboxes.

const mg = mailgun.client({
  username: 'api',
  key: process.env.MAILGUN_API_KEY!,
});

// Use sandbox domain for local development
await mg.messages.create(
  process.env.NODE_ENV === 'development'
    ? process.env.MAILGUN_SANDBOX_DOMAIN!  // sandbox.mailgun.org
    : process.env.MAILGUN_DOMAIN!,
  {
    from: 'noreply@sandbox.mailgun.org',
    to: email,
    subject: 'Verify your email',
    html: `<p>Click <a href="${verifyUrl}">here</a> to verify.</p>`,
  }
);
Enter fullscreen mode Exit fullscreen mode

Important: Mailgun's sandbox domain only delivers to pre-authorized recipient email addresses. You must add your test email to the authorized list in your Mailgun dashboard.

What it solves: Visual inspection during local development without sending real emails.

What it doesn't solve: Automated testing. You can't programmatically read emails from Mailgun's dashboard in a Playwright test. The authorized recipient limitation also makes it unsuitable for CI where you need random inbox addresses.


Stage 2 — Staging: Mailgun live domain to a real inbox

Switch to your live Mailgun domain for staging:

await mg.messages.create(process.env.MAILGUN_DOMAIN!, {
  from: 'noreply@yourapp.com',
  to: email,
  subject: 'Verify your email',
  html: `<p>Click <a href="${verifyUrl}">here</a> to verify.</p>`,
});
Enter fullscreen mode Exit fullscreen mode

Emails now go through real delivery. You can manually verify they arrive, links work, and nothing lands in spam.

What it solves: Does the email actually reach a real inbox end-to-end?

What it doesn't solve: Automation. You can't run this in CI without access to a real inbox your test can read.


Stage 3 — CI: Mailgun live domain + ZeroDrop

For automated Playwright tests in GitHub Actions:

npm install zerodrop-client
Enter fullscreen mode Exit fullscreen mode
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';

const mail = new ZeroDrop();

test('user can sign up and verify email', async ({ page }) => {
  // 1. Generate a disposable inbox
  const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
  // → "swift-x7k2m@zerodrop-sandbox.online"

  // 2. Sign up — Mailgun sends a real verification email to this inbox
  await page.goto('/signup');
  await page.fill('[data-testid="email"]', inbox);
  await page.click('[data-testid="submit"]');

  await expect(page).toHaveURL('/check-email');

  // 3. ZeroDrop catches the email — magic link auto-extracted
  const email = await mail.waitForLatest(inbox, { timeout: 30000 });

  expect(email.subject).toContain('Verify your email');
  expect(email.magicLink).not.toBeNull();

  // 4. Click the verification link
  await page.goto(email.magicLink!);

  // 5. Assert verified
  await expect(page).toHaveURL('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

No authorized recipient list. No sandbox limitations. ZeroDrop generates a new inbox for every test run — Mailgun delivers to it like any real email address.


OTP flows

await mg.messages.create(process.env.MAILGUN_DOMAIN!, {
  from: 'noreply@yourapp.com',
  to: email,
  subject: 'Your verification code',
  html: `<p>Your code is: <strong>${otp}</strong></p>`,
});
Enter fullscreen mode Exit fullscreen mode
const email = await mail.waitForLatest(inbox, { timeout: 30000 });

// OTP auto-extracted at the edge — no regex needed
expect(email.otp).not.toBeNull();
await page.fill('[data-testid="otp"]', email.otp!);
await page.click('[data-testid="verify"]');
Enter fullscreen mode Exit fullscreen mode

Using Mailgun templates

If you use Mailgun's stored templates:

await mg.messages.create(process.env.MAILGUN_DOMAIN!, {
  from: 'noreply@yourapp.com',
  to: email,
  template: 'verify-email',
  'h:X-Mailgun-Variables': JSON.stringify({
    verify_url: verifyUrl,
    username: 'Test User',
  }),
});
Enter fullscreen mode Exit fullscreen mode
// ZeroDrop catches the fully rendered template
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
Enter fullscreen mode Exit fullscreen mode

This tests that your Mailgun template renders correctly with real variable substitution.


GitHub Actions workflow

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 }}
          MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }}
          MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }}
          NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}
Enter fullscreen mode Exit fullscreen mode
// Use CI inbox or generate locally
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
Enter fullscreen mode Exit fullscreen mode

The full picture

Sandbox domain (local) Live domain (staging) Live domain + ZeroDrop (CI)
Visual inspection ✅ dashboard ✅ real inbox ✅ automated
No real emails sent
Authorized recipients only ✅ (limitation)
Automated in CI
Random test addresses
Parallel test runs
OTP auto-extraction
Tests real delivery

Use the sandbox during development, the live domain for manual staging verification, and the live domain + ZeroDrop for automated CI.


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

Top comments (0)