I used to have a function in my test suite called extractOtpFromEmail. It was 40 lines long, had six different regex patterns for different email formats, and still broke every time an upstream service changed their email template.
When I finally killed that function, I replaced it with one line:
const otp = await fce.otp.waitFor(inbox, { timeout: 30_000 });
This post is about how to get there — a clean, parallel-safe OTP automation setup for Playwright using the FreeCustom.Email API, which is the only disposable email service that ships a proper OTP extraction endpoint (and the only one with an official CLI).
The Core Problem
Playwright's async model and parallel worker architecture are genuinely excellent. But when your test needs to read an email, you hit a wall:
- You can't share an inbox across workers — parallel tests receive each other's OTPs
- Web-based temp mail sites require a second page context and fragile DOM scraping
- Mocking email delivery means you are not testing the actual pipeline
- Rolling your own IMAP reader is a project in itself
The answer is a fresh isolated inbox per test, with a purpose-built OTP extraction API that does the parsing for you.
Setup
Install the SDK and create a free account at freecustom.email:
npm install freecustom-email
For the CLI (useful for local debugging):
curl -fsSL freecustom.email/install.sh | sh # macOS / Linux
npm install -g fcemail@latest # All platforms
choco install fce # Windows
All releases at github.com/DishIs/fce-cli/releases. Authenticate once:
fce login
# Browser opens → sign in → key saved to keychain automatically
For CI, grab your API key from the dashboard and set FCE_API_KEY as a secret.
The Fixture — The Right Pattern for Playwright
The cleanest way to integrate FCE with Playwright is a custom fixture. Each test gets a fresh inbox and it is cleaned up automatically after:
// tests/fixtures/fce.ts
import { test as base } from '@playwright/test';
import { FreecustomEmailClient } from 'freecustom-email';
type FceFixture = {
inbox: string;
getOtp: (timeoutMs?: number) => Promise<string>;
};
const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });
export const test = base.extend<FceFixture>({
inbox: async ({}, use) => {
const addr = `pw-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@ditmail.info`;
await fce.inboxes.register(addr);
await use(addr);
await fce.inboxes.unregister(addr).catch(() => {});
},
getOtp: async ({ inbox }, use) => {
await use((ms = 30_000) => fce.otp.waitFor(inbox, { timeout: ms }));
},
});
export { expect } from '@playwright/test';
Signup Test
// tests/signup.spec.ts
import { test, expect } from './fixtures/fce';
test('user signs up and verifies email', async ({ page, inbox, getOtp }) => {
await page.goto('/signup');
await page.fill('#email', inbox);
await page.fill('#password', 'TestPass123!');
await page.click('button[type="submit"]');
// Wait for OTP screen
await page.waitForSelector('#otp-input');
// No polling loop. No regex. Just this:
const otp = await getOtp();
expect(otp).toMatch(/^\d+$/);
await page.fill('#otp-input', otp);
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
expect(page.url()).toContain('/dashboard');
});
Password Reset Test
Because getOtp() always returns the latest OTP in the inbox, multi-step flows work naturally:
test('user resets password via email', async ({ page, inbox, getOtp }) => {
// First: create and verify the account
await page.goto('/signup');
await page.fill('#email', inbox);
await page.fill('#password', 'Original1!');
await page.click('button[type="submit"]');
await page.waitForSelector('#otp-input');
await page.fill('#otp-input', await getOtp());
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Then: request a reset
await page.goto('/forgot-password');
await page.fill('#email', inbox);
await page.click('button[type="submit"]');
await page.waitForSelector('#reset-otp');
// getOtp() picks up the new code, not the old one
const resetOtp = await getOtp();
await page.fill('#reset-otp', resetOtp);
await page.fill('#new-password', 'NewPass456!');
await page.click('button[type="submit"]');
await page.waitForURL('**/login');
});
Playwright Config — 4 Parallel Workers
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
workers: 4,
retries: 1,
timeout: 60_000,
use: {
baseURL: process.env.APP_URL,
headless: true,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
});
Four workers, four isolated inboxes, no interference. This is the biggest quality-of-life improvement over any shared inbox approach.
Python Version
If you are using Python Playwright:
pip install playwright pytest-playwright freecustom-email pytest-asyncio
playwright install chromium
# conftest.py
import asyncio, os, time, pytest
from freecustom_email import FreeCustomEmail
fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
@pytest.fixture
def inbox():
addr = f"pw-{int(time.time())}@ditmail.info"
asyncio.run(fce.inboxes.register(addr))
yield addr
asyncio.run(fce.inboxes.unregister(addr))
@pytest.fixture
def get_otp(inbox):
return lambda timeout=30: asyncio.run(fce.otp.wait_for(inbox, timeout=timeout))
# tests/test_signup.py
from playwright.sync_api import Page, expect
import re
def test_signup(page: Page, inbox, get_otp):
page.goto("/signup")
page.fill("#email", inbox)
page.fill("#password", "TestPass123!")
page.click("button[type='submit']")
page.wait_for_selector("#otp-input")
otp = get_otp()
assert otp and otp.isdigit()
page.fill("#otp-input", otp)
page.click("button[type='submit']")
page.wait_for_url("**/dashboard")
expect(page).to_have_url(re.compile(r"/dashboard"))
GitHub Actions
- name: Playwright E2E tests
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
APP_URL: ${{ vars.STAGING_URL }}
run: npx playwright test --workers=4
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
What About Apps That Block Disposable Domains?
Some signup forms validate the email domain and reject known temp mail addresses. If that is an issue, the Growth plan lets you register a custom domain and create inboxes like test-001@automation.yourcompany.com. Register the domain in the dashboard, verify DNS, and you are done.
What the OTP Command Looks Like (CLI debugging)
When I am debugging a failing test locally, I use the CLI directly to inspect what was received:
fce otp pw-1234567890@ditmail.info
────────────────────────────────────────────────
OTP
────────────────────────────────────────────────
OTP · 212342
From · noreply@myapp.com
Subj · Your verification code
Time · 20:19:54
The CLI is separate from the SDK but uses the same underlying API. Useful for quick manual checks without spinning up a test runner.
Links
- 🖥️ CLI docs: freecustom.email/api/cli
- 📦 GitHub: github.com/DishIs/fce-cli
- 🚀 Releases: github.com/DishIs/fce-cli/releases
- 📖 API docs: freecustom.email/api/docs
- 💬 Discord: discord.com/invite/Ztp7kT2QBz
Are you using Playwright for E2E tests with email verification? I am curious what your current setup looks like — specifically whether anyone has a nice pattern for handling magic links in Playwright that they are happy with.
Top comments (0)