Stop mocking email.
Test the real signup flow.
mailutils.dev gives every test its own inbox. Your app sends real email, we catch it, and your test grabs the one-time code or magic link in a single line, with no shared Gmail account, no flaky polling and no regexes in your test code.
import { MailUtils } from '@mailutils/sdk' const mu = new MailUtils() // reads MAILUTILS_API_KEY test('user can sign up with an email code', async ({ page }) => { const inbox = await mu.newInbox() // signup+x7k2@acme.mailutils.dev await page.goto('/signup') await page.fill('#email', inbox.address) await page.click('text=Send code') const code = await inbox.waitFor().otp() // "482913" await page.fill('#code', code) await expect(page.getByText('Welcome')).toBeVisible() })
Email is the flakiest step in your end-to-end tests
Signup, login, password reset and invites all go through an inbox. Most teams either skip those paths in CI or hack around them. mailutils.dev makes them as easy to test as an API call.
Shared test inboxes collide
Parallel CI runs read each other's emails from the same Gmail account. With mailutils, every run gets its own tagged address.
Sleep-and-poll is slow and flaky
sleep(10) then IMAP polling wastes time and still misses mail. Our wait endpoint returns the moment the email lands.
Parsing codes is brittle
Every auth provider formats emails differently. We extract the OTP and verification link for you, tested against 20 real-world templates.
What you can use today
Everything below is live in the current release. Point an app at us and start testing.
Unlimited inboxes per project Live
Every project gets its own subdomain. Any address at *@acme.mailutils.dev works instantly, with no setup.
Plus-address tagging Live
user+run42@… lands in the user inbox tagged run42, so parallel test runs never see each other's mail.
SMTP sandbox Live
Point your app's SMTP settings at port 2525 with your API key. We capture everything and deliver nothing, so no real customer ever gets a test email.
Instant wait-for-email Live
One long-poll request that returns as soon as a matching email arrives, typically in under two seconds. No sleeps, no retry loops.
OTP & magic-link extraction Live
Codes like 482913, 842 017 or 7KQ-M2X and verify/sign-in/reset links come back as fields. Works with Auth0, Clerk, Cognito, Firebase, Supabase, Okta and more.
REST API Live
List, fetch, full-text search and delete messages. Get parsed HTML, text, headers, attachments metadata and the original .eml.
JavaScript / TypeScript SDK Live
await inbox.waitFor().otp() and you're done. Zero dependencies, works in Node 18+, Playwright, Cypress tasks and Vitest.
Web inbox viewer Live
See every caught email live: rendered HTML (scripts disabled), plain text, headers, links and raw source. Good for debugging a failing test.
API keys per project Live
Separate keys for CI, local dev and teammates. Keys are stored hashed and can be revoked at any time.
Three steps from send to assert
Your app sends an email
Either to a real address like signup+run42@acme.mailutils.dev (via our MX), or to any address through the SMTP sandbox on port 2525.
We catch and parse it
The message is stored, decoded (HTML, text, attachments) and scanned for one-time codes and verification links within milliseconds.
Your test reads the result
The SDK's waitFor() wakes up the moment the email lands and hands back the code, the link or the full message.
Built for the people who own the test suite
QA & test automation engineers
Cover signup, email verification, passwordless login, password reset and team invites end to end, in Playwright or Cypress, without mocks or manual checks.
Developers building auth
Use the SMTP sandbox as your local and staging mail server. See exactly what your templates render and never email a real user by accident.
CI pipelines
Give every pipeline run, shard or worker its own tagged inbox. Tests stay isolated and run in parallel safely, with no cleanup between runs.
Teams shipping email features
Share one project across QA, dev and product, with per-person API keys and a web inbox everyone can open to see what went out.
Up and running in five minutes
Create a project and key, point your app at the sandbox, then wait for the email from your test.
// npm install @mailutils/sdk import { MailUtils } from '@mailutils/sdk' const mu = new MailUtils({ apiKey: process.env.MAILUTILS_API_KEY }) const inbox = await mu.newInbox('signup') // unique tag per call // ... trigger the email in your app using inbox.address ... const otp = await inbox.waitFor().otp() // "482913" const link = await inbox.waitFor().link() // "https://app/verify?token=…" const msg = await inbox.waitFor({ timeout: 10_000 }) // full message
# Point your app (staging, CI, local) at the sandbox. # Mail to ANY recipient is captured, never delivered. SMTP_HOST=smtp.mailutils.dev SMTP_PORT=2525 SMTP_USER=acme # your project slug (any value works) SMTP_PASS=mu_3f9c… # your API key
# Wait up to 30 s for the next email to signup+run42@acme.mailutils.dev curl -H "Authorization: Bearer $MAILUTILS_API_KEY" \ "https://api.mailutils.dev/v1/messages/wait?inbox=signup&tag=run42&timeout=30" # → { "id": "…", "subject": "Your verification code", "otp": "482913", # "primary_link": "https://…", "links": [...], "text": "…", "html": "…" } # Search, then clean up curl -H "Authorization: Bearer $MAILUTILS_API_KEY" "https://api.mailutils.dev/v1/messages?q=reset" curl -X DELETE -H "Authorization: Bearer $MAILUTILS_API_KEY" "https://api.mailutils.dev/v1/messages?tag=run42"
# One Go binary plus Postgres. docker compose --profile app up -d docker compose exec mailutils mailutils project create acme --name "Acme QA" # → Project acme created. API key: mu_3f9c… (shown once) # Web inbox: http://localhost:8080/inbox # SMTP sandbox: localhost:2525 (user: acme, pass: your key)
A small API that does one job well
Authenticate with Authorization: Bearer <api key>. Filters inbox, tag, to,
q (full-text) and since work on list, wait and bulk delete.
| Endpoint | What it does |
|---|---|
GET /v1/messages/wait | Long-polls until a matching email arrives (up to timeout seconds, max 60), then returns it with otp and primary_link. Returns 408 on timeout. |
GET /v1/messages | Lists messages newest first. Supports limit and before for pagination. |
GET /v1/messages/{id} | Full message: HTML, text, headers, attachments, links, extracted OTP. |
GET /v1/messages/{id}/raw | The original RFC 822 source as .eml. |
DELETE /v1/messages/{id} | Deletes one message. |
DELETE /v1/messages | Bulk-deletes everything matching the filters, e.g. one test run's tag. |
GET /v1/project | Your project's domain and SMTP sandbox settings. |
What's coming next
We're building in the open with a small group of QA teams. Here's the plan for the next four months.
Proof of concept Live now
- MX and SMTP sandbox ingest, plus-address tagging
- REST API, long-poll wait, full-text search
- OTP and link extraction, JS/TS SDK, web inbox
Private beta Dec 2026
- Python SDK
- Playwright and Cypress plugins
- Signed webhooks on email arrival
- Disposable, auto-expiring addresses per test run
v1 Jan 2027
- Teams, invites and Stripe billing
- Retention and quotas per plan
- Custom domains via MX
- Forward a caught email to an allowlisted real address
- HTML compatibility, link and image checker
- Spam score, SPF/DKIM/DMARC validator and header analyzer
- GitHub Action: an inbox per workflow run
Public launch Feb 2027
- Docs and example projects
- Free public DNS tools (SPF, DMARC, MX lookups)
- Uptime monitoring, abuse limits and support
Free while in beta
Proposed plans for v1. Beta teams keep their pricing locked in.
Pro
- 20,000 emails / month
- Webhooks and longer retention
- Custom domain
Team
- 100,000 emails / month
- Team members and invites
- Release to real address, deliverability checks
Questions
Does mailutils ever deliver email to real people?
No. Mail sent to the SMTP sandbox is captured and stored only. Nothing is relayed onward. Forwarding a caught email to an allowlisted address is planned for v1 and will be opt-in.
Which address should I use: MX or sandbox?
Use anything@yourproject.mailutils.dev when the app under test sends through a real provider (SendGrid, SES, Postmark…). Use the sandbox when you control the app's SMTP settings, for example in staging or CI.
How do I keep parallel test runs apart?
Add a tag: signup+<run-id>@…. The SDK's newInbox() does this for you and filters every wait by that tag.
Is it safe to open captured emails?
Yes. HTML is rendered in a sandboxed frame with scripts, forms and same-origin access disabled.
Can I self-host?
Yes. It's a single Go binary plus Postgres, and a Docker Compose file is included.