back to blog
Guides

testing signup & otp flows with disposable inboxes

June 8, 2026 9 min readguides
A friendly robot plucking a glowing six-digit code chip out of an open envelope, with gears and a green checkmark
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.

// the problem with shared test inboxes

the problem with shared test inboxes

The first instinct is to point every test at one fixed address —qa+otp@yourcompany.com — and call it a day. It works until it doesn't, in roughly this order:

  • parallelism breaks it. Two tests run at the same time, both grab the most recent message, one of them gets the other's code.
  • stale codes break it. A flaky retry resends the email. Now there are two valid-looking 6-digit codes in the inbox and "first match" is wrong.
  • cleanup breaks it. Nobody owns the mailbox cleanup script. It fills up. The provider rate-limits you. The pipeline goes red on a Friday.
  • auth breaks it. Your CI needs credentials for a real mailbox. Now you have a secret to rotate and a 2FA flow to babysit.

The fix is to flip the model: each test owns a unique, throwaway address that the test itself creates and forgets about.

// 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 waitForMessage filtered by subject and receivedAfter.
  • Read the typed extracted field 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 Gharby
// written by
Amine Gharby
Founder, ephemail

Amine builds ephemail and writes about email plumbing, developer tooling, and the small annoyances of testing software.

// try it

spin up a throwaway inbox

no signup. instant. gone in ten minutes.

open inbox
// related posts

keep reading