testing signup & otp flows with disposable inboxes

on this page
// the takeaway
the takeaway
A reliable signup-and-OTP test is six steps: create a fresh inbox, kick off the signup, wait for the verification message with a long poll, extract the code, type it, assert success. Skip the regex parser, skip the setTimeout, and use a new address per test so parallel runs don't steal each other's mail.
// step 1 — provision an address via api
step 1 — provision an address via api
A disposable-mail provider exposes an endpoint that mints a fresh inbox. With ephemail it's one call:
POST /v1/addresses
Authorization: Bearer $EPHEMAIL_KEY
# response
{
"id": "addr_01HZ7M...",
"address": "tmp.k2n9x4@ephemail.io",
"expiresAt": "2026-06-08T14:25:00Z"
}Call this in your test's beforeEach (or at the top of the test). You get a string you can paste straight into the signup form.
// step 2 — trigger the signup flow
step 2 — trigger the signup flow
Drive the UI like you'd drive any other end-to-end scenario. The only thing worth getting right here is making the request correlatable— pass the address as a unique identifier so the email you wait for can't be confused with anything else.
await page.goto("/signup");
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', "correct horse battery staple");
await page.click('button[type="submit"]');// step 3 — wait for the message
step 3 — wait for the message
Email delivery is asynchronous and the latency varies. A fixed sleep is the wrong tool. Use the provider's long-poll endpoint and filter on something specific:
POST /v1/wait-for-message
{
"address": "tmp.k2n9x4@ephemail.io",
"subjectContains": "verify your account",
"receivedAfter": "2026-06-08T14:20:00Z",
"timeoutMs": 30000
}The receivedAfter filter is the safety net for resends: even if an old verification email is still sitting in storage, you only get the one that arrived during this test run.
// step 4 — extract the code
step 4 — extract the code
You have two options. Write a regex. Or read the typed extracted field that the inbox already populated at delivery time:
{
"id": "msg_01HZ...",
"subject": "Verify your account",
"extracted": {
"type": "otp",
"value": "604812",
"confidence": 0.98
}
}If you do roll your own, scope the regex to the part of the body the code actually lives in — never grep the entire HTML, because tracking pixels and timestamps will happily match /\b\d6\b/.
// step 5 — assert in your test
step 5 — assert in your test
Put the pieces together. This is a full Playwright test:
import { test, expect } from "@playwright/test";
import { Ephemail } from "@ephemail/sdk";
const mail = new Ephemail({ apiKey: process.env.EPHEMAIL_KEY! });
test("user can sign up and verify", async ({ page }) => {
const inbox = await mail.createAddress();
const startedAt = new Date();
// 1. trigger
await page.goto("/signup");
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', "correct horse battery staple");
await page.click('button[type="submit"]');
// 2. wait
const msg = await mail.waitForMessage({
address: inbox.address,
subjectContains: "verify your account",
receivedAfter: startedAt.toISOString(),
timeoutMs: 30_000,
});
// 3. extract
expect(msg.extracted?.type).toBe("otp");
const code = msg.extracted!.value;
// 4. assert
await page.fill('[name="code"]', code);
await page.click('button[type="submit"]');
await expect(page).toHaveURL("/welcome");
await expect(page.getByText("Welcome")).toBeVisible();
});Same idea in pytest with the Python SDK — one address per test, one long-poll, one assertion.
import os
from datetime import datetime, timezone
from ephemail import Ephemail
mail = Ephemail(api_key=os.environ["EPHEMAIL_KEY"])
def test_signup_and_verify(page):
inbox = mail.create_address()
started = datetime.now(timezone.utc)
page.goto("/signup")
page.fill('[name="email"]', inbox.address)
page.fill('[name="password"]', "correct horse battery staple")
page.click('button[type="submit"]')
msg = mail.wait_for_message(
address=inbox.address,
subject_contains="verify your account",
received_after=started.isoformat(),
timeout_ms=30_000,
)
assert msg.extracted.type == "otp"
page.fill('[name="code"]', msg.extracted.value)
page.click('button[type="submit"]')
assert page.url.endswith("/welcome")// step 6 — teardown (mostly nothing)
step 6 — teardown (mostly nothing)
This is the part that gets the smallest section. Disposable addresses expire on their own — public ones in ten minutes, private ones on a workspace timer. You don't need an afterEach to delete mail.
The only thing worth doing on teardown is logging the inbox URL when a test fails:
test.afterEach(async ({}, testInfo) => {
if (testInfo.status !== testInfo.expectedStatus) {
console.log(`failing inbox: ${inbox.viewUrl}`);
}
});Drop that link into the bug ticket and anyone on the team can open the exact message your test saw — for as long as the inbox is alive.
// key takeaways
key takeaways
- One disposable address per test — never share one across parallel runs.
- Replace fixed sleeps with a long-poll
waitForMessagefiltered by subject andreceivedAfter. - Read the typed
extractedfield instead of maintaining a regex for every template change. - Set a 30-second timeout, scope link selectors to the CTA, and log the inbox URL on failure for instant debugging.
- Auto-expiring addresses mean no cleanup step, no rotating mailbox creds, and no long-lived record of test data.
try it on your own signup flow — spin up a throwaway inbox →
Use disposable inboxes for testing software you control. When testing against third-party providers, follow their terms of service.
Amine builds ephemail and writes about email plumbing, developer tooling, and the small annoyances of testing software.


