No more flaky email tests

Inbox per test. Server-side wait. Auto-extracted magic links and OTPs. Zero sleep() calls.

The Problem

  • Shared test inboxes cause race conditions when tests run in parallel
  • sleep(10000) wastes CI minutes and still flakes when emails are slow
  • Polling with retries adds 5-30 seconds of latency per test
  • Extracting magic links and OTPs with regex breaks when templates change
  • SMTP mocking bypasses email entirely — you're not testing the real flow
  • Mailosaur and Mailtrap add a network dependency that causes its own flakiness

Why Existing Solutions Fall Short

Test-only backend endpoints that skip email — you're not testing email anymore

Gmail API with plus-addressing — shared inbox, race conditions in parallel

MailDev/MailHog for local — doesn't work in CI without extra infrastructure

Mailosaur/Mailtrap — expensive, enterprise-focused, still uses polling

You shouldn't have to build this yourself.

How Mailhooks Solves This

Inbox per test

Each test creates its own inbox — no shared state, no race conditions, safe for parallel CI.

Server-side wait

Long-poll that returns the moment email arrives. No client-side polling, no sleep(), no retries.

Auto-extraction

Magic links, OTPs (4-8 digits), and verification codes extracted from email body automatically. No regex.

Real email flows

Uses your actual auth provider (WorkOS, Auth0, Clerk, Supabase). Tests the full path, not a mock.

How It Works

1

Create a test inbox

mailhooks.inboxes.create() — unique address, auto-expires with TTL

2

Trigger the email

Your app sends a real email (magic link, OTP, password reset)

3

Wait for it

inboxes.waitForMessage() — server-side long-poll, returns when email arrives

4

Use extracted data

msg.extracted.magicLink / msg.extracted.otp — no parsing needed

Code Example

Create an inbox, trigger signup, wait for the email, use the magic link. Four lines that replace 50.

Webhook Payload

// POST /v1/inboxes — Create a virtual inbox
{
  "id": "3e86a45b-f29d-4f33-883f-a89fed41cbd3",
  "address": "[email protected]",
  "active": true,
  "messageCount": 0,
  "expiresAt": "2025-01-15T11:30:00Z"
}

// GET /v1/inboxes/:id/messages/wait — Server-side long-poll
{
  "id": "msg_xyz789",
  "from": "[email protected]",
  "subject": "Sign in to YourApp",
  "extracted": {
    "magicLink": "https://yourapp.com/auth?token=abc123",
    "otp": null,
    "verificationCode": null,
    "links": ["https://yourapp.com/auth?token=abc123"],
    "codes": []
  }
}

Handler Code

import { test, expect } from '@playwright/test';
import Mailhooks from '@mailhooks/sdk';

const mh = new Mailhooks({ apiKey: process.env.MAILHOOKS_API_KEY });

test('magic link login', async ({ page }) => {
  // Inbox per test — no shared state
  const inbox = await mh.inboxes.create({ ttlSeconds: 3600 });

  // Trigger the real signup flow
  await page.goto('/login');
  await page.fill('[name="email"]', inbox.address);
  await page.click('button[type="submit"]');

  // Server-side wait — no polling, no sleep()
  const msg = await mh.inboxes.waitForMessage(inbox.id);

  // Magic link auto-extracted — no regex
  expect(msg.extracted.magicLink).toBeTruthy();
  await page.goto(msg.extracted.magicLink);
  await expect(page.locator('.dashboard')).toBeVisible();
});

test('OTP verification', async ({ page }) => {
  const inbox = await mh.inboxes.create({ ttlSeconds: 3600 });

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

  const msg = await mh.inboxes.waitForMessage(inbox.id);

  // OTP auto-extracted (4-8 digits)
  expect(msg.extracted.otp).toMatch(/^\d{6}$/);
  await page.fill('[name="otp"]', msg.extracted.otp);
  await page.click('button[type="submit"]');
  await expect(page.locator('.dashboard')).toBeVisible();
});

Frequently Asked Questions

Every inbox can be configured with sender filtering rules. You can whitelist specific domains or email addresses, or use our webhook to implement your own spam filtering logic. Emails that don't match your rules are automatically rejected.

Webhooks are typically delivered within 100-500ms of email receipt. We process emails in real-time with no polling delays. For high-availability applications, we also offer webhook retries with exponential backoff.

Mailhooks is built specifically for inbound email. We offer simpler setup (no DNS changes required for testing), better attachment handling with direct download URLs, and a developer-first API for fetching emails programmatically—perfect for E2E testing.

Yes! You can connect your own domain with simple DNS configuration. We also provide free subdomains on inbox.mailhooks.dev for testing and development.

We automatically retry failed webhooks with exponential backoff for up to 24 hours. You can also use our API to fetch any missed emails. All emails are stored and accessible via the dashboard.

Get Started in 3 Steps

Takes ~2 minutes — no email infrastructure required.

1

Create a Mailhooks account

Sign up for free in seconds.

2

Create an inbox

Get a unique email address for your use case.

3

Add your webhook URL

Point to your endpoint and start receiving emails.

Stop writing flaky email tests