Disposable Email API for QA Testing in 2026: Complete Developer Guide

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 fcemail

Core 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_ENV

See 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

msg.otp

5–15 min

Magic link

Click URL

msg.verificationLink

15–60 min

Email confirmation

Confirm link

msg.verificationLink

24–72 h

Invite-based

Invite URL + token

msg.verificationLink

7 days

Two-step (code + password)

Numeric code

msg.otp

30 min


Installation

npm install freecustom-email   # JavaScript
pip install freecustom-email   # Python
npm install -g fcemail         # CLI

Complete 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/

See CI/CD pipelines use case.


Automation Integrations

For no-code and low-code QA workflows:


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 freenpm install freecustom-emailpip install freecustom-emailSee all signup flow use casesView OTP extraction docsRead CI/CD pipeline guide

Written by

D

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.

FAQ

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.

Discussion0

No comments yet. Be the first to share your thoughts.