Most E2E tests stop at "click submit" because the next step is "check your email." You can stub the email service, but stubs lie — the bug that bites you in production is usually the SMTP misconfig your stub never had. myagentinbox gives every test a real, disposable inbox in one HTTP call. Real emails, real verification codes, real magic links. No mail server to run.
Drop this in a tests/helpers/inbox.ts file. It wraps the four endpoints you'll actually use:
// tests/helpers/inbox.ts
const BASE = "https://myagentinbox.com";
export async function createInbox(): Promise<string> {
const r = await fetch(`${BASE}/api/inboxes`, { method: "POST" });
if (!r.ok) throw new Error(`createInbox failed: ${r.status}`);
const { data } = await r.json();
return data.address;
}
export async function waitForMessage(
address: string,
opts: { timeoutMs?: number; pollMs?: number; matching?: RegExp } = {}
): Promise<{ id: string; from: string; subject: string }> {
const timeout = opts.timeoutMs ?? 60_000;
const poll = opts.pollMs ?? 3_000;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const r = await fetch(`${BASE}/api/inboxes/${address}/messages`);
if (r.ok) {
const { data } = await r.json();
const match = opts.matching
? data.find((m: any) => opts.matching!.test(m.subject))
: data[0];
if (match) return match;
}
await new Promise((res) => setTimeout(res, poll));
}
throw new Error(`No message arrived in ${address} within ${timeout}ms`);
}
export async function readMessage(address: string, id: string) {
const r = await fetch(`${BASE}/api/inboxes/${address}/messages/${id}`);
if (!r.ok) throw new Error(`readMessage failed: ${r.status}`);
const { data } = await r.json();
return data; // { from, subject, text, html, attachments, ... }
}
export function extractCode(body: string, pattern = /\b(\d{6})\b/): string {
const m = body.match(pattern);
if (!m) throw new Error("No verification code found in message body");
return m[1];
}
export function extractMagicLink(body: string, host: string): string {
const re = new RegExp(`https?://${host.replace(/\./g, "\\.")}/[^\\s"<>]+`, "i");
const m = body.match(re);
if (!m) throw new Error(`No magic link for host ${host} found`);
return m[0];
}
import { test, expect } from "@playwright/test";
import {
createInbox,
waitForMessage,
readMessage,
extractCode,
} from "./helpers/inbox";
test("user can sign up and verify with a 6-digit code", async ({ page }) => {
const email = await createInbox();
await page.goto("https://app.example.com/signup");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill("Test-Password-1234");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByText("Check your email")).toBeVisible();
const msg = await waitForMessage(email, {
matching: /verify|verification|confirm/i,
timeoutMs: 60_000,
});
const full = await readMessage(email, msg.id);
const code = extractCode(full.text);
await page.getByLabel("Verification code").fill(code);
await page.getByRole("button", { name: "Verify" }).click();
await expect(page).toHaveURL(/\/dashboard/);
});
test("user can log in via magic link", async ({ page, context }) => {
const email = await createInbox();
await page.goto("https://app.example.com/login");
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Send magic link" }).click();
const msg = await waitForMessage(email, {
matching: /sign in|magic|login/i,
});
const full = await readMessage(email, msg.id);
const link = extractMagicLink(full.html ?? full.text, "app.example.com");
// Open the magic link in a fresh tab to mimic the user clicking from email
const newPage = await context.newPage();
await newPage.goto(link);
await expect(newPage).toHaveURL(/\/dashboard/);
});
context.newPage()) replicates how the user actually clicks the link — navigating page directly works for most apps but fails on a few.
If your app emails the user a PDF receipt or onboarding doc, you can pull that down inside the test too:
test("welcome email includes onboarding PDF", async ({ page }) => {
const email = await createInbox();
await page.goto("https://app.example.com/signup");
// ... fill form, submit ...
const msg = await waitForMessage(email, { matching: /welcome/i });
const full = await readMessage(email, msg.id);
expect(full.attachments.length).toBeGreaterThan(0);
const pdf = full.attachments.find((a: any) => a.filename.endsWith(".pdf"));
expect(pdf).toBeDefined();
const r = await fetch(
`https://myagentinbox.com/api/inboxes/${email}/messages/${msg.id}/attachments/${pdf.filename}`
);
const buf = Buffer.from(await r.arrayBuffer());
expect(buf.slice(0, 4).toString()).toBe("%PDF");
});
Each createInbox() call returns a unique address, so parallel test workers don't collide. The relevant rate limits, scoped per IP:
--workers=2.waitForMessage helper above polls every 3 seconds, so each waiting test uses ~20 reads/minute on its own. Lower the worker count or raise pollMs if you hit 429s.If your CI is hammering the rate limits, the simplest fix is to point a couple of staggered workers at the same suite rather than maxing out parallelism.
data[0] is racy. Pass a matching regex.extractCode throws, log full.text — the failure is almost always that your regex didn't match the actual message format (e.g. the code was in the HTML, not the text part).The helper module is plain fetch — no Playwright dependency. Drop the same file into a Cypress support/, a WebdriverIO custom command, or a Puppeteer script and it works as-is. Only the test-runner-specific glue (test(), page.goto, etc.) changes.