Email Testing API for Playwright in 2026: Complete Guide with SDK Code Examples

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

await-compatible wait

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-email

2. Set your API key

# .env (used by Playwright via dotenv)
FCE_API_KEY=fce_your_key_here

Get 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();
  });
});

// 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();
  });
}

See WebSocket documentation.


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()

See Python SDK documentation.


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/

See CI/CD pipelines use case.


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

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: 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 &amp; Selenium use case.

Q: What are the available platform domains?+

See platform domains documentation.

Discussion0

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