Most Playwright suites stop right before the hard part of a signup flow: the "confirm your email" link, the emailed 6-digit code, the password-reset mail. The usual reasons are that you need a real inbox your test can read, and every option seems to want an account, an API key, or a paid plan.
This post shows how to test those flows end to end with a disposable inbox that needs no signup and no API key. Full disclosure: I built the inbox service (receivemail.dev) and the small Playwright fixture used below, because I wanted exactly this for my own projects.
The idea: one fresh inbox per test
The pattern that keeps email tests reliable:
- Create a brand-new inbox for each test (no shared state, no stale mail from a previous run).
- Sign up in your app with that address.
- Poll the inbox until the expected email arrives, with a timeout.
- Pull the link or code out of the body and finish the flow.
Install
npm i -D playwright-receivemail
@playwright/test is a peer dependency, so nothing else to add.
Test a "verify your email" link
Import test and expect from the package instead of @playwright/test. You get a mailbox fixture: a fresh @receivemail.dev address for every test.
import { test, expect } from "playwright-receivemail";
test("a new user can verify their email", async ({ page, mailbox }) => {
await page.goto("/signup");
await page.getByLabel("Email").fill(mailbox.address);
await page.getByLabel("Password").fill("test-Pa55w0rd");
await page.getByRole("button", { name: "Create account" }).click();
// Wait for the email, then pull the verification link out of it
const link = await mailbox.waitForMatch(/https:\/\/[^\s"]+\/verify[^\s"]*/, {
subjectContains: "verify",
timeout: 30_000,
});
await page.goto(link);
await expect(page.getByText("Email verified")).toBeVisible();
});
Adjust the regex and the final assertion to your app's link format and confirmation text.
Test an emailed OTP code
const code = await mailbox.waitForMatch(/\b\d{6}\b/);
await page.getByLabel("Verification code").fill(code);
Assert on the whole message
const msg = await mailbox.waitForMessage({ fromContains: "noreply@yourapp" });
expect(msg.subject).toContain("Welcome");
waitForMessage polls every 2 seconds and throws after 30 seconds by default (both configurable). By default it also ignores anything that was already in the mailbox before the test started, so a re-used address can't make a test pass on yesterday's email.
Already extending test?
Compose it instead of replacing your own fixtures:
import { test as base } from "@playwright/test";
import { withReceivemail } from "playwright-receivemail";
export const test = withReceivemail(base, { ttlMinutes: 30 });
Mailboxes live 15 minutes by default (1 to 60 configurable), which is plenty for a CI run.
Without the fixture (any test runner)
The fixture is a thin wrapper over two HTTP calls, so you can use the same inbox from Cypress, Jest, a shell script, or anything else:
# 1. create an inbox: returns { address, expiresAt, secret }
curl -s -X POST https://receivemail.dev/mailboxes
# 2. read it (the secret from step 1 is the bearer token)
curl -s https://receivemail.dev/mailboxes/<address>/messages \
-H "authorization: Bearer <secret>"
There's also a Cypress plugin (cypress-receivemail) with cy.createMailbox() and cy.waitForEmail().
When not to use this
This is built for test environments: short-lived inboxes for test accounts. Each inbox can only be read with the secret returned when it was created, but expired inboxes and their mail are wiped in a daily cleanup, so don't point real users or anything you need to keep at it. If you need long retention or an SLA, use a paid service.
Links
- receivemail.dev: the inbox API, free
- playwright-receivemail on npm
- cypress-receivemail on npm
If you try it, I'd like to hear what broke or what was missing.
Top comments (0)