Use case · password reset

Test password-reset emails end to end

The test creates a user, asks for a reset, reads the reset email, follows the link, sets a new password and signs in with it. It is the one flow every user needs on their worst day, and the easiest to break with a template or routing change.

tests/password-reset.spec.ts
// tests/password-reset.spec.ts
import { test, expect } from "@playwright/test";
import { newAddress, waitForEmail } from "./zyntra";

test("a user resets their password from the email", async ({ page }) => {
  test.setTimeout(120_000);
  const address = newAddress("reset");

  // 1. A user with that address (your seed step or the sign-up form).
  await page.goto("/signup");
  await page.getByLabel("Email").fill(address);
  await page.getByLabel("Password").fill("old-password-123");
  await page.getByRole("button", { name: "Sign up" }).click();
  await waitForEmail(address, (e) => e.subject.includes("Confirm"));

  // 2. Ask for a reset. The address already has mail, so wait
  //    for the email whose subject is the reset one.
  await page.goto("/forgot-password");
  await page.getByLabel("Email").fill(address);
  await page.getByRole("button", { name: "Send reset link" }).click();
  const email = await waitForEmail(address, (e) =>
    e.subject.includes("Reset your password"),
  );

  // 3. Follow the link, set a new password, sign in with it.
  const reset = email.links.find((l) => l.url.includes("/reset"));
  await page.goto(reset!.url);
  await page.getByLabel("New password").fill("new-password-456");
  await page.getByRole("button", { name: "Save password" }).click();

  await page.goto("/login");
  await page.getByLabel("Email").fill(address);
  await page.getByLabel("Password").fill("new-password-456");
  await page.getByRole("button", { name: "Log in" }).click();
  await expect(page.getByText("Dashboard")).toBeVisible();
});
tests/zyntra.ts
// zyntra.ts — read test email from Zyntra
import { randomUUID } from "node:crypto";

const API = "https://api.zyntra.app";
const KEY = process.env.ZYNTRA_API_KEY!;
const TEAM = process.env.ZYNTRA_TEAM_ID!;

export interface Email {
  subject: string;
  body: string; // plain-text part
  html: string | null;
  received_at: string;
  links: { url: string; text: string }[];
}

// A fresh address per test: no mail from earlier runs,
// no clash between parallel workers. Tags use no dots.
export const newAddress = (tag: string) =>
  `${TEAM}.${tag}-${randomUUID().slice(0, 8)}@zyntramail.com`;

// The newest email to `address` that passes `match`.
// The first request waits up to 30 s on the server.
export async function waitForEmail(
  address: string,
  match: (e: Email) => boolean = () => true,
  timeoutMs = 60_000,
): Promise<Email> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(
      `${API}/emails/last?email=${encodeURIComponent(address)}`,
      { headers: { "X-API-Key": KEY } },
    );
    if (res.ok) {
      const email = (await res.json()) as Email;
      if (match(email)) return email;
    } else if (res.status !== 404) {
      throw new Error(`Zyntra API: ${res.status}`);
    }
    await new Promise((r) => setTimeout(r, 2_000));
  }
  throw new Error(`No matching email for ${address}`);
}

The tests import zyntra.ts, the second file above: about 40 lines of plain fetch, so the same calls work from Cypress, Selenium or any runner. Set ZYNTRA_API_KEY and ZYNTRA_TEAM_ID from your dashboard.

What happens to the email

  1. Your app sends it through its usual email provider, to an address on zyntramail.com.
  2. A real mail server receives it and stores it for your team, with its links pulled out of the HTML and text and its attachments kept.
  3. Your test reads it with one API request. Mail to an address you did not create in the dashboard is deleted after 1 day.

Two emails to one address

The user's address gets the sign-up confirmation first and the reset email second. /emails/last always returns the newest email, so the helper takes a match function and asks again every two seconds until the newest email is the reset one. Matching on the subject is enough for most apps.

Worth asserting too

  • The old password no longer works after the reset.
  • The reset link stops working once it has been used.
  • Asking for a reset for an unknown address sends nothing and says nothing different on screen.

Limits to know about

  • /emails/last waits up to 30 seconds for the first email to an address. Give the test more time than your runner's default.
  • Every API call counts toward your plan's API requests.
  • Mail to these addresses is kept 1 day. Create a permanent inbox in the dashboard to keep it longer.
  • Each check of the newest email while waiting for the second one is one API request.

Questions

Can I use a user that already exists?
Yes, if its address is on zyntramail.com. A permanent inbox created in the dashboard keeps mail for your plan's retention, so a seeded user can keep one address. Use match so the test never reads an older reset email.
How do I test that the link expires?
Use the link once, then open it again and expect your app's expired-link message. For time-based expiry, shorten the lifetime in the test environment rather than waiting.

Test the reset flow before your users do

Free plan, no setup: send to any address and read it back.

Start free