The problem with static test mailboxes
Playwright tests against real signup flows tend to break on something trivial: you need an email address that exists, receives mail and does not pollute your team's inbox. Static test accounts (qa@yourdomain.com) turn into landfill over time — onboarding mails, password resets and welcome sequences pile up until someone manually purges them. Parallel CI runs collide in the same inbox and pick up each other's verification mails.
The mainstream workarounds (Mailpit, MailHog, MailCrab) are good for local development but awkward for cloud CI: you have to run them as services, expose them somehow, and wire your app to send to them instead of to real SMTP. That is feasible but it is not the same code path you ship.
The Mailiy API in three calls
The Mailiy REST API is designed for exactly this. In a beforeEach hook you POST /v1/mailbox for a fresh address, optionally with ttlMinutes:
import { test } from '@playwright/test'
test.beforeEach(async ({ context }) => {
const res = await fetch('https://api.mailiy.com/v1/mailbox', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MAILIY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ ttlMinutes: 30 }),
})
const { id, address } = await res.json()
context.mailboxId = id
context.address = address
})
API keys are prefixed with ml_pk_ and are minted from your account settings. Put the key in your CI secret store, not the repo.
In the test you then poll GET /v1/mailbox/:id/messages until the verification mail arrives:
async function waitForMail(mailboxId: string, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const r = await fetch(`https://api.mailiy.com/v1/mailbox/${mailboxId}/messages`, {
headers: { 'Authorization': `Bearer ${process.env.MAILIY_API_KEY}` },
})
const { messages } = await r.json()
if (messages.length > 0) return messages[0]
await new Promise(r => setTimeout(r, 1000))
}
throw new Error('verification mail did not arrive in time')
}
A 1-second interval with a 30-second timeout is plenty — the backend usually responds in under a second because SMTP ingest and the API read against the same Postgres instance.
Optional: webhooks instead of polling
If you would rather work synchronously, register a webhook with POST /v1/webhooks and { mailboxId, url } pointing at a local ngrok tunnel:
await fetch('https://api.mailiy.com/v1/webhooks', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.MAILIY_API_KEY}` },
body: JSON.stringify({
mailboxId: context.mailboxId,
url: `${process.env.NGROK_URL}/inbound`,
}),
})
For CI the polling approach is usually simpler — no inbound HTTP exposure required.
Teardown
After the test you can either let the TTL do the work, or delete explicitly:
test.afterEach(async ({ context }) => {
await fetch(`https://api.mailiy.com/v1/mailbox/${context.mailboxId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${process.env.MAILIY_API_KEY}` },
})
})
Explicit delete is friendly to your rate-limit budget and makes test runs hermetic. Letting the TTL handle it is fine for low-volume suites.
Rate limit budgeting
Your API tier controls both the monthly quota and the per-minute cap:
| Tier | Calls/month | Rate |
|---|---|---|
| api_free | 500 | 10 req/min |
| api_lite | 10,000 | 60 req/min |
| api_pro | 100,000 | 300 req/min |
A typical PR pipeline with 50 tests burns 3–5 calls per test (create, poll, optional delete) — at 100 PRs per day that fits comfortably in the Pro plan. For a small team with light CI usage, the free tier is genuinely usable.
If you need higher per-minute throughput than Pro gives (300 req/min), the Business plan goes to 1000 req/min, and Enterprise is unmetered.
What this gets you
- No more shared-inbox collisions in parallel CI runs
- Hermetic test data — every test starts with a fresh inbox
- Real SMTP path — you hit your actual signup endpoint, not a mock
- Sub-second feedback — polling reliably gets messages within ~1s of SMTP arrival
For a complete example with Cypress (instead of Playwright), see the examples in the API docs.
Try it now → Generate a disposable address on the mailiy homepage — one click, ready in under two seconds, no signup, no account.