Skip to content

Testing with MailMatter

Practices for email assertions in an automated suite — addressing per test case, waiting without polling loops, isolating parallel runs, and cleaning up.

An email assertion is usually the flakiest step in a suite: the address is shared, the wait is a sleep, and a stale message from the last run passes a test that should have failed. MailMatter is built so none of those are necessary. This page collects the practices that keep email tests deterministic.

Prerequisites: an inbox and its credential — an anonymous secret from the quickstart, or an org API key (mm_…) from Settings → API keys.

Give every test case its own address

Scenarios are implicit: {handle}.{anything}@mailmatter.dev starts working the moment something sends to it, with no create call and no cleanup obligation. So spend them freely — one per test case, rather than one shared inbox everything reads from:

const address = (scenario) => `${handle}.${scenario}@mailmatter.dev`;

address("signup");                // signup happy path
address("signup.expired-token");  // the failure case
address("password-reset");

A test that owns its address can never read another test’s mail, which removes an entire class of cross-test flakes before you write any assertion.

Wait for the email

GET /api/mailbox/{handle}.{scenario}/wait blocks until a message strictly newer than a since watermark arrives, then returns it fully parsed. One call replaces a sleep-and-retry loop, and the watermark makes it impossible to match a stale message from an earlier run.

Capture the watermark before triggering the send

The order is the whole idiom: take the timestamp first, then cause the email. Anything that arrives after the watermark — and only that — can satisfy the wait.

const since = Date.now();
await page.getByRole("button", { name: "Sign up" }).click(); // triggers the email

Block on the wait endpoint

const res = await fetch(
  `https://mailmatter.dev/api/mailbox/${handle}.signup/wait?since=${since}&timeout=55`,
  { headers: { Authorization: `Bearer ${secret}` } },
);
const { message } = await res.json();
  • timeout is in seconds, maximum 55. On timeout the call returns 200 with "message": null — a normal outcome your test should treat as “the email didn’t come”, not as a transport error.
  • The endpoint is rate-limited per IP; one blocked call per arriving email is the intended usage, not a tight retry loop.
  • Set the wait timeout a little under your test runner’s own timeout, so a missing email fails with your assertion message rather than the runner’s.

Assert on the parsed message

Assert on the data, not on a rendered view. text is the decoded plain-text body and links the verbatim extracted href values — nothing has been rewritten, resolved, or visited:

expect(message).not.toBeNull();
const otp = message.text.match(/\b(\d{6})\b/)[1];
const verifyLink = message.links.find((l) => l.includes("/verify"));

Because MailMatter never follows a link on your behalf, a one-time token is still unspent when your test gets it — see The hermetic inbox. For header-level assertions (DKIM, custom X-* metadata) read headers, or fetch GET /api/message/{id}/raw for the byte-exact .eml.

For a complete Playwright OTP example, see Test email OTP flows in Playwright.

Keep parallel runs apart

Two options, freely combined:

  • A scenario per run. Scenarios need no setup, so run-${CI_RUN_ID}.signup gives every pipeline its own inbox for free.
  • A header filter per run. If your mailer can set a header such as X-Test-Run: 42, list with GET /api/mailbox/{handle}.{scenario}?header=X-Test-Run:42 and assert only on tagged messages.

Clean up after the run

curl -s -X DELETE https://mailmatter.dev/api/mailbox/<handle>.signup \
  -H "Authorization: Bearer <secret>"

Deletion is optional — every message expires after your plan’s retention window anyway — but purging in an afterAll keeps a long-lived handle’s listings readable and its volume down.

Troubleshooting

A test that times out is almost always one of these:

  • The address is wrong. It must be {handle}.{scenario}@mailmatter.dev exactly; mail is lowercased and split on the first dot.
  • The watermark was taken after the send. The message arrived before since, so the wait ignores it. Capture it first.
  • 403 — the credential doesn’t match the handle. 404 — the handle doesn’t exist (check for a typo, or that it hasn’t been released).
  • 402 — the message did arrive but is over your plan’s volume cap, so only its envelope is returned. That’s an inbox-full signal, not a missing email.

Next steps