If you’ve ever tried to test a “click the link in your email” flow in Playwright, you know the shape of the suffering: shared Gmail catch-all, OAuth on the test runner, forwarding rules to a Slack channel, label rules to scope per-PR runs, race conditions when two CI workers read the same inbox at the same moment. The shared inbox is the single biggest source of flakiness in email-dependent E2E tests.
The fix: every test gets its own address on a wildcard inbox you control. No OAuth on the runner. No race conditions. No “did this email arrive yet?” timeouts that fire 200ms before the email actually arrives.
The setup
Section titled “The setup”One-time:
nylas inbound create e2eYou get back an inbox ID and a wildcard pattern shaped like e2e-*@yourapp.nylas.email. From here on, each test fixture mints its own unique address under that wildcard.
Per-test fixture
Section titled “Per-test fixture”import { test as base, expect } from "@playwright/test";import { execSync } from "node:child_process";import { randomUUID } from "node:crypto";
type Fixtures = { testEmail: string; pollInbox: (timeoutMs?: number) => Promise<any>;};
export const test = base.extend<Fixtures>({ testEmail: async ({}, use) => { const addr = `e2e-${randomUUID()}@yourapp.nylas.email`; await use(addr); },
pollInbox: async ({ testEmail }, use) => { const poll = async (timeoutMs = 30_000) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const out = execSync( `nylas inbound messages ${process.env.INBOX_ID} --json --limit 50`, ).toString(); const msgs = JSON.parse(out); const match = msgs.find((m: any) => m.to.some((t: any) => t.email === testEmail), ); if (match) return match; await new Promise((r) => setTimeout(r, 1500)); } throw new Error(`Email never arrived for ${testEmail}`); }; await use(poll); },});
export { expect };Two fixtures: testEmail mints a UUID-suffixed address per test, pollInbox waits for a matching message and returns it.
Use it
Section titled “Use it”A signup-with-verification flow:
test("signup completes after email verification", async ({ page, testEmail, pollInbox }) => { await page.goto("/signup"); await page.getByLabel("Email").fill(testEmail); await page.getByLabel("Password").fill("hunter2-correct"); await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("Check your inbox")).toBeVisible();
const msg = await pollInbox(); const linkMatch = msg.body.match(/https:\/\/[^\s"<]+\/verify\?[^\s"<]+/); expect(linkMatch).not.toBeNull();
await page.goto(linkMatch![0]); await expect(page.getByText("Email verified")).toBeVisible();});Password reset is the same shape — fill the form, poll the inbox, extract the link, navigate, complete the flow.
Parallel-safe by construction
Section titled “Parallel-safe by construction”Playwright runs tests in parallel across workers. Each test gets a different testEmail UUID, so the polling logic only sees its own messages. No filtering by subject, no “wait until the right message bubbles to the top” — you just look for to: testEmail and you’ve got the right mail.
For Playwright’s fullyParallel: true config, the INBOX_ID is shared but the addresses are unique. The wildcard endpoint accepts mail to all of them.
Common patterns
Section titled “Common patterns”OTP capture
Section titled “OTP capture”For 2FA / OTP flows, parse the body for a 6-digit number:
const msg = await pollInbox();const code = msg.body.match(/\b\d{6}\b/)?.[0];expect(code).toBeDefined();await page.getByLabel("Verification code").fill(code!);For more robust extraction (the body sometimes has multiple 6-digit numbers — phone numbers, transaction IDs, etc.), use the Extract OTP codes from email helper.
HTML link extraction
Section titled “HTML link extraction”When the verification URL is in <a href> rather than plain text, parse the HTML:
import * as cheerio from "cheerio";
const $ = cheerio.load(msg.html);const link = $('a:contains("Verify your email")').attr("href");Cleanup
Section titled “Cleanup”By default, mail in the inbox stays for the standard retention window. For a noisier signal, mark messages read after each test:
test.afterEach(async ({ pollInbox }) => { // optional — only if you want a clean inbox for debugging execSync(`nylas inbound messages-read ${process.env.INBOX_ID} --to ${testEmail}`);});Things to know
Section titled “Things to know”- Latency is sub-5-seconds typical. The poll interval (1.5s) catches most messages within two iterations. Bump the timeout for unusually slow flows; 30s is generous for most.
- The CLI is sync. Playwright’s
execSyncblocks the test until the CLI returns. For chatty test suites, consider replacing withexecAsyncand awaiting in parallel. - One inbox, many addresses. You don’t pay or provision per address — the wildcard pattern is just a convention. Burn UUIDs freely.
- MX is on Nylas. Mail flows through Nylas’s MX. There’s nothing to configure in your DNS. The downside: addresses always live under
*.nylas.email.