Introduction
Disposable email addresses are the backbone of QA automation. Every test that touches an email-based flow — user registration, password reset, account verification, newsletter confirmation — needs a fresh, isolated email address that can receive mail, be read programmatically, and be discarded cleanly afterward.
The naive approach is to reuse a shared test email. This works until two developers run the same test simultaneously, or until an old email from a previous run is mistakenly picked up. Then you have flaky tests and debugging sessions that trace back to a shared, contaminated inbox.
The correct architecture: create a new unique inbox per test, use it, delete it. Private, guaranteed clean, safe for parallel execution.
FreeCustom.Email provides official JavaScript and Python SDKs for exactly this pattern — instant inbox creation, long-poll wait, automatic OTP extraction (Growth+), WebSocket push, and a free tier that covers active local development workloads.
The QA Disposable Email Pattern
Test start
│
├─ 1. Generate unique email qa-w0-1744190400@ditapi.info
│
├─ 2. Register inbox client.inboxes.register(email)
│
├─ 3. Trigger app action app.signup(email) / app.resetPassword(email)
│
├─ 4. Wait for email client.messages.waitFor(email, {...})
│
├─ 5. Extract OTP / link msg.otp / msg.verificationLink [Growth+]
│
├─ 6. Complete the flow app.verifyOtp(email, otp)
│
└─ 7. Cleanup client.inboxes.unregister(email)This pattern is isolation-safe: each test has its own inbox, no message can cross-contaminate, and cleanup prevents stale inboxes accumulating.
Installation
JavaScript / TypeScript
npm install freecustom-email
# ESM + CJS, TypeScript types included, Node.js 18+Python
pip install freecustom-email
# async-first with sync mode, Python 3.9+CLI
npm install -g fcemailCore SDK: Inbox and Message Operations
JavaScript
import { FreecustomEmailClient } from 'freecustom-email';
import type { InboxObject, Message } from 'freecustom-email';
const client = new FreecustomEmailClient({
apiKey: process.env.FCE_API_KEY!,
timeout: 10_000,
retry: { attempts: 2, initialDelayMs: 500 },
});
// Register a new inbox
const reg = await client.inboxes.register('mytest@ditapi.info');
// RegisterInboxResult(success: true, inbox: 'mytest@ditapi.info')
// List all registered inboxes
const inboxes: InboxObject[] = await client.inboxes.list();
// [{ inbox: 'mytest@ditapi.info', local: 'mytest', domain: 'ditapi.info' }]
// List messages in an inbox
const messages: Message[] = await client.messages.list('mytest@ditapi.info');
// Get a specific message
const msg: Message = await client.messages.get('mytest@ditapi.info', 'D3vt8NnEQ');
console.log(msg.subject, msg.otp, msg.verificationLink);
// Wait for next email (server-side long-poll)
const incoming = await client.messages.waitFor('mytest@ditapi.info', {
timeoutMs: 30_000,
pollIntervalMs: 2_000,
match: m => m.from.includes('noreply@'),
});
// Delete a specific message
await client.messages.delete('mytest@ditapi.info', 'D3vt8NnEQ');
// Unregister inbox (deletes all messages)
await client.inboxes.unregister('mytest@ditapi.info');Python
from freecustom_email import FreeCustomEmail
from freecustom_email.types import InboxObject, Message
client = FreeCustomEmail(api_key="fce_...", sync=False) # async mode
# Register
reg = await client.inboxes.register("mytest@ditapi.info")
# RegisterInboxResult(success=True, inbox='mytest@ditapi.info')
# List inboxes
inboxes: list[InboxObject] = await client.inboxes.list()
# List messages
messages: list[Message] = await client.messages.list("mytest@ditapi.info")
for msg in messages:
print(msg.subject, msg.otp, msg.verification_link)
print(msg.from_) # Note: 'from_' (Python keyword conflict)
# Get specific message
msg = await client.messages.get("mytest@ditapi.info", "D3vt8NnEQ")
# Wait with filter
incoming = await client.messages.wait_for(
"mytest@ditapi.info",
timeout_ms=30_000,
poll_interval_ms=2_000,
match=lambda m: "noreply" in m.from_,
)
# Delete message
await client.messages.delete("mytest@ditapi.info", "D3vt8NnEQ")
# Unregister inbox
await client.inboxes.unregister("mytest@ditapi.info")Full QA Client: TypeScript
// lib/qa-email.ts
import { FreecustomEmailClient, TimeoutError, PlanError } from 'freecustom-email';
import type { Message } from 'freecustom-email';
export class QAEmailHelper {
private client: FreecustomEmailClient;
constructor() {
this.client = new FreecustomEmailClient({
apiKey: process.env.FCE_API_KEY!,
timeout: 10_000,
retry: { attempts: 2, initialDelayMs: 500 },
});
}
/** Unique email safe for parallel Playwright/Jest workers */
generateEmail(workerIndex = 0, domain = 'ditapi.info'): string {
return `qa-w${workerIndex}-${Date.now()}@${domain}`;
}
async createInbox(email: string): Promise<void> {
await this.client.inboxes.register(email);
}
async waitForEmail(email: string, opts: {
timeoutMs?: number;
match?: (m: Message) => boolean;
} = {}): Promise<Message> {
return this.client.messages.waitFor(email, {
timeoutMs: opts.timeoutMs ?? 30_000,
pollIntervalMs: 2_000,
match: opts.match,
});
}
async waitForOtp(email: string, timeoutMs = 30_000): Promise<string> {
return this.client.otp.waitFor(email, { timeoutMs, pollIntervalMs: 2_000 });
}
async getFullMessage(email: string, messageId: string): Promise<Message> {
return this.client.messages.get(email, messageId);
}
async listMessages(email: string, limit = 10): Promise<Message[]> {
return this.client.messages.list(email);
}
async deleteInbox(email: string): Promise<void> {
await this.client.inboxes.unregister(email);
}
/** Full lifecycle: register → trigger → wait → cleanup */
async withInbox<T>(
workerIndex: number,
fn: (email: string) => Promise<T>,
): Promise<T> {
const email = this.generateEmail(workerIndex);
await this.createInbox(email);
try {
return await fn(email);
} finally {
await this.deleteInbox(email);
}
}
}
export const qa = new QAEmailHelper();Pytest: Full QA Email Fixtures (Python)
# tests/conftest.py
import os, time, pytest, asyncio
from freecustom_email import FreeCustomEmail
@pytest.fixture(scope="session")
def fce():
return FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
@pytest.fixture
async def disposable_inbox(fce):
"""Private isolated inbox, cleaned up after each test."""
email = f"qa-{int(time.time() * 1000)}@ditapi.info"
await fce.inboxes.register(email)
yield email
await fce.inboxes.unregister(email)
@pytest.fixture
def sync_inbox(fce):
"""Synchronous version for non-async tests."""
fce_sync = fce.__class__(api_key=os.environ["FCE_API_KEY"], sync=True)
email = f"qa-sync-{int(time.time() * 1000)}@ditapi.info"
fce_sync.inboxes.register(email)
yield email, fce_sync
fce_sync.inboxes.unregister(email)# tests/test_email_flows.py
import pytest
from freecustom_email.errors import WaitTimeoutError
@pytest.mark.asyncio
async def test_registration_email_arrives(fce, disposable_inbox, app_api):
"""Registration email is received with correct sender."""
await app_api.register(email=disposable_inbox, password="Pass@2026!")
msg = await fce.messages.wait_for(disposable_inbox, timeout_ms=30_000)
assert msg.from_ == "noreply@yourapp.com"
assert "verify" in msg.subject.lower()
assert msg.otp is not None # Growth+ plan
@pytest.mark.asyncio
async def test_password_reset_link(fce, disposable_inbox, app_api):
"""Password reset email contains valid verification link."""
await app_api.request_password_reset(email=disposable_inbox)
msg = await fce.messages.wait_for(
disposable_inbox,
timeout_ms=30_000,
match=lambda m: "password" in m.subject.lower(),
)
assert msg.verification_link is not None
assert "/reset-password" in msg.verification_link
assert "token=" in msg.verification_link
@pytest.mark.asyncio
async def test_email_content_no_template_bugs(fce, disposable_inbox, app_api):
"""Email content has no unfilled template variables."""
await app_api.register(email=disposable_inbox, password="Pass@2026!")
summary = await fce.messages.wait_for(disposable_inbox, timeout_ms=30_000)
full_msg = await fce.messages.get(disposable_inbox, summary.id)
assert "undefined" not in full_msg.html
assert "null" not in full_msg.html
assert "{{" not in full_msg.html # No Handlebars/Mustache remnants
assert full_msg.from_ == "hello@yourapp.com"
def test_sync_otp_flow(sync_inbox, app_api_sync):
"""Sync mode — no asyncio needed."""
email, fce_sync = sync_inbox
app_api_sync.trigger_otp(email)
otp = fce_sync.otp.wait_for(email, timeout_ms=30_000)
assert otp.isdigit()Parallel Worker Safety
// playwright.config.ts — 8 parallel workers
export default defineConfig({
fullyParallel: true,
workers: 8,
use: { baseURL: process.env.STAGING_URL },
});// Each worker gets a unique inbox — zero collision
test('registration flow', async ({ page }, testInfo) => {
await qa.withInbox(testInfo.workerIndex, async (email) => {
await page.goto('/signup');
await page.fill('[name="email"]', email);
await page.click('[type="submit"]');
const otp = await qa.waitForOtp(email, 30_000);
await page.fill('[data-testid="otp"]', otp);
await page.click('[data-testid="verify"]');
await expect(page).toHaveURL(/\/dashboard/);
});
});Email Content Validation
Beyond OTP extraction, disposable inboxes are useful for asserting that the right email was sent with the right content:
// Validate welcome email content
test('welcome email has correct content', async ({}, testInfo) => {
await qa.withInbox(testInfo.workerIndex, async (email) => {
// Trigger registration
await createTestUser(email);
// Get email summary
const summary = await qa.waitForEmail(email, {
match: m => m.subject.includes('Welcome'),
});
// Get full message for HTML inspection
const fullMsg = await qa.getFullMessage(email, summary.id);
// Assert content correctness
expect(summary.from).toBe('hello@yourapp.com');
expect(summary.subject).toContain('Welcome to');
expect(fullMsg.html).toContain('Get Started');
expect(fullMsg.html).not.toContain('undefined');
expect(fullMsg.html).not.toContain('{{'); // No template leaks
});
});Domain Management
// JavaScript — list available platform domains
const domains = await client.domains.list();
const withExpiry = await client.domains.listWithExpiry();
// Check for expiring domains
for (const d of withExpiry) {
if (d.expiring_soon) {
console.warn(`${d.domain} expires in ${d.expires_in_days} days`);
}
}
// Add custom domain (Growth+)
const result = await client.domains.addCustom('qa.yourcompany.com');
console.log('Add these DNS records:', result.dns_records);
// [{ type: 'MX', hostname: '@', value: 'mx.freecustom.email', priority: '10' },
// { type: 'TXT', hostname: '@', value: 'freecustomemail-verification=...' }]
const v = await client.domains.verifyCustom('qa.yourcompany.com');
console.log('Verified:', v.verified);# Python — domain management
domains = await client.domains.list()
all_domains = await client.domains.list_with_expiry()
for d in all_domains:
if d.expiring_soon:
print(f"Warning: {d.domain} expires in {d.expires_in_days} days")
# Custom domain
result = await client.domains.add_custom("qa.yourcompany.com")
for rec in result.dns_records:
print(f"{rec.type} {rec.hostname} → {rec.value}")
v = await client.domains.verify_custom("qa.yourcompany.com")
print(f"Verified: {v.verified}")See platform domains documentation and custom domains documentation.
CLI: Quick QA Workflows
# One-liner OTP for shell scripts
OTP=$(fce otp qa-test@ditapi.info)
echo "Using OTP: $OTP"
# Watch inbox in real-time while running tests
fce watch qa-test@ditapi.info
# CI/CD: grab OTP between steps
- name: Get verification OTP
run: echo "TEST_OTP=$(fce otp $TEST_EMAIL)" >> $GITHUB_ENVSee CLI documentation.
Account Usage Monitoring
const info = await client.account.info();
console.log(info.plan, info.credits, info.api_inbox_count);
console.log('OTP extraction:', info.features?.otp_extraction);
console.log('WebSocket:', info.features?.websocket);
console.log('Max WS connections:', info.features?.max_ws_connections);
const usage = await client.account.usage();
console.log(`${usage.requests_used} / ${usage.requests_limit} requests`);
console.log('Resets at:', usage.resets);info = await client.account.info()
print(info.plan, info.credits, info.api_inbox_count)
print(f"OTP extraction: {info.features.otp_extraction}")
usage = await client.account.usage()
print(f"{usage.requests_used} / {usage.requests_limit}")
print(f"Percent used: {usage.percent_used:.1f}%")Pricing for QA Testing
Plan | Price | Req/mo | Inboxes | OTP Extract | Best For |
|---|---|---|---|---|---|
Free | $0 | 5,000 | 10 | — | Local dev |
Developer | $7 | 100,000 | 25 | — | Small CI |
Startup | $19 | 500,000 | 40 | — | Active pipelines |
Growth | $49 | 2,000,000 | 100 | ✅ | Full OTP testing |
Enterprise | $149 | 10,000,000 | Unlimited | ✅ | Large QA dept |
Pay-as-you-go credits from $10/200k requests for burst workloads. See pricing.
FAQ
Q: How many disposable inboxes can I create? 10 (Free), 25 (Developer), 40 (Startup), 100 (Growth), unlimited (Enterprise). See pricing.
Q: Do inboxes expire automatically? Free: 10h persistence. Developer: 24h. Higher plans: until deleted. Always delete inboxes in test teardown.
Q: Can I use the sync Python SDK with Django or Flask? Yes — FreeCustomEmail(api_key="...", sync=True) works in any synchronous context.
Q: How do I assert that no email was sent? Call client.messages.list(email) after a short delay. An empty array confirms no email was sent.
Q: Does FCE work with Zapier or n8n? Yes — see Zapier integration and n8n integration.
Signup Flow Testing Email API in 2026: End-to-End Guide with SDK Examples
Meta Description: Testing your signup flow with email verification? FreeCustom.Email's official JS and Python SDKs handle OTP extraction, magic links, and disposable inboxes for complete end-to-end signup flow testing. Full guide with Playwright, pytest, and Jest examples.
Introduction
The signup flow is the most critical path in any user-facing application. If email verification fails — the OTP doesn't arrive, the link expires, the email goes to spam — you lose the user permanently. Testing this path automatically, reliably, and at scale is not optional.
FreeCustom.Email was purpose-built for signup flow testing: official SDKs in JavaScript and Python, built-in OTP and magic link extraction, private isolated inboxes, long-polling, WebSocket push, and a CLI for shell-based pipelines. This guide covers the complete implementation.
Signup Flow Types and What to Test
Signup Type | Email Contains | SDK Field | Expiry |
|---|---|---|---|
OTP verification | 6-digit code |
| 5–15 min |
Magic link | Click URL |
| 15–60 min |
Email confirmation | Confirm link |
| 24–72 h |
Invite-based | Invite URL + token |
| 7 days |
Two-step (code + password) | Numeric code |
| 30 min |
Installation
npm install freecustom-email # JavaScript
pip install freecustom-email # Python
npm install -g fcemail # CLIComplete Playwright Signup Test Suite
// tests/signup-flows.spec.ts
import { test, expect } from '@playwright/test';
import { FreecustomEmailClient, TimeoutError } from 'freecustom-email';
const client = new FreecustomEmailClient({
apiKey: process.env.FCE_API_KEY!,
retry: { attempts: 2, initialDelayMs: 500 },
});
const APP = process.env.BASE_URL || 'https://staging.yourapp.com';
// ── Fixtures ────────────────────────────────────────────────────────────────
const workerEmail = (testInfo: { workerIndex: number }) =>
`signup-w${testInfo.workerIndex}-${Date.now()}@ditapi.info`;
// ── OTP Signup ───────────────────────────────────────────────────────────────
test.describe('OTP signup flow', () => {
let email: string;
test.beforeEach(async ({}, ti) => {
email = workerEmail(ti);
await client.inboxes.register(email);
});
test.afterEach(async () => {
await client.inboxes.unregister(email);
});
test('new user completes OTP signup', async ({ page }) => {
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', 'SecurePass@2026!');
await page.fill('[data-testid="name"]', 'QA Tester');
await page.click('[data-testid="signup-btn"]');
await expect(page.locator('[data-testid="otp-screen"]')).toBeVisible({ timeout: 5000 });
// Long-poll wait — no sleep(), no polling loop
const otp = await client.otp.waitFor(email, {
timeoutMs: 30_000,
pollIntervalMs: 2_000,
});
expect(otp).toMatch(/^\d{4,8}$/);
await page.fill('[data-testid="otp-input"]', otp);
await page.click('[data-testid="verify-btn"]');
await expect(page).toHaveURL(new RegExp(`${APP}/(dashboard|onboarding)`));
await expect(page.locator('[data-testid="user-greeting"]')).toContainText('QA Tester');
});
test('resend OTP delivers new code', async ({ page }) => {
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', 'SecurePass@2026!');
await page.click('[data-testid="signup-btn"]');
// First OTP
const firstOtp = await client.otp.waitFor(email, { timeoutMs: 30_000 });
// Click resend
await page.click('[data-testid="resend-otp"]');
// Second OTP — wait for new email
const secondMsg = await client.messages.waitFor(email, {
timeoutMs: 30_000,
pollIntervalMs: 2_000,
});
expect(secondMsg.otp).toBeTruthy();
// Use the new OTP
await page.fill('[data-testid="otp-input"]', secondMsg.otp!);
await page.click('[data-testid="verify-btn"]');
await expect(page).toHaveURL(/\/dashboard/);
});
test('incorrect OTP shows error', async ({ page }) => {
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', 'SecurePass@2026!');
await page.click('[data-testid="signup-btn"]');
await client.otp.waitFor(email, { timeoutMs: 30_000 });
// Enter wrong OTP
await page.fill('[data-testid="otp-input"]', '000000');
await page.click('[data-testid="verify-btn"]');
await expect(page.locator('[data-testid="otp-error"]')).toBeVisible();
});
});
// ── Magic Link Signup ────────────────────────────────────────────────────────
test.describe('Magic link signup flow', () => {
let email: string;
test.beforeEach(async ({}, ti) => {
email = workerEmail(ti);
await client.inboxes.register(email);
});
test.afterEach(async () => {
await client.inboxes.unregister(email);
});
test('user signs up and authenticates via magic link', async ({ page }) => {
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', email);
await page.click('[data-testid="send-link-btn"]');
await expect(page.locator('[data-testid="check-email"]')).toBeVisible();
const msg = await client.messages.waitFor(email, {
timeoutMs: 30_000,
match: m => m.subject.toLowerCase().includes('verify') ||
m.subject.toLowerCase().includes('sign'),
});
expect(msg.verificationLink).toBeTruthy();
expect(msg.verificationLink).toContain('token=');
await page.goto(msg.verificationLink!);
await expect(page).toHaveURL(new RegExp(`${APP}/(dashboard|onboarding)`));
});
});
// ── Email Content Assertions ─────────────────────────────────────────────────
test.describe('Signup email content quality', () => {
let email: string;
test.beforeEach(async ({}, ti) => {
email = workerEmail(ti);
await client.inboxes.register(email);
});
test.afterEach(async () => {
await client.inboxes.unregister(email);
});
test('verification email has correct sender and no template bugs', async ({ page }) => {
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', email);
await page.fill('[data-testid="password"]', 'SecurePass@2026!');
await page.click('[data-testid="signup-btn"]');
const summary = await client.messages.waitFor(email, { timeoutMs: 30_000 });
// Assert sender / subject
expect(summary.from).toBe('noreply@yourapp.com');
expect(summary.subject).toMatch(/verif|confirm/i);
expect(summary.otp).toMatch(/^\d{6}$/); // Valid OTP format (Growth+)
// Fetch full message for HTML inspection
const full = await client.messages.get(email, summary.id);
expect(full.html).toContain('Verify');
expect(full.html).not.toContain('undefined');
expect(full.html).not.toContain('null');
expect(full.html).not.toContain('{{');
});
});
// ── Error Cases ──────────────────────────────────────────────────────────────
test.describe('Signup error paths', () => {
test('invalid email shows client-side error', async ({ page }) => {
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', 'not-valid');
await page.click('[data-testid="signup-btn"]');
await expect(page.locator('[data-testid="email-error"]')).toBeVisible();
// No email API call needed — no email sent
});
test('duplicate email shows error', async ({ page }) => {
// Uses a pre-seeded existing user in staging DB
await page.goto(`${APP}/signup`);
await page.fill('[data-testid="email"]', 'existing@yourapp.com');
await page.fill('[data-testid="password"]', 'AnyPass@2026!');
await page.click('[data-testid="signup-btn"]');
await expect(page.locator('[data-testid="duplicate-error"]')).toBeVisible();
});
});Pytest: Signup Flow Tests
# tests/test_signup_flows.py
import pytest, asyncio, time, os
from freecustom_email import FreeCustomEmail
from freecustom_email.errors import WaitTimeoutError
from playwright.async_api import async_playwright
@pytest.fixture(scope="session")
def fce():
return FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
@pytest.fixture
async def signup_email(fce):
email = f"signup-{int(time.time() * 1000)}@ditapi.info"
await fce.inboxes.register(email)
yield email
await fce.inboxes.unregister(email)
APP = os.environ.get("STAGING_URL", "https://staging.yourapp.com")
@pytest.mark.asyncio
async def test_otp_signup_flow(fce, signup_email):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
# Submit signup
await page.goto(f"{APP}/signup")
await page.fill('[data-testid="email"]', signup_email)
await page.fill('[data-testid="password"]', "SecurePass@2026!")
await page.click('[data-testid="signup-btn"]')
# Wait for OTP
otp = await fce.otp.wait_for(signup_email, timeout_ms=30_000)
assert otp.isdigit(), f"Non-numeric OTP: {otp}"
assert 4 <= len(otp) <= 8
# Submit OTP
await page.fill('[data-testid="otp-input"]', otp)
await page.click('[data-testid="verify-btn"]')
assert "/dashboard" in page.url or "/onboarding" in page.url
await browser.close()
@pytest.mark.asyncio
async def test_magic_link_signup_flow(fce, signup_email):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto(f"{APP}/signup")
await page.fill('[data-testid="email"]', signup_email)
await page.click('[data-testid="send-link-btn"]')
msg = await fce.messages.wait_for(signup_email, timeout_ms=30_000)
assert msg.verification_link, "No magic link in email"
assert "token=" in msg.verification_link
await page.goto(msg.verification_link)
assert "/dashboard" in page.url or "/onboarding" in page.url
await browser.close()Jest: Signup API-Level Tests
// tests/signup-api.test.js
const { FreecustomEmailClient, TimeoutError } = require('freecustom-email');
const client = new FreecustomEmailClient({
apiKey: process.env.FCE_API_KEY,
retry: { attempts: 2, initialDelayMs: 500 },
});
describe('Signup API flow', () => {
let email;
beforeEach(async () => {
email = `api-test-${Date.now()}@ditapi.info`;
await client.inboxes.register(email);
});
afterEach(async () => {
await client.inboxes.unregister(email);
});
test('full OTP signup completes', async () => {
const otp = await client.getOtpForInbox(
email,
async () => {
const res = await fetch(`${process.env.STAGING_URL}/api/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: 'SecurePass@2026!' }),
});
expect(res.status).toBe(201);
},
{ timeoutMs: 30_000, autoUnregister: false },
);
expect(otp).toMatch(/^\d{6}$/);
const verifyRes = await fetch(`${process.env.STAGING_URL}/api/verify-otp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, otp }),
});
expect(verifyRes.status).toBe(200);
}, 45_000);
test('timeout error when no email sent', async () => {
await expect(
client.otp.waitFor(email, { timeoutMs: 3_000 })
).rejects.toBeInstanceOf(TimeoutError);
}, 10_000);
});GitHub Actions CI/CD
# .github/workflows/signup-e2e.yml
name: Signup Flow E2E
on: [push, pull_request]
jobs:
signup-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx playwright install --with-deps
- name: Run signup flow tests
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
BASE_URL: ${{ secrets.STAGING_URL }}
run: npx playwright test tests/signup-flows.spec.ts
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/Automation Integrations
For no-code and low-code QA workflows:
Zapier integration — trigger actions when signup emails arrive
n8n automation — visual workflow for signup testing
Make.com integration — connect email events to QA dashboards
OpenClaw — AI-native signup flow automation
MCP for AI agents —
create_and_wait_for_otpin one tool call
FAQ
Q: Can FreeCustom.Email receive mail from SendGrid, Postmark, and AWS SES? Yes. FCE inboxes accept SMTP from any provider. Your app sends email normally; FCE delivers it to the registered inbox.
Q: How do I assert that no email was sent? Wait 5–10 seconds for delivery, then call client.messages.list(email). An empty array means no email was sent.
Q: Does the Python SDK support sync usage in Django? Yes — FreeCustomEmail(api_key="...", sync=True). See Python SDK documentation.
Q: What is getOtpForInbox vs calling otp.waitFor manually? getOtpForInbox registers the inbox, executes your trigger callback, waits for the OTP, and optionally unregisters — all in one call. It's the safest pattern because it triggers and waits simultaneously, minimizing the risk of missing an email.
Q: Where is the API FAQ? See the API FAQ documentation.
Q: How do I monitor usage to avoid hitting limits? Use client.account.usage() — returns requests_used, requests_limit, percent_used, and resets. See credits documentation.
Conclusion
Signup flow testing is non-negotiable for production-quality web applications. Every path — OTP, magic link, email confirmation, invite-based — must be automated, run in parallel, and cleaned up correctly.
FreeCustom.Email gives you the infrastructure to do this correctly: official SDKs with typed exceptions, automatic OTP and magic link extraction, private isolated inboxes, and pricing that starts at zero.
→ Get started free → npm install freecustom-email → pip install freecustom-email → See all signup flow use cases → View OTP extraction docs → Read CI/CD pipeline guide
Written by
Dishant Singh
A full stack developer with good knowledge of email server, SEO, proxies, and networking, have more than 3 years of experience in building webapps for the netizens. Developing open source, fast, and free SaaS for all.
Frequently Asked Questions
Q: How many disposable inboxes can I create?+
10 (Free), 25 (Developer), 40 (Startup), 100 (Growth), unlimited (Enterprise). See pricing.
Q: Do inboxes expire automatically?+
Free: 10h persistence. Developer: 24h. Higher plans: until deleted. Always delete inboxes in test teardown.
Q: Can I use the sync Python SDK with Django or Flask?+
Yes — FreeCustomEmail(api_key="...", sync=True) works in any synchronous context.
Q: How do I assert that no email was sent?+
Call client.messages.list(email) after a short delay. An empty array confirms no email was sent.
Q: Does FCE work with Zapier or n8n?+
Yes — see Zapier integration and n8n integration.
Q: How do I assert that no email was sent?+
Wait 5–10 seconds for delivery, then call client.messages.list(email). An empty array means no email was sent.
Q: Does the Python SDK support sync usage in Django?+
Yes — FreeCustomEmail(api_key="...", sync=True). See Python SDK documentation.
Q: Where is the API FAQ?+
See the API FAQ documentation.
Q: How do I monitor usage to avoid hitting limits?+
Use client.account.usage() — returns requests_used, requests_limit, percent_used, and resets. See credits documentation.
No comments yet. Be the first to share your thoughts.