A practical guide for developers using temp email to test signup flows, OTP verification, and transactional email in development and CI/CD pipelines.
By Alex Morgan | Developer & Privacy Researcher | Last updated: May 2026
I build web apps and have used temp email extensively for testing email verification flows in development. This guide is based on real developer workflows.
Every time you test your app's signup flow in development, you either spam your real inbox or create yet another Gmail alias. There's a better way. Here's how developers actually use temp email for testing โ and the tools that make it scriptable.
Email flows are stateful, asynchronous, and annoying to reset. Signup verification, password resets, magic links, invite emails, billing notices, and OTP prompts all depend on a mailbox outside your app. During development, your real inbox becomes a graveyard of "Verify your account" messages. Gmail aliases help locally, but they are not enough for CI/CD because aliases still route into one mailbox, can hit provider rate limits, and require fragile IMAP parsing.
Disposable inboxes solve a different problem: each test run can get a fresh mailbox. If a test fails, you throw away the inbox with the run. If you need to test domain reputation or staging deliverability, a real external inbox is more realistic than Mailhog.
The key is choosing the right level of realism. Unit tests should not send email. Component tests should not wait on external services. But staging E2E tests should occasionally exercise the real delivery path, because that is where bugs hide: broken DNS, misconfigured SPF/DKIM, provider sandbox mode, bad template variables, blocked links, and OTP parsing changes.
For manual QA, the workflow is simple: generate an inbox, use it in your local or staging signup form, wait for the verification message, copy the OTP, and assert that the user reaches the expected state.
This is useful when testing copy, template rendering, spam-folder behavior, and final-mile delivery. For local-only message rendering, Mailpit or Mailhog is faster. For staging systems that send through Postmark, SES, Resend, Mailgun, or SendGrid, temp email catches the real external path.
My manual checklist is short: create a fresh inbox, sign up with a unique test user, confirm the subject line, verify the sender, inspect the HTML, copy the OTP or magic link, and check whether the app marks the email as verified. If any of those steps fail manually, automation will only make the failure harder to understand.
The pattern is always the same: create inbox, trigger the app action, poll for email, extract the code, continue the test. The exact API depends on your provider. FireTempMail is good for manual testing; for CI/CD automation use a scriptable provider such as the Tempo-Mail API.
// Create inbox and wait for verification email
async function getVerificationCode(timeout = 30000) {
const inbox = await createTempInbox();
await signupUser({ email: inbox.address });
const email = await waitForEmail(inbox.id, timeout);
return extractOTP(email.body);
}
async function createTempInbox() {
const res = await fetch(process.env.TEMP_MAIL_API + '/inboxes', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.TEMP_MAIL_API_KEY }
});
if (!res.ok) throw new Error('Failed to create inbox');
return res.json(); // { id, address }
}
async function waitForEmail(inboxId, timeout) {
const start = Date.now();
while (Date.now() - start < timeout) {
const res = await fetch(process.env.TEMP_MAIL_API + '/inboxes/' + inboxId + '/messages', {
headers: { Authorization: 'Bearer ' + process.env.TEMP_MAIL_API_KEY }
});
const messages = await res.json();
if (messages[0]) return messages[0];
await new Promise((resolve) => setTimeout(resolve, 1500));
}
throw new Error('Timed out waiting for verification email');
}
function extractOTP(body) {
const match = body.match(/\b\d{6}\b/);
if (!match) throw new Error('OTP not found');
return match[0];
}
Here is the same idea in pytest. The code assumes your temp mail provider returns JSON and that your app exposes a helper for creating users in the browser or API.
import os, re, time, requests
API = os.environ["TEMP_MAIL_API"]
KEY = os.environ["TEMP_MAIL_API_KEY"]
def create_temp_inbox():
r = requests.post(f"{API}/inboxes", headers={"Authorization": f"Bearer {KEY}"})
r.raise_for_status()
return r.json()
def wait_for_otp(inbox_id, timeout=30):
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(f"{API}/inboxes/{inbox_id}/messages",
headers={"Authorization": f"Bearer {KEY}"})
r.raise_for_status()
messages = r.json()
if messages:
match = re.search(r"\b\d{6}\b", messages[0]["body"])
if match:
return match.group(0)
time.sleep(1.5)
raise TimeoutError("No OTP received")
def test_signup_flow(client):
inbox = create_temp_inbox()
client.post("/signup", json={"email": inbox["address"], "password": "testpass123"})
otp = wait_for_otp(inbox["id"])
response = client.post("/verify", json={"email": inbox["address"], "otp": otp})
assert response.status_code == 200
Keep your OTP extractor boring. Do not parse the entire template with brittle selectors unless you need to. A strict regex for a six-digit code is often enough. If your product supports both magic links and OTPs, add a helper that extracts links by hostname and rejects unexpected domains. That catches accidental phishing-style template bugs before users see them.
function extractMagicLink(html) {
const matches = [...html.matchAll(/href="([^"]+)"/g)].map((m) => m[1]);
const link = matches.find((url) => url.startsWith(process.env.APP_ORIGIN + '/verify'));
if (!link) throw new Error('Verification link not found');
return link.replace(/&/g, '&');
}
For a real suite, wrap the provider calls behind your own interface. That makes it painless to swap a local fake inbox for Mailpit in development, Tempo-Mail in CI, and a stub in unit tests.
export interface TestInbox {
id: string;
address: string;
}
export interface EmailClient {
createInbox(): Promise<TestInbox>;
waitForMessage(inboxId: string, timeoutMs: number): Promise<{ subject: string; body: string }>;
}
export async function verifySignup(emailClient: EmailClient) {
const inbox = await emailClient.createInbox();
await signupUser({ email: inbox.address });
const message = await emailClient.waitForMessage(inbox.id, 30000);
return extractOTP(message.body);
}
End-to-end tests are where disposable email shines. You can create a user like a real customer, without leaving fake accounts tied to your primary inbox.
import { test, expect } from '@playwright/test';
test('user signup flow', async ({ page }) => {
const inbox = await createTempInbox();
await page.goto('/signup');
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="password"]', 'testpass123');
await page.click('[type="submit"]');
const otp = await waitForOTP(inbox.id);
await page.fill('[name="otp"]', otp);
await page.click('text=Verify');
await expect(page).toHaveURL('/dashboard');
});
In Cypress, put inbox creation in a task so the browser test does not expose API keys. In Selenium, use the same API helper from your test runner and pass the address into the browser automation.
// cypress.config.js
const { defineConfig } = require('cypress');
module.exports = defineConfig({
e2e: {
setupNodeEvents(on) {
on('task', {
async createInbox() {
return createTempInbox();
},
async waitForOTP(inboxId) {
return waitForOTP(inboxId);
}
});
}
}
});
// signup.cy.js
it('verifies a new account by email', () => {
cy.task('createInbox').then((inbox) => {
cy.visit('/signup');
cy.get('[name=email]').type(inbox.address);
cy.get('[name=password]').type('testpass123');
cy.get('button[type=submit]').click();
cy.task('waitForOTP', inbox.id).then((otp) => {
cy.get('[name=otp]').type(otp);
cy.contains('button', 'Verify').click();
cy.location('pathname').should('eq', '/dashboard');
});
});
});
CI changes the constraints. You need deterministic cleanup, rate-limit awareness, and secrets stored outside the repo. A minimal GitHub Actions workflow looks like this:
name: e2e
on: [push]
jobs:
test:
runs-on: ubuntu-latest
env:
TEMP_MAIL_API: \${{ secrets.TEMP_MAIL_API }}
TEMP_MAIL_API_KEY: \${{ secrets.TEMP_MAIL_API_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run test:e2e
Do not create hundreds of inboxes per minute unless your provider supports it. Add jitter to polling, delete inboxes after runs when the API supports deletion, and mark email-dependent tests separately so you can rerun them without rerunning your whole suite.
Use environment variables for every endpoint and API key. Your test code should run against local, staging, and preview deployments without editing source. I also recommend a feature flag like EMAIL_E2E_ENABLED so pull requests can skip external email checks while nightly builds run the full flow.
Rate limiting is the failure mode that surprises teams. Polling every 250ms from eight parallel workers can look like abuse. Poll every 1-2 seconds, add a hard timeout, and include the inbox ID in test failure output. That way the failed run leaves enough evidence to debug whether the app never sent mail, the provider delayed delivery, or the parser failed.
async function waitForOTP(inboxId, timeout = 45000) {
const started = Date.now();
let attempts = 0;
while (Date.now() - started < timeout) {
attempts += 1;
const email = await getLatestMessage(inboxId);
if (email) return extractOTP(email.body);
const delay = 1000 + Math.min(attempts * 250, 2000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error('No OTP for inbox ' + inboxId + ' after ' + timeout + 'ms');
}
Use Mailpit or Mailhog when testing local templates and you do not care about public deliverability. Use a catch-all on your own domain when a third-party platform blocks all known disposable domains. Use a real test mailbox when testing account recovery for a service your company will keep. Temp email is excellent for staging verification; it is not a universal replacement for an email sandbox.
A good stack is layered: Mailpit for local developer loops, provider sandbox mode for template snapshots, temp email for staging E2E, and a small number of permanent QA inboxes for long-lived accounts. That gives you speed during development and realism before release.
Do not use public temp inboxes for tests that include personal data. Seed fake users, fake names, fake addresses, and fake order IDs. Email testing is valuable because it crosses system boundaries, but it should not become a privacy leak in your own QA process.
For CI/CD pipelines that need reliable temp email at scale, the Tempo-Mail API gives you programmatic inbox creation and OTP extraction. Use FireTempMail free for manual checks; use Tempo-Mail.net/api when inboxes need to be created, polled, and discarded by code.
The API approach also makes cleanup easier. A good automated flow creates the inbox at test start, tags it with the build ID if the provider supports metadata, polls only until the expected message arrives, then deletes or lets the inbox expire. That keeps test artifacts out of your normal mail and makes repeated CI runs predictable.
One final developer pattern: test negative cases too. Verify that expired OTPs fail, reused OTPs fail, malformed codes fail, and verification links cannot be used for the wrong account. Temp email helps you create enough fresh users to test those edge cases without manually cleaning your database after every run.
test('expired OTP cannot verify account', async ({ page }) => {
const inbox = await createTempInbox();
await signupUser({ email: inbox.address });
const otp = await waitForOTP(inbox.id);
await expireOtpForUser(inbox.address);
await page.goto('/verify');
await page.fill('[name="email"]', inbox.address);
await page.fill('[name="otp"]', otp);
await page.click('text=Verify');
await expect(page.getByText('Code expired')).toBeVisible();
});
That is where disposable inboxes become more than convenience. They let your tests model real user lifecycle events: first signup, resend code, wrong code, expired code, password reset, email change, and account deletion. Those flows are hard to test well with one shared inbox.
In a larger codebase, keep these tests in a separate project or tag, such as @email. Run them before releases and on nightly builds, but do not block every small pull request on an external mailbox unless email is the feature being changed. This keeps the suite useful instead of making developers resent it.
Log enough context to debug without leaking secrets: inbox ID, message subject, provider response status, and elapsed wait time. Never log full verification links in public CI output if those links can authenticate a user.
For teams, this also creates a shared language: "local email test" means Mailpit, "staging delivery test" means temp inbox, and "production smoke test" means a controlled QA account. That clarity prevents accidental use of real customer addresses in test runs.
It also makes failures easier to route: infrastructure owns delivery, product owns copy, and QA owns the scenario.
That saves real debugging time later for everyone.
Related: how platforms detect temp email, testing Cursor AI, why use temporary email, and temp Gmail.
No. Mock email delivery in unit tests. Use temp email for integration and E2E tests.
Use generous timeouts, poll every 1-2 seconds, and assert on subject/sender before extracting codes.
Yes for test accounts. Never send production user data to public temporary inboxes.
Yes, if each worker creates its own inbox and your provider rate limit supports parallel polling.