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
Create a test inbox
mailhooks.inboxes.create() — unique address, auto-expires with TTL
Trigger the email
Your app sends a real email (magic link, OTP, password reset)
Wait for it
inboxes.waitForMessage() — server-side long-poll, returns when email arrives
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.
Create a Mailhooks account
Sign up for free in seconds.
Create an inbox
Get a unique email address for your use case.
Add your webhook URL
Point to your endpoint and start receiving emails.