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
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();
});// 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
- Your app sends it through its usual email provider, to an address on zyntramail.com.
- 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.
- 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/lastwaits 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. Usematchso 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