# Test email OTP with Playwright

Sign up in a real browser, wait for the emailed one-time code and run it in GitHub Actions.

<a id="guide-content"></a>



This recipe signs up through a real browser, waits for the emailed one-time code with a single blocking call, types it in and checks the result. There is no polling loop, no fixed sleep and no shared test mailbox.

<a id="before-you-start"></a>

## Before you start

-   Node.js 24+ and `@playwright/test`.
-   The `@inboxspec/sdk` archive from a [reviewed client release](https://www.inboxspec.com/docs/markdown/installation.md). npm registry publication is pending, so install the downloaded archive.
-   A project key with inbox and message access, and your API address: `https://api.inboxspec.com`.
-   An application that sends its verification email through a provider that delivers over normal MX. [Capture SMTP](https://www.inboxspec.com/docs/markdown/capture-smtp.md) files mail under separate capture inboxes, so it is not the route for this recipe.

```bash
npm install --save-dev @playwright/test
npm install --no-save ./inboxspec-sdk-0.1.0.tgz
npx playwright install chromium
export INBOXSPEC_API_URL=https://api.inboxspec.com
export INBOXSPEC_API_KEY=...   # from your secret store
```

<a id="write-the-test"></a>

## Write the test

An `inbox` fixture registers one address per run, attempt, worker and retry with `inboxes.forRun`, then deletes it after the test. Waits do not consume messages, so a retry must not reuse the previous attempt's address. Run IDs use lowercase letters, digits, hyphens, underscores and dots.

```typescript
import { InboxSpec, type InboxView } from "@inboxspec/sdk";
import { test as base, expect, type TestInfo } from "@playwright/test";

const inboxspec = new InboxSpec({
  apiKey: process.env.INBOXSPEC_API_KEY!,
  baseUrl: process.env.INBOXSPEC_API_URL!,
});

function runId(testInfo: TestInfo): string {
  const run = process.env.GITHUB_RUN_ID
    ? `ci.${process.env.GITHUB_RUN_ID}.a${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`
    : `local.${Date.now().toString(36)}`;
  return `${run}.w${testInfo.workerIndex}.r${testInfo.retry}`;
}

const test = base.extend<{ inbox: InboxView }>({
  inbox: async ({}, use, testInfo) => {
    const inbox = await inboxspec.inboxes.forRun({
      run_id: runId(testInfo),
      retention_seconds: 3600,
    });
    await use(inbox);
    await inboxspec.inboxes.delete(inbox.id);
  },
});

test("signs up with the emailed one-time code", async ({ page, inbox }) => {
  await page.goto("/");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Send code" }).click();

  const result = await inboxspec.messages.wait({
    inbox: inbox.id,
    match: { has_code: true },
    timeout_ms: 60_000,
  });
  if (result.status === "timeout") {
    throw new Error(
      `No code arrived (${result.non_matching_arrivals} other messages). ${result.hint}`,
    );
  }
  const code = result.message.codes[0];
  expect(code).toMatch(/^\d{6}$/);

  await page.getByLabel("Verification code").fill(code!);
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page.getByRole("heading", { name: "Welcome" })).toBeVisible();
});
```

Replace the labels, button names and the six-digit pattern with your application's own. `codes` is ranked best first with separators removed, ready to type into a code field. If your email can contain more than one code, add `subject_contains` or `from_contains` to the match, or see [OTP and link assertions](https://www.inboxspec.com/docs/markdown/otp-tests.md) for correlation headers.

Set the Playwright test timeout longer than the wait, so a slow delivery reports the wait's own hint rather than a generic test timeout:

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  timeout: 120_000,
  retries: process.env.CI ? 1 : 0,
  use: { baseURL: "http://127.0.0.1:4321" },
  webServer: { command: "npm run start", url: "http://127.0.0.1:4321" },
});
```

<a id="run-in-github-actions"></a>

## Run in GitHub Actions

Store the project key as a secret and the API address as a variable. Vendor the reviewed SDK archive and its `SHA256SUMS` in your repository and check them before installing. Your application under test sends through its normal email provider. Many CI hosts block outbound port 25, so use the provider's authenticated submission port rather than connecting to MX yourself.

```yaml
name: Email OTP
on: [pull_request, workflow_dispatch]
permissions:
  contents: read
jobs:
  playwright:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      INBOXSPEC_API_URL: ${{ vars.INBOXSPEC_API_URL }}
      INBOXSPEC_API_KEY: ${{ secrets.INBOXSPEC_API_KEY }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - name: Check vendored SDK integrity
        working-directory: .tools/inboxspec
        run: sha256sum -c --ignore-missing SHA256SUMS
      - run: npm ci
      - run: npm install --no-save .tools/inboxspec/inboxspec-sdk-0.1.0.tgz
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
```

Each run, attempt, worker and retry gets its own address, so parallel jobs never read each other's codes. If a job is cancelled before the fixture's teardown, the inbox's messages expire with its retention; see [cleaning up](https://www.inboxspec.com/docs/markdown/cleanup.md) to remove a run explicitly.

<a id="if-the-code-never-arrives"></a>

## If the code never arrives

A timeout is a result, not an error: the inbox existed and nothing matched in time. Read `non_matching_arrivals` and `hint`. Zero arrivals usually means the application sent to a different address or its provider has not delivered yet. Arrivals that did not match usually means the email had no recognisable code. Authentication, quota and concurrency problems are raised as errors instead; see [waiting for email](https://www.inboxspec.com/docs/markdown/waits.md). Do not wrap the wait in a retry loop. Raise `timeout_ms` within your test deadline instead.
