MailSlurp logo

blog

Playwright Email Testing: Verification, OTP and Password Resets

Test email verification, OTP codes and notifications with Playwright. Match the current message and run a complete parallel password-reset example.

A Playwright email test needs to continue after the application says "Email sent". Receive the message, use its verification link or OTP, and check the account the browser reaches. For a notification, check that the right recipient receives the right information.

The inbox becomes especially important when tests run in parallel. A shared address can receive several similar emails, and a successful send tells the browser neither which message arrived nor which request produced it.

MailSlurp gives each test an API-controlled inbox. Install @playwright/test for browser automation and mailslurp-client for inbox creation, message waits and cleanup. The short Playwright inbox example covers the initial setup; the examples here extend it to verification, OTPs, notifications and parallel resets.

Receive the verification email and finish signup

Create an inbox, enter its address in your application's signup form and record the time before submitting. Wait for the expected subject in that inbox, then extract the intended action link and continue in the same browser context.

The sample below uses a placeholder application. Replace its origin, form selectors, subject rule, a.verify-email selector and /verify path with your application's values. The parallel reset download includes an application you can run locally.

import { expect, test } from "@playwright/test";
import {
  MailSlurp,
  MatchOptionFieldEnum,
  MatchOptionShouldEnum,
} from "mailslurp-client";

test("verifies signup email", async ({ page }) => {
  test.setTimeout(90_000);
  const apiKey = process.env.MAILSLURP_API_KEY;
  if (!apiKey) throw new Error("Set MAILSLURP_API_KEY");

  const mailslurp = new MailSlurp({ apiKey });
  const inbox = await mailslurp.createInboxWithOptions({ expiresIn: 600_000 });
  const appOrigin = "https://app.example.test";

  try {
    await page.goto(`${appOrigin}/signup`);
    await page.getByLabel("Email").fill(inbox.emailAddress);
    const since = new Date();
    await page.getByRole("button", { name: "Create account" }).click();

    const email = await mailslurp.waitController.waitForMatchingFirstEmail({
      inboxId: inbox.id,
      since,
      timeout: 60_000,
      unreadOnly: false,
      matchOptions: {
        matches: [{
          field: MatchOptionFieldEnum.SUBJECT,
          should: MatchOptionShouldEnum.CONTAIN,
          value: "Verify",
        }],
      },
    });
    const { links } = await mailslurp.emailController.getEmailLinks({
      emailId: email.id,
      selector: "a.verify-email",
    });

    expect(links).toHaveLength(1);
    const verificationUrl = new URL(links[0]);
    expect(verificationUrl.origin).toBe(appOrigin);
    expect(verificationUrl.pathname).toBe("/verify");
    await page.goto(verificationUrl.href);
    await expect(page.getByText("Account verified")).toBeVisible();
  } finally {
    await mailslurp.inboxController.deleteInbox({ inboxId: inbox.id });
  }
});

The link selector avoids choosing a logo or help link from the template. Checking the origin and path before navigation also catches links pointing at the wrong environment. Replace the final assertion with an account-specific check where your application exposes one, such as the verified user's email address.

since restricts the wait to the current request. unreadOnly: false keeps the test independent of whether someone opened the email in the dashboard. A dedicated inbox plus a matching rule is more useful than relying on unread status to distinguish new mail.

Submit an email OTP in Playwright

For a code-based flow, keep the same inbox setup and current-request wait. Extract the code from the received email ID, then enter it through the browser. The application, rather than the extraction API, decides whether that code is valid.

This helper uses MailSlurp's code endpoint for a six-digit template. Call it after receiving the current OTP email. Adapt the input label, button and account selector to your application; change the length rule if your codes use another format.

import { expect, type Page } from "@playwright/test";
import { MailSlurp, ExtractCodesOptionsMethodEnum } from "mailslurp-client";

export async function submitEmailOtp(
  page: Page, mailslurp: MailSlurp, emailId: string, expectedAccount: string,
) {
  const result = await mailslurp.emailController.getEmailCodes({
    emailId,
    extractCodesOptions: {
      method: ExtractCodesOptionsMethodEnum.PATTERN,
      allowFallback: false,
      minLength: 6, maxLength: 6, maxCandidates: 5,
    },
  });
  if (!result.found || typeof result.code !== "string" ||
      !/^[0-9]{6}$/.test(result.code)) {
    throw new Error("No usable six-digit OTP");
  }
  const candidates = new Set(result.candidates.map(candidate => candidate.code));
  if (candidates.size !== 1 || !candidates.has(result.code)) {
    throw new Error("Ambiguous OTP candidates");
  }

  await page.getByLabel("Verification code").fill(result.code);
  await page.getByRole("button", { name: "Verify code", exact: true }).click();
  await expect(page.getByTestId("signed-in-email")).toHaveText(expectedAccount);
}

Keeping the code as text preserves leading zeros. Rejecting ambiguity stops a test from guessing between a login code and another number in the email. The verification code API guide explains the extraction options and includes a plain Node.js helper.

Use separate tests for incorrect, expired and already-used codes. A resend test must follow your application's policy for the earlier code. The OTP testing guide provides those cases; for SMS, use the Playwright phone verification walkthrough.

Check notification emails without a verification step

Receipts, alerts and export-ready messages may not change the user's authentication state. Trigger the event in Playwright, then assert the recipient and the content that identifies that event. For example, an export notification should contain the reference created in this test, rather than just the words "Export ready".

After your matching wait returns an email, a focused assertion helper can check those values:

import { expect } from "@playwright/test";
import type { Email } from "mailslurp-client";

export function expectExportNotification(
  email: Email, recipient: string, exportReference: string,
) {
  expect(exportReference).not.toBe("");
  expect(email.to).toContain(recipient);
  expect(email.subject).toContain("Export ready");
  expect(email.body).toContain(exportReference);
}

Get exportReference from the application action or its supported test setup. If the message promises a download or attachment, check that too. Match the current request before calling the helper, and keep actual customer data out of the test.

Keep inbox state isolated in CI

Use a fresh inbox for each independent test, including retries. A test-scoped Playwright fixture can create it before the test and delete it afterward. When tests intentionally share a worker-level account, give their messages a request-specific match and control the account state between tests.

Bound the message wait inside the test's total timeout. Do not retry authentication or quota errors as if they were slow delivery. On failure, retain the test name, inbox ID and email ID with restricted artifacts; avoid logging whole bodies, OTPs or reset URLs.

For a magic link or reset link, validate the destination and final session in the same browser journey. The runnable example below also checks that a previous reset request cannot satisfy the current wait.

Run two password resets in parallel

The runnable password reset example includes a small local application, a test-scoped MailSlurp fixture, two parallel browser tests and pinned dependencies. Each test gets a separate account, recipient inbox, sender inbox and application port.

With Node.js 22 or newer, unzip the download and run:

cd playwright-parallel-reset
npm ci
npx playwright install chromium
export MAILSLURP_API_KEY='your-test-account-api-key'
npm run typecheck
npm test

The application sends real emails between four newly created MailSlurp inboxes, using your account's normal allowance. Each worker first receives an earlier reset email, then checks that it cannot satisfy the next request's inbox, timestamp and exact subject rule. A 408 timeout is expected for that negative check; authentication or quota errors fail the test.

After the current email arrives, the test validates the reset URL's origin and path, rejects the old link, changes the password, rejects the old password and signs in as the expected account. It also rejects reuse of the consumed reset link. The resend and replay assertions reflect the included application's policy; adapt them to your own application.

The fixture deletes all four inboxes during teardown and sets a ten-minute expiry for interrupted runs. Its application sends through MailSlurp; replace that sending path with your application's provider when that is the integration you need to test.

The README explains how to use installed Chrome, inspect the worker/result attachments and adapt the forms, matching rules and reset policy. Keep test artifacts private because failure output may contain temporary message details. See Playwright's parallelism and fixture documentation for how per-test resources fit the runner.

A reset-email assertion should continue past finding a link: submit a new password and sign in as the account that requested it. Validate the URL before navigating, then reject the old password and a second use of the consumed link. This catches a reset form that displays success without updating the intended account.

This focused test uses tests/fixtures.ts from the download above, which supplies the local app, isolated inboxes and initial account. Save it as tests/reset-focus.spec.ts in that project and run npx playwright test tests/reset-focus.spec.ts. Its routes, subject and selectors belong to the included example application; adapt them when testing your own app.

import { randomUUID } from "node:crypto";
import { MatchOptionFieldEnum, MatchOptionShouldEnum } from "mailslurp-client";
import { test, expect } from "./fixtures.js";

test("the delivered reset changes the intended account", async ({ page, reset }) => {
  const { app, mailslurp, recipient, sender, initialPassword } = reset;
  await page.goto(`${app.origin}/request-reset`);
  const requestId = (await page.getByTestId("request-id").innerText()).trim();
  await page.getByLabel("Email", { exact: true }).fill(recipient.emailAddress);
  const since = new Date();
  await page.getByRole("button", { name: "Send reset email", exact: true }).click();
  const email = await mailslurp.waitController.waitForMatchingFirstEmail({
    inboxId: recipient.id, since, timeout: 60_000, unreadOnly: false,
    matchOptions: { matches: [{
      field: MatchOptionFieldEnum.SUBJECT,
      should: MatchOptionShouldEnum.EQUAL,
      value: `Password reset [${requestId}]`,
    }] },
  });
  expect(email.to).toContain(recipient.emailAddress);
  expect(email.from).toBe(sender.emailAddress);
  const { links } = await mailslurp.emailController.getEmailLinks({
    emailId: email.id, selector: "a.reset-link",
  });
  expect(links).toHaveLength(1);
  const resetUrl = new URL(links[0]);
  expect(resetUrl.origin).toBe(app.origin);
  expect(resetUrl.pathname).toBe("/reset");
  expect(resetUrl.searchParams.get("token")).toMatch(/^[a-f0-9]{48}$/);

  await page.goto(resetUrl.href);
  const newPassword = `After-${randomUUID()}`;
  await page.getByLabel("New password", { exact: true }).fill(newPassword);
  await page.getByRole("button", { name: "Save password", exact: true }).click();
  await expect(page.getByRole("status")).toHaveText("Password updated");
  await page.getByLabel("Email", { exact: true }).fill(recipient.emailAddress);
  await page.getByLabel("Password", { exact: true }).fill(initialPassword);
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
  await expect(page.getByRole("status")).toHaveText("Incorrect credentials");
  await page.getByLabel("Email", { exact: true }).fill(recipient.emailAddress);
  await page.getByLabel("Password", { exact: true }).fill(newPassword);
  await page.getByRole("button", { name: "Sign in", exact: true }).click();
  await expect(page.getByTestId("signed-in-email")).toHaveText(recipient.emailAddress);
  const replay = await page.request.get(resetUrl.href);
  expect(replay.status()).toBe(400);
});

Use the link once to complete the browser flow before checking replay rejection. A preliminary HTTP request from a generic link checker can consume a one-time link in some applications. For those applications, validate its origin, path and parameters without fetching it, then let the intended browser action perform the first request.

The full two-worker test additionally requests a replacement email, proves that the previous message cannot satisfy the current wait, and checks the example application's old-link rejection policy. Use it when testing isolation and resend behavior, rather than weakening the matching rule to make a delayed message pass.

Adapt the example to your application's policy

A successful reset should prove that the new password works for the intended account. Add rejection checks for an incorrect token, an expired token and a consumed link. Keep those cases separate when using the same token would affect another assertion.

The included application's resend policy rejects the previous link. Your application's policy may differ, so preserve the rule you intend to offer users and test it explicitly. For email verification, check activation rather than assuming that opening the URL creates a session.

If your suite uses Cypress as well, the Cypress email guide applies the same recipient isolation and message-matching approach in that runner. The MailSlurp Playwright documentation covers SDK setup for your own project.