Playwright guide

Test email OTP and signup flows with Playwright

Each test invents its own address on your catch-all subdomain, triggers the email, then polls the MailBots API until the message arrives with its code already extracted. Parallel workers never read each other's mail, and there is nothing to clean up afterwards.

Before you start

  • An active Starter or Pro plan with a provisioned catch-all subdomain. See DNS onboarding.
  • An API key with the messages:read scope, created in Dashboard → Developer. See REST API.
  • Playwright Test (@playwright/test) in a Node.js project.

Pass both values to the test run as environment variables:

MAILBOTS_API_KEY
mb_live_…
MAILBOTS_DOMAIN
otp.example.com
Note

Keep the API key in your CI secret store or a local .env file that is not committed. It never needs to reach the browser under test, because Playwright calls the API from Node.

Add a MailBots helper

The helper creates a unique address and waits for its code. It asks for the 50 newest messages and matches on envelope_to, the address the email was delivered to, so it keeps working while other tests receive mail at the same time.

// tests/mailbots.ts
import type { APIRequestContext } from '@playwright/test';

const API = 'https://api.mailbots.top/v1';

type Message = {
  id: string;
  envelope_to: string;
  subject: string | null;
  otp: string | null;
  received_at_ms: number;
};

/** A new address for every test, so parallel workers never share an inbox. */
export function testAddress(prefix: string): string {
  const unique = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
  return `${prefix}-${unique}@${process.env.MAILBOTS_DOMAIN}`.toLowerCase();
}

/** Polls MailBots until an email for `address` arrives with a detected code. */
export async function waitForOtp(request: APIRequestContext, address: string, timeoutMs = 60_000): Promise<string> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const response = await request.get(`${API}/messages`, {
      params: { limit: 50 },
      headers: { Authorization: `Bearer ${process.env.MAILBOTS_API_KEY}` },
    });
    // A bad key or an inactive plan will not fix itself, so fail at once.
    if (!response.ok()) throw new Error(`MailBots API ${response.status()}: ${await response.text()}`);
    const { data } = (await response.json()) as { data: Message[] };
    const message = data.find((item) => item.envelope_to.toLowerCase() === address && item.otp);
    if (message?.otp) return message.otp;
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  throw new Error(`No email with a code reached ${address} within ${timeoutMs / 1000}s`);
}

Write the test

Replace the URL, labels and button names with the ones in your app. The test timeout is raised because Playwright's default of 30 seconds is shorter than the time the helper is allowed to wait for mail.

// tests/signup.spec.ts
import { test, expect } from '@playwright/test';
import { testAddress, waitForOtp } from './mailbots';

test('a new user verifies their email with a one-time code', async ({ page, request }) => {
  test.setTimeout(90_000);
  const address = testAddress('signup');

  await page.goto('/signup');
  await page.getByLabel('Email').fill(address);
  await page.getByRole('button', { name: 'Continue' }).click();

  const otp = await waitForOtp(request, address);
  await page.getByLabel('Verification code').fill(otp);
  await page.getByRole('button', { name: 'Verify' }).click();

  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

Run it locally:

MAILBOTS_API_KEY=mb_live_REPLACE_ME MAILBOTS_DOMAIN=otp.example.com npx playwright test

Run it in CI

In GitHub Actions, store the key as a repository secret and pass both variables to the test step:

- name: End-to-end tests
  run: npx playwright test
  env:
    MAILBOTS_API_KEY: ${{ secrets.MAILBOTS_API_KEY }}
    MAILBOTS_DOMAIN: otp.example.com

There is no cleanup step. Every run uses new addresses, and messages are deleted automatically after 72 hours.

Troubleshooting

SymptomWhat to check
401 from the APIThe key is revoked, lacks messages:read, or the plan is no longer active
Timeout, nothing in the dashboardThe app did not send, or DNS for the subdomain changed. Re-run the probe in Dashboard → Domains
Timeout, message is in the dashboardMore than 50 messages arrived during the wait. Raise limit to 100
Message found but otp is nullNo convincing code was detected. Read the message in the dashboard and check the email's wording
Note

Assert on your application, not on MailBots. If a test needs the full message, the same API response includes the sender, subject and received time.