Introduction
Playwright is the de facto end-to-end testing framework for modern web applications — async-first, parallel by default, and excellent TypeScript support. But it has a natural blind spot: the email inbox.
When your app sends a verification email with a 6-digit OTP, Playwright cannot natively intercept it. You need external infrastructure. The wrong infrastructure turns clean Playwright tests into fragile messes: page.waitForTimeout(5000) scattered everywhere, regex embedded in test files, and polling loops that burn API quota and slow down CI runs.
The right infrastructure is FreeCustom.Email — official JavaScript SDK with TypeScript types, a server-side long-poll that pairs naturally with await, automatic OTP extraction (Growth+), and isolated private inboxes safe for parallel Playwright workers.
This guide is the complete integration reference.
Why Playwright Tests Need a Specific Email API
Requirement | Why It Matters |
|---|---|
| Playwright is async — polling loops are verbose and fragile |
Unique inboxes per worker | 4+ parallel workers must not share inboxes |
OTP pre-extracted | No regex in 50 test files |
Fast delivery | Slow email adds minutes to CI runs |
Private inbox isolation | No cross-test contamination |
Typed SDK | TypeScript errors at compile time, not runtime |
Cleanup on teardown | No orphaned inboxes after test runs |
FreeCustom.Email's SDK addresses every point.
Installation & Setup
1. Install the SDK
npm install freecustom-email
# or pnpm add freecustom-email2. Set your API key
# .env (used by Playwright via dotenv)
FCE_API_KEY=fce_your_key_hereGet your key from the API dashboard. The free plan covers local development.
3. Playwright config
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import * as dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
testDir: './tests',
fullyParallel: true,
workers: process.env.CI ? 4 : 2,
use: {
baseURL: process.env.BASE_URL || 'https://staging.yourapp.com',
trace: 'on-first-retry',
},
});Email Helper Module
Create tests/helpers/email.ts once — reuse across all specs:
// tests/helpers/email.ts
import { FreecustomEmailClient } from 'freecustom-email';
import type { Message } from 'freecustom-email';
export const emailClient = new FreecustomEmailClient({
apiKey: process.env.FCE_API_KEY!,
timeout: 10_000,
retry: { attempts: 2, initialDelayMs: 500 },
});
/**
* Generate a unique email safe for parallel Playwright workers.
* Uses workerIndex + timestamp to guarantee no collision.
*/
export function generateEmail(workerIndex = 0, domain = 'ditapi.info'): string {
return `qa-w${workerIndex}-${Date.now()}@${domain}`;
}
/**
* Register a private inbox.
*/
export async function createInbox(email: string): Promise<void> {
await emailClient.inboxes.register(email);
}
/**
* Wait for the next email, with optional condition filter.
* Uses server-side long-poll — no client polling loop.
*/
export async function waitForEmail(
email: string,
opts: { timeoutMs?: number; match?: (m: Message) => boolean } = {},
): Promise<Message> {
return emailClient.messages.waitFor(email, {
timeoutMs: opts.timeoutMs ?? 30_000,
pollIntervalMs: 2_000,
match: opts.match,
});
}
/**
* Wait for email and return extracted OTP (Growth+ plans).
*/
export async function waitForOtp(email: string, timeoutMs = 30_000): Promise<string> {
return emailClient.otp.waitFor(email, { timeoutMs, pollIntervalMs: 2_000 });
}
/**
* Delete inbox after test.
*/
export async function deleteInbox(email: string): Promise<void> {
await emailClient.inboxes.unregister(email);
}Complete Example: OTP Signup Flow
// tests/signup-otp.spec.ts
import { test, expect } from '@playwright/test';
import { generateEmail, createInbox, waitForOtp, deleteInbox } from './helpers/email';
test.describe('Signup with OTP verification', () => {
let testEmail: string;
test.beforeEach(async ({}, testInfo) => {
// Each Playwright worker gets a unique inbox — safe for parallel runs
testEmail = generateEmail(testInfo.workerIndex);
await createInbox(testEmail);
});
test.afterEach(async () => {
// Cleanup always runs, even if the test fails
await deleteInbox(testEmail);
});
test('user can sign up and verify via OTP', async ({ page }) => {
// Step 1: Submit signup form
await page.goto('/signup');
await page.fill('[data-testid="email"]', testEmail);
await page.fill('[data-testid="password"]', 'SecurePass@2026!');
await page.click('[data-testid="signup-btn"]');
// Step 2: Expect OTP entry screen
await expect(page.locator('[data-testid="otp-screen"]')).toBeVisible({ timeout: 5000 });
// Step 3: Wait for OTP — no polling loop, no sleep()
const otp = await waitForOtp(testEmail, 30_000);
expect(otp).toMatch(/^\d{4,8}$/);
// Step 4: Enter OTP
await page.fill('[data-testid="otp-input"]', otp);
await page.click('[data-testid="verify-btn"]');
// Step 5: Assert success
await expect(page).toHaveURL(/\/(dashboard|onboarding)/);
await expect(page.locator('[data-testid="user-greeting"]')).toBeVisible();
});
});Complete Example: Magic Link Flow
// tests/magic-link.spec.ts
import { test, expect } from '@playwright/test';
import { generateEmail, createInbox, waitForEmail, deleteInbox } from './helpers/email';
test.describe('Passwordless magic link login', () => {
let testEmail: string;
test.beforeEach(async ({}, testInfo) => {
testEmail = generateEmail(testInfo.workerIndex, 'ditube.info');
await createInbox(testEmail);
});
test.afterEach(async () => {
await deleteInbox(testEmail);
});
test('user logs in via magic link', async ({ page }) => {
// Request magic link
await page.goto('/login');
await page.fill('[name="email"]', testEmail);
await page.click('[data-testid="send-magic-link"]');
await expect(page.locator('[data-testid="link-sent"]')).toBeVisible();
// Wait for email — filter by subject if multiple emails expected
const msg = await waitForEmail(testEmail, {
timeoutMs: 30_000,
match: m => m.subject.toLowerCase().includes('sign in'),
});
// verification_link is pre-extracted — no URL parsing needed
expect(msg.verificationLink).toBeTruthy();
expect(msg.verificationLink).toContain('token=');
// Navigate to magic link
await page.goto(msg.verificationLink!);
// Assert authenticated
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
});
test('magic link is single-use', async ({ page }) => {
await page.goto('/login');
await page.fill('[name="email"]', testEmail);
await page.click('[data-testid="send-magic-link"]');
const msg = await waitForEmail(testEmail);
const link = msg.verificationLink!;
// First use — should authenticate
await page.goto(link);
await expect(page).toHaveURL(/\/dashboard/);
// Second use — should be rejected
await page.goto(link);
await expect(page.locator('[data-testid="link-expired"]')).toBeVisible();
});
});Playwright Fixtures: Reusable Inbox Lifecycle
For larger test suites, define the inbox as a Playwright fixture to keep test files clean:
// tests/fixtures/inbox.ts
import { test as base, expect } from '@playwright/test';
import { generateEmail, createInbox, deleteInbox, emailClient } from '../helpers/email';
type InboxFixtures = {
testEmail: string;
};
export const test = base.extend<InboxFixtures>({
testEmail: async ({}, use, testInfo) => {
const email = generateEmail(testInfo.workerIndex);
await createInbox(email);
await use(email); // test body executes here
await deleteInbox(email); // cleanup runs after, even on failure
},
});
export { expect } from '@playwright/test';Usage — tests stay minimal:
// tests/password-reset.spec.ts
import { test, expect } from './fixtures/inbox';
import { waitForEmail } from './helpers/email';
test('password reset sends email with valid link', async ({ page, testEmail }) => {
await page.goto('/forgot-password');
await page.fill('[name="email"]', testEmail);
await page.click('[type="submit"]');
const msg = await waitForEmail(testEmail, { timeoutMs: 30_000 });
expect(msg.verificationLink).toContain('/reset-password?token=');
await page.goto(msg.verificationLink!);
await page.fill('[name="new-password"]', 'NewPass@2026!');
await page.click('[type="submit"]');
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('[data-testid="reset-success"]')).toBeVisible();
});Full OTP Flow with getOtpForInbox
For tests where Playwright triggers the email action, getOtpForInbox is the most concise pattern — it handles register, trigger, wait, and cleanup atomically:
import { test, expect } from '@playwright/test';
import { FreecustomEmailClient } from 'freecustom-email';
const client = new FreecustomEmailClient({
apiKey: process.env.FCE_API_KEY!,
});
test('complete OTP signup', async ({ page }, testInfo) => {
const email = `qa-w${testInfo.workerIndex}-${Date.now()}@ditapi.info`;
const otp = await client.getOtpForInbox(
email,
async () => {
// This callback runs after inbox is registered
await page.goto('/signup');
await page.fill('[name="email"]', email);
await page.fill('[name="password"]', 'SecurePass@2026!');
await page.click('[type="submit"]');
},
{
timeoutMs: 30_000,
autoUnregister: true, // cleanup handled automatically
},
);
expect(otp).toMatch(/^\d{6}$/);
await page.fill('[data-testid="otp"]', otp);
await page.click('[data-testid="verify"]');
await expect(page).toHaveURL(/\/dashboard/);
});WebSocket for Latency-Sensitive Tests
For tests where OTP expiry is very short, use the WebSocket client for sub-200ms email delivery:
// tests/helpers/email-ws.ts
import { FreecustomEmailClient } from 'freecustom-email';
export function waitForEmailViaWebSocket(
client: FreecustomEmailClient,
email: string,
timeoutMs = 30_000,
): Promise<{ otp: string | null; verificationLink: string | null }> {
return new Promise((resolve, reject) => {
const ws = client.realtime({
mailbox: email,
autoReconnect: false,
});
const timer = setTimeout(() => {
ws.disconnect();
reject(new Error(`No email via WebSocket within ${timeoutMs}ms`));
}, timeoutMs);
ws.on('email', emailEvent => {
clearTimeout(timer);
ws.disconnect();
resolve({
otp: emailEvent.otp,
verificationLink: emailEvent.verificationLink,
});
});
ws.on('error', err => {
clearTimeout(timer);
reject(new Error(err.message));
});
ws.connect();
});
}Python: pytest + Playwright Integration
# tests/conftest.py
import os, time, pytest
from freecustom_email import FreeCustomEmail
@pytest.fixture(scope="session")
def fce_client():
return FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
@pytest.fixture
async def test_email(fce_client, worker_id):
email = f"qa-{worker_id}-{int(time.time())}@ditapi.info"
await fce_client.inboxes.register(email)
yield email
await fce_client.inboxes.unregister(email)# tests/test_signup.py
import pytest
from playwright.async_api import async_playwright
@pytest.mark.asyncio
async def test_otp_signup(fce_client, test_email):
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page()
await page.goto("https://staging.yourapp.com/signup")
await page.fill('[name="email"]', test_email)
await page.fill('[name="password"]', "SecurePass@2026!")
await page.click('[type="submit"]')
# Wait for OTP — server-side long-poll
otp = await fce_client.otp.wait_for(test_email, timeout_ms=30_000)
assert otp.isdigit(), f"Expected numeric OTP, got: {otp}"
await page.fill('[data-testid="otp"]', otp)
await page.click('[data-testid="verify"]')
assert "/dashboard" in page.url
await browser.close()CI/CD: GitHub Actions
# .github/workflows/e2e.yml
name: E2E Tests
on: [push, pull_request]
jobs:
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
BASE_URL: ${{ secrets.STAGING_URL }}
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/Error Handling in Tests
import { TimeoutError, PlanError, RateLimitError } from 'freecustom-email';
async function waitForOtpSafe(email: string): Promise<string> {
try {
return await emailClient.otp.waitFor(email, { timeoutMs: 30_000 });
} catch (err) {
if (err instanceof TimeoutError) {
throw new Error(`OTP not received for ${email} within 30s — check email delivery`);
}
if (err instanceof PlanError) {
throw new Error(`OTP extraction requires Growth plan: ${err.message}`);
}
if (err instanceof RateLimitError) {
// Wait and retry once
await new Promise(r => setTimeout(r, err.retryAfter * 1000));
return emailClient.otp.waitFor(email, { timeoutMs: 30_000 });
}
throw err;
}
}See errors documentation.
FAQ
Q: Does the SDK work with Node.js 18? Yes. Node.js 18+ is required. ESM and CJS builds are included. Also works with Deno and Bun. See JS SDK documentation.
Q: What if two parallel Playwright workers receive mail to the same inbox? Use generateEmail(testInfo.workerIndex) to give each worker a unique email address. FCE inboxes are private — no cross-account contamination.
Q: Do I need to register inboxes before using them? Yes — client.inboxes.register() (or createInbox() in the helper above) must be called before mail arrives. Registration takes one API call.
Q: What is the minimum plan for OTP extraction? Growth ($49/mo). On lower plans, otp returns __DETECTED__. Use messages.waitFor() to get the full message body instead.
Q: Can I match emails by sender or subject in waitFor? Yes — pass a match function: match: m => m.from === 'noreply@yourapp.com'.
Q: How do I avoid OTP expiry in slow tests? Use getOtpForInbox() — it triggers your app and starts waiting simultaneously, minimizing the delay between email send and OTP capture.
Q: Is there a Playwright-specific plugin? Not yet — the helper module in this guide (~30 lines) covers all patterns. See Playwright & Selenium use case.
Q: What are the available platform domains? See platform domains documentation.
Conclusion
Playwright's async-first architecture deserves an email API that matches it. FreeCustom.Email's SDK integrates in under 30 lines of helper code, eliminates OTP parsing boilerplate, handles parallel workers safely, and starts free.
→ Get started free → npm install freecustom-email → See Playwright & Selenium use case → Read the quickstart → Try the API playground
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: Does the SDK work with Node.js 18?+
Yes. Node.js 18+ is required. ESM and CJS builds are included. Also works with Deno and Bun. See JS SDK documentation.
Q: What if two parallel Playwright workers receive mail to the same inbox?+
Use generateEmail(testInfo.workerIndex) to give each worker a unique email address. FCE inboxes are private — no cross-account contamination.
Q: Do I need to register inboxes before using them?+
Yes — client.inboxes.register() (or createInbox() in the helper above) must be called before mail arrives. Registration takes one API call.
Q: What is the minimum plan for OTP extraction?+
Growth ($49/mo). On lower plans, otp returns __DETECTED__. Use messages.waitFor() to get the full message body instead.
Q: How do I avoid OTP expiry in slow tests?+
Use getOtpForInbox() — it triggers your app and starts waiting simultaneously, minimizing the delay between email send and OTP capture.
Q: Is there a Playwright-specific plugin?+
Not yet — the helper module in this guide (~30 lines) covers all patterns. See Playwright & Selenium use case.
Q: What are the available platform domains?+
See platform domains documentation.
No comments yet. Be the first to share your thoughts.