DEV Community

Cover image for Test Email OTP Flows in Playwright Without a Shared Gmail Inbox
Eugene for PNTR

Posted on • Originally published at pntr.dev

Test Email OTP Flows in Playwright Without a Shared Gmail Inbox

A shared Gmail account is an attractive shortcut for end-to-end tests. It also creates a surprisingly bad test primitive:

  • parallel runs read each other's messages;
  • “use the latest email” occasionally selects an older retry;
  • mailbox state survives the test that created it;
  • UI automation around a consumer inbox adds another fragile browser flow.

A cleaner boundary is a catch-all inbox with an API. Each test creates a unique recipient, the application sends its normal email, and Playwright polls for that exact recipient.

Disclosure: I built PNTR, the catch-all inbox used in this example. The test deliberately uses PNTR only for test-message retrieval; it does not replace your email provider.

The flow

Playwright creates a unique recipient, polls the PNTR inbox, reads the matching email, and submits its OTP

The important detail is not the polling. It is the isolation key:

signup-<parallel-worker>-<timestamp>-<random>@testbox.pntr.dev
Enter fullscreen mode Exit fullscreen mode

Every local part reaches the same catch-all inbox, but no two tests need to ask for “the newest message.”

1. Prepare the test inbox

Create a PNTR subdomain such as testbox.pntr.dev, enable email, and generate an API token in dashboard settings. Keep the credentials in your test environment:

PNTR_API_TOKEN=replace_me
PNTR_SUBDOMAIN_ID=replace_me
PNTR_EMAIL_DOMAIN=testbox.pntr.dev
Enter fullscreen mode Exit fullscreen mode

Do not commit this file. The API token can read captured messages, so treat it like any other test secret.

PNTR stores test emails for 48 hours on the free plan and 90 days on premium. Use a dedicated testing subdomain and synthetic user data—not real customer mail.

2. Poll the API from the Playwright test

Playwright's built-in request fixture is an APIRequestContext, so the browser flow and inbox lookup can stay in one test without opening a second web UI.

This example:

  • creates a unique recipient for each parallel worker;
  • records a lower time boundary before submitting the form;
  • matches the exact recipient;
  • fetches the full message only after finding its summary;
  • extracts the OTP from a label specific to the application's template.
import { randomUUID } from "node:crypto";
import {
  expect,
  test,
  type APIRequestContext,
  type APIResponse,
} from "@playwright/test";

type EmailSummary = {
  id: string;
  recipient: string;
  sender: string;
  subject: string | null;
  received_at: string;
};

type EmailDetail = EmailSummary & {
  body_text: string | null;
  body_html: string | null;
};

const apiBase = "https://api.pntr.dev/api";

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return value;
}

async function jsonOrThrow<T>(response: APIResponse): Promise<T> {
  if (!response.ok()) {
    throw new Error(
      `PNTR API returned ${response.status()}: ${await response.text()}`,
    );
  }
  return (await response.json()) as T;
}

async function waitForEmail(
  request: APIRequestContext,
  recipient: string,
  notBefore: number,
): Promise<EmailDetail> {
  const token = requiredEnv("PNTR_API_TOKEN");
  const subdomainId = requiredEnv("PNTR_SUBDOMAIN_ID");
  const headers = { Authorization: `Bearer ${token}` };
  let emailId = "";

  await expect
    .poll(
      async () => {
        const response = await request.get(
          `${apiBase}/subdomains/${subdomainId}/emails?limit=100`,
          { headers },
        );
        const emails = await jsonOrThrow<EmailSummary[]>(response);
        const match = emails.find(
          (email) =>
            email.recipient.toLowerCase() === recipient.toLowerCase() &&
            Date.parse(email.received_at) >= notBefore,
        );

        emailId = match?.id ?? "";
        return emailId;
      },
      {
        message: `wait for email sent to ${recipient}`,
        timeout: 30_000,
        intervals: [500, 1_000, 2_000],
      },
    )
    .not.toBe("");

  const response = await request.get(
    `${apiBase}/subdomains/${subdomainId}/emails/${emailId}`,
    { headers },
  );
  return jsonOrThrow<EmailDetail>(response);
}

test("a new account can verify its email", async ({
  page,
  request,
}, testInfo) => {
  const emailDomain = requiredEnv("PNTR_EMAIL_DOMAIN");
  const recipient = [
    "signup",
    testInfo.parallelIndex,
    Date.now(),
    randomUUID().slice(0, 8),
  ].join("-") + `@${emailDomain}`;

  // PNTR timestamps have second precision. Flooring avoids rejecting a
  // message that arrives later within this same second.
  const notBefore = Math.floor(Date.now() / 1_000) * 1_000;

  await page.goto("/sign-up");
  await page.getByLabel("Email").fill(recipient);
  await page.getByRole("button", { name: "Create account" }).click();

  const email = await waitForEmail(request, recipient, notBefore);
  expect(email.subject ?? "").toContain("verification");

  const body = email.body_text ?? email.body_html ?? "";
  const otpMatch = body.match(/Verification code:\s*(\d{6})/i);
  expect(otpMatch, "verification email should contain a labeled OTP").not.toBeNull();

  await page.getByLabel("Verification code").fill(otpMatch![1]);
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page.getByText("Email verified")).toBeVisible();
});
Enter fullscreen mode Exit fullscreen mode

Adjust the visible labels and success assertion to your application. Keep the recipient matching and time boundary.

The complete Playwright example separates offline helper tests from the optional live browser flow. Its default test suite uses a local fake PNTR API, so CI needs no browser, PNTR token, or external service.

Why not match any six-digit number?

Email templates often contain other numbers: support references, dates, invoice totals, or CSS values in the HTML part. A broad \d{6} match can pass with the wrong value.

Prefer one of these, in order:

  1. a stable label in the plain-text body, such as Verification code: 123456;
  2. a purpose-specific link with a known hostname and path;
  3. a small HTML parser targeting a stable semantic element.

If you control the template, a plain-text alternative is useful for accessibility, deliverability, and tests.

Failure modes worth making explicit

Symptom Check
API returns 401 Token value and Bearer header
No matching message Application environment, recipient, MX/email toggle, provider logs
Wrong test consumes the message Exact recipient uniqueness
Old message matches received_at boundary
Intermittent timeout Provider latency, retry interval, application queue
OTP is found but rejected Template extraction, OTP expiry, application state

Avoid an unbounded while loop. expect.poll gives the wait a deadline and reports the assertion in Playwright's normal failure output.

Keep the test honest

This test verifies a useful path: the application asked its provider to send an email, the message reached the test inbox, and the browser submitted the code from that message.

It does not measure consumer-inbox placement, spam filtering, or production deliverability. Those need different monitoring. Keep this inbox focused on development, CI, and staging flows.

Create a catch-all test inbox in PNTR, or read the shorter Playwright email guide.

References

Top comments (0)