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 freenpm install freecustom-emailSee Playwright & Selenium use caseRead the quickstartTry 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.