Skip to content
Skip to main content

E2E email testing with Playwright

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.

One-time:

nylas inbound create e2e

You 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.

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.

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.

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.

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.

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");

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}`);
});
  • 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 execSync blocks the test until the CLI returns. For chatty test suites, consider replacing with execAsync and 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.