Skip to content
Hosted inboxes, no SMTP rewiring

Every test gets
its own inbox

Mailwurf is a hosted catch all mailbox for end to end tests. Your app keeps sending through Resend or SES. Your test creates an address, waits for the message, and opens the link.

Get access

No mail server to run. No DNS to hold. One API key per project.

The Mailwurf mole surfacing from a pile of envelopes, holding one up to the camera
tests/signup.spec.ts
const inbox = await mailwurf.createInbox();
// k3f9x2@mailwurf.io

await page.fill("[name=email]", inbox.address);
await page.click("text=Sign up");

const mail = await inbox.waitFor({
  subject: "Confirm",
});

await page.goto(mail.links[0]);

The whole integration. No polling loop, no shared mailbox, no sleep.

1
inbox per test worker
120s
longest server side wait
0
mail servers you operate
0
lines of app code changed

An SMTP sink tests your sink,
not your email

The moment a test rewires where mail goes, it stops covering the part that actually breaks: the provider, the template, the link. Mailwurf changes the recipient instead.

With a local sink

  • The test suite repoints SMTP at a local sink, so Resend, SES and your DNS are never exercised.
  • Four Playwright workers share one mailbox and pick up each other’s confirmation links.
  • Every spec carries its own polling loop with a sleep that is either flaky or slow.
  • A template that renders fine locally breaks in production, because production sends it differently.

With Mailwurf

  • Mail arrives over real MX on port 25, sent by the same provider your users get mail from.
  • Each test calls POST /v1/inboxes and owns that address until its TTL runs out.
  • One wait call blocks until the message lands, then returns subject, links and OTP.
  • The raw MIME is kept next to the parsed fields, so you can assert on what really arrived.

Built for the part of the suite
that keeps going red

One inbox per worker

POST /v1/inboxes hands back a fresh address on mailwurf.io. Twelve workers running in parallel never read each other’s mail, so retries stop being a coin flip.

The wait lives on the server

GET /messages/wait blocks until the message arrives, up to 120 seconds. Matching is oldest first, by subject substring, and after takes a message id.

Links and codes already parsed

Each message comes back with text, html, links and the detected OTP. The raw MIME stays in the store, because parsers are wrong sometimes.

Your sending path stays production

Mail reaches Mailwurf the way it reaches a customer: through your provider, over MX, with your templates. A broken sender fails the test, not the release.

Nothing to operate

We hold the MX records, the certificate and the store. You hold an API key. There is no mail server in your stack and no port 25 in your firewall rules.

Readable by your agents

GET /llm returns a markdown guide to the API, no auth required, so a coding agent can wire up a mail assertion without a client library or a doc site.

Test mail should be boring.  Create an inbox, wait,  open the link, move on. 

Three steps, none of them
touching your mail setup

  1. Step 01

    Put the key in your CI

    Every project gets its own key. Two environment variables in the test runner, and nothing about the app under test changes.

    .env.test
    MAILWURF_URL=https://mailwurf.io
    MAILWURF_API_KEY=mw_live_9c2f41d8a7
  2. Step 02

    Create an inbox in the test

    One call per test, before the flow starts. You get an id and an address on mailwurf.io. Drop the address into the form the way a real signup would.

    tests/signup.spec.ts
    const mailwurf = createClient(); // reads both variables
    const inbox = await mailwurf.createInbox();
    
    await page.fill("[name=email]", inbox.address);
  3. Step 03

    Wait for the message

    The request stays open until a matching message arrives. Subject is a substring match, oldest message first, and the response carries the parsed links and OTP.

    tests/signup.spec.ts
    const mail = await inbox.waitFor({
      subject: "Confirm your email",
      timeout: 15_000,
    });
    
    expect(mail.otp).toMatch(/^\d{6}$/);
    await page.goto(mail.links[0]);

A small API, on purpose

Every call under /v1 takes your project key as a bearer token. The TypeScript client is a fetch wrapper around these calls, so anything that speaks HTTP works the same way.

Mailwurf HTTP endpoints
MethodPath
POST/v1/inboxes
GET/v1/inboxes?address=
GET/v1/inboxes/{id}/messages
GET/v1/inboxes/{id}/messages/wait
GET/v1/messages/{id}
DELETE/v1/inboxes/{id}
GET/llm
GET/health
200 OK — messages/wait
{
  "id": "01J8ZQ2K7M",
  "subject": "Confirm your email",
  "from": "hello@yourapp.com",
  "text": "Your code is 418902",
  "links": ["https://yourapp.com/confirm?t=ey..."],
  "otp": "418902",
  "receivedAt": "2026-08-20T19:04:11Z"
}

Raw MIME is the source of truth

Parsed fields are a convenience. The original message is stored next to them, so a template change that confuses the parser never silently changes what your assertions see.

Wired into a suite
in an afternoon

first call
curl -s -X POST https://mailwurf.io/v1/inboxes \
  -H "Authorization: Bearer $MAILWURF_API_KEY"

# {"id":"01J8ZQ…","address":"k3f9x2@mailwurf.io"}

Anything that speaks HTTP works. The TypeScript client is a fetch wrapper around exactly these calls, with types for the message shape.

What you need on your side

MAILWURF_URL=https://mailwurf.io
  • The API key we issue for the project, kept in your CI secrets.
  • One inbox per test, never one per suite.
  • The sender configuration of the app under test, left exactly as it is.
See every endpoint

Three plans. Prices land
with the public launch

Mailwurf is in private access while we finish the billing side. Tell us how your suite runs and we will tell you which plan fits and what it will cost.

Trial

For the first spec, on a shared mailwurf.io domain.

Pricing to be announced

  • Inboxes on mailwurf.io
  • Short message retention
  • One API key
Ask about Trial

Team

For a suite that runs on every pull request.

Pricing to be announced

  • Several keys, one per project
  • Longer retention
  • Parallel workers without limits per test
Ask about Team

Dedicated domain

For teams that need their own MX name in the address.

Pricing to be announced

  • Your domain on our MX
  • Isolated storage
  • Support agreement and DPA
Ask about Dedicated domain

Questions that come up
before the first test run

How is this different from Mailpit or Mailhog?

Those are SMTP sinks you point your app at, so the test never exercises Resend, SES, your templates or your DNS. Mailwurf receives on real MX records, which means only the recipient address changes and the rest of the path stays production.

Do I have to change the app to run tests against it?

No. The app keeps its provider, its credentials and its templates. The test sets the recipient to an inbox address it created a moment earlier.

Do I need to run anything myself?

No. We hold the MX records, the TLS certificate and the store. Your side is two environment variables in the test runner and a call at the start of the spec.

What happens to mail sent to an address nobody created?

It is accepted and an inbox is created on delivery, as long as the recipient is on your Mailwurf domain. Find it afterwards with GET /v1/inboxes?address=. Recipients on any other domain are refused with 550 relay not permitted.

Is the mail private?

Every call under /v1 requires your project key, inbox ids are random, and messages are deleted when their retention window ends. Treat Mailwurf as a test system regardless: it is built for mail your test suite generates, not for production mail or real customer data.

Will my provider actually deliver to it?

Yes. The MX endpoint offers STARTTLS with a certificate covering the MX name, which is what providers check before they hand a message over. Nothing about your own SPF or DKIM setup needs to change.

How long does a wait call block?

The timeout query parameter is in milliseconds, defaults to 15000 and is capped at 120000. Matching is oldest message first, subject is a substring, and after takes a message id so a retry does not resolve to the message you already read.

Can I use it without the client library?

Yes. It is plain HTTP with a bearer token. GET /llm returns a markdown guide to the API without auth, which is also what a coding agent reads before writing the test.

Does it work outside Playwright?

Anything that can make an HTTP request works: Cypress, Vitest, Go tests, a shell script with curl. The TypeScript client is a thin fetch wrapper with no Playwright dependency.

Can I have my own domain in the address?

Yes, on the dedicated plan. You delegate an MX record for a subdomain to us and inboxes are created under that name instead of mailwurf.io.

How long do you keep the messages?

Until the inbox TTL for your plan elapses, or until you call DELETE on the inbox, whichever comes first. Test mail is deleted, not archived.

What does it cost?

Pricing is not public yet. Mailwurf is in private access while billing is finished, so write to us with the shape of your suite and we will tell you which plan fits.

Give your flakiest
signup test an inbox

Send us the shape of your suite and we set up a project key. One call in the spec, and the mail step stops being the flaky one.

  • Test mail only, deleted when the retention window ends
  • No mail server, no certificate renewals, no port 25
  • Drop an inbox any time, or let its TTL do it
The Mailwurf mole resting on a neat stack of envelopes