Using MailFlat with Playwright
Give every Playwright test its own real inbox, read the verification code your app actually sent, and finish the signup flow end to end.
How it fits together
Playwright already drives the browser; MailFlat gives it a mailbox. A fixture opens an inbox before the test, hands the address over, and deletes it afterwards, so parallel workers never read each other's mail and a failed run cleans itself up. One number is worth changing before the first run: Playwright's default test timeout and the client's default wait are both thirty seconds, so out of the box the test is killed at the exact moment the client would have given up. Playwright's message wins that race, and it can only say the test ran out of time, never whether mail arrived.
Skip the setup
The steps below are what a working project looks like. If you want that project rather than the explanation, generate it: one runnable smoke test, one signup test to edit, and a gitignored .env for your key.
npm create mailflat@latest -- --template playwright
Zero dependencies of its own; the generated project pulls the SDK.
Install
The client has no dependencies of its own and runs on the Node that Playwright already uses.
npm i -D @mailflat/sdk
A fixture that hands each test an inbox
Everything after the use call is teardown, so it runs even when the test fails. Leaving this fixture at its default test scope also means a retry gets a brand new inbox rather than the one the failed attempt already filled, which is what stops a retry from passing on stale mail.
// e2e/fixtures.ts
import { test as base } from "@playwright/test";
import { MailFlat, type Inbox } from "@mailflat/sdk";
const mf = new MailFlat({ apiKey: process.env.MAILFLAT_API_KEY! });
export const test = base.extend<{ inbox: Inbox }>({
inbox: async ({}, use) => {
const inbox = await mf.create({
prefix: `e2e-${Date.now().toString(36)}`,
retentionHours: 2,
});
await use(inbox);
await inbox.delete();
},
});
export { expect } from "@playwright/test";
The test
A real address, a real email, a real code. No test-mode backdoor in the app under test. The waiting call is given sixty seconds while the test's own budget is thirty by default, so raise the test timeout to something above it or the client never gets to report what it saw.
// e2e/signup.spec.ts
import { test, expect } from "./fixtures";
test("user can sign up with a real code", async ({ page, inbox }) => {
await page.goto("/signup");
await page.fill("#email", inbox.address);
await page.click("#submit");
await page.fill("#code", await inbox.waitForOtp({ timeout: 60_000 }));
await expect(page).toHaveURL(/dashboard/);
});
Give the test room for the wait
Both defaults are thirty seconds, so they expire together and the framework's message is the one you get. Raise it for the whole project, or for the one spec that waits.
// playwright.config.ts
export default defineConfig({
timeout: 90_000, // above the waitForOtp timeout below
expect: { timeout: 10_000 },
});
// or per test
test("user can sign up with a real code", async ({ page, inbox }) => {
test.setTimeout(90_000);
// ...
});
Magic links instead of codes
Same idea, one step shorter: take the first link out of the message and navigate to it. Links are not a typed field yet, which is why this reads through raw rather than off the message directly.
const msg = await inbox.waitForMessage({ timeout: 60_000 });
await page.goto(msg.raw.links[0]);
await expect(page).toHaveURL(/dashboard/);
Worth knowing
Playwright runs workers in parallel by default. One inbox per test is what keeps that safe.
Set MAILFLAT_API_KEY in your CI secrets, never in playwright.config.ts.
waitForOtp polls once a second and returns the moment the code lands, so a generous timeout costs nothing on a healthy run. It is only ever paid in full when something is actually wrong.
Moving the inbox fixture to worker scope is tempting for speed and is the one change that breaks the isolation. A worker scoped box is shared by every test that worker runs, and the wait returns the newest message in it regardless of which test caused it.
Free allows three agent inboxes at once, so fullyParallel with the default worker count can exceed the allowance on a machine with more cores. The refusal is a 400 that names the plan, not a 429; there is no rate limit on reading a box you own.
The address is permanent until you delete it. A suite that only reads mail can run against one fixed address with a key carrying inbox:read alone, which removes every path by which a test run could delete something.
When it goes wrong
| Symptom | Why | What to do |
|---|---|---|
| The test fails at exactly thirty seconds with no detail about the mail | Playwright's default test timeout and the client's default wait are both thirty seconds. The framework stops the test at the same instant the client would have raised, and its message cannot distinguish a missing message from an unreadable one. | Raise the test timeout above the wait, as above. Once the client is the one that gives up, the error says whether nothing arrived or something arrived without a code. |
| A test passes with a code that belongs to another test | The inbox fixture was moved to worker scope, so one box serves every test that worker runs. waitForOtp returns the newest message already in it and comes back instantly. | Keep the fixture at test scope as shown. This failure leaves the suite green, so it will not appear in a report and has to be looked for. |
| mf.create throws 403 during fixture setup | Opening and deleting boxes sit behind the inbox:manage scope, and a newly issued key carries inbox:read only. A key tied to a single inbox is refused here too, whatever its scopes, because a new box would land outside its reach. | Issue an account level key with inbox:manage, or pass a fixed address in through the environment and drop creation from the fixture. |
| The suite runs with more workers on CI and starts failing on the plan | Free allows three agent inboxes at once and the worker count decides how many exist simultaneously. This is a plan allowance rather than throttling, so it answers 400 and names the number. | Set workers in the config, make sure the fixture teardown really runs so the allowance frees, or share one address across the suite. Paid plans do not cap agent inboxes. |
| msg.raw.links is empty on a message that clearly contains a link | Links are extracted from the message the sender delivered. A mail whose only link sits inside an image or a tracking redirect that the body never spells out has nothing to extract. | Read msg.raw.body_html and pull the href you need, and send us the message shape so it can be handled properly rather than worked around. |
See also
TypeScript
The same package as JavaScript, with types shipped in the box: Message, CreateInboxOptions, WaitOptions and typed errors.
Waiting and timeoutsMail is asynchronous, so every email test waits for something. The difference between a solid suite and a flaky one is how that wait is written.
Testing & CIGive every CI run its own real inbox: the four API calls, parallel shards, teardown, and how to keep an end-to-end suite fast and honest.