Testing signup flows is one of the most painful parts of QA automation. The flow itself is usually simple — fill a form, verify an email, access the app. But getting a fresh, isolated email address for each test run, reliably, without flakiness, without shared state leaking between parallel runs, is surprisingly hard. Programmatic disposable inboxes solve this completely.
Why Standard Approaches Fail in CI
- Shared inbox: if multiple tests share one email address and run in parallel, they pick up each other's OTPs. Test A gets Test B's verification code and fails.
- Temp mail services: sites like Guerrilla Mail and 10minutemail are actively blocked by many services and unreliable via API.
- Email mocking: mocking the email delivery doesn't test that your mail server actually sends. A real E2E test needs real email delivery.
- Unique plus-addressing: user+test123@gmail.com all routes to the same inbox, still causes parallel collisions, and many services reject plus addresses.
Inbox Per Test: The Right Pattern
The correct pattern is one inbox per test, created fresh in beforeEach and deleted in afterEach. Each test run gets a unique address with a clean inbox. OTPs cannot bleed between tests. Parallel test suites cannot interfere with each other.
This pattern works identically in local development and CI. The only difference is the API key, which comes from an environment variable in both cases.
Playwright Test Example
import { test, expect } from "@playwright/test";
import AgentMailr from "agentmailr";
const client = new AgentMailr({ apiKey: process.env.AGENTMAILR_API_KEY });
test.describe("signup flow", () => {
let inbox: { id: string; address: string };
test.beforeEach(async () => {
// Fresh inbox for every test
inbox = await client.inboxes.create();
});
test.afterEach(async () => {
// Clean up — no lingering test data
await client.inboxes.delete(inbox.id);
});
test("completes OTP verification", async ({ page }) => {
await page.goto("/signup");
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', "TestPassword123!");
await page.click('[type="submit"]');
// Wait for real OTP delivery
const { otp } = await client.messages.waitForOTP({
inboxId: inbox.id,
timeout: 30_000,
});
await page.fill('[name="otp"]', otp);
await page.click('[type="submit"]');
await expect(page).toHaveURL("/dashboard");
await expect(page.locator("h1")).toContainText("Welcome");
});
test("resends OTP on request", async ({ page }) => {
await page.goto("/signup");
await page.fill('[name="email"]', inbox.address);
await page.click('[type="submit"]');
// Get first OTP
await client.messages.waitForOTP({ inboxId: inbox.id, timeout: 15_000 });
// Request resend
await page.click('button:has-text("Resend")');
// Get second OTP — no collision because inbox is isolated
const { otp } = await client.messages.waitForOTP({
inboxId: inbox.id,
timeout: 15_000,
});
await page.fill('[name="otp"]', otp);
await page.click('[type="submit"]');
await expect(page).toHaveURL("/dashboard");
});
});
Using in CI/CD
No changes are needed to run these tests in CI. Add the AGENTMAILR_API_KEY secret to your CI environment (GitHub Actions, GitLab CI, CircleCI, etc.) and the tests run exactly as they do locally. Each parallel test worker gets its own inbox. Flaky OTP collisions are eliminated by construction.
# .github/workflows/e2e.yml
- name: Run E2E tests
env:
AGENTMAILR_API_KEY: ${{ secrets.AGENTMAILR_API_KEY }}
run: npx playwright test
# That's it. No special configuration needed.
# Each test gets its own inbox automatically.
Start Free
Add disposable inboxes to your test suite today. Free to start, no credit card required.