Use case · magic link
Test magic-link login
With passwordless sign-in, the email is the login. The test asks for a link, reads the email, opens the link in the same browser and checks the user is signed in — then checks the link does not work twice.
// tests/magic-link.spec.ts
import { test, expect } from "@playwright/test";
import { newAddress, waitForEmail } from "./zyntra";
test("a user signs in with a magic link", async ({ page }) => {
test.setTimeout(90_000);
const address = newAddress("magic");
await page.goto("/login");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Email me a sign-in link" }).click();
await expect(page.getByText("Check your inbox")).toBeVisible();
const email = await waitForEmail(address, (e) =>
e.subject.includes("Sign in"),
);
const signIn = email.links.find((l) => l.url.includes("/auth/callback"));
// Same browser context: some apps tie the link to the session
// that asked for it.
await page.goto(signIn!.url);
await expect(page.getByText("Signed in as " + address)).toBeVisible();
// Links are single-use: the second visit must fail.
await page.context().clearCookies();
await page.goto(signIn!.url);
await expect(page.getByText("This link has expired")).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.
Open the link where it was requested
Many apps tie a magic link to the browser session that asked for it. Open it with the same Playwright page, not a new context, unless the test is checking exactly that a link opened elsewhere is refused.
Picking the right link
Sign-in emails often carry several links: the sign-in button, your home page, an unsubscribe link. Choose from links by path (/auth/callback, /magic) or by the button text in text.
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.
Questions
- Does reading the email through the API use up the link?
- No. Zyntra stores the email and returns its content; it never opens the links in it. The link is used only when your test visits it.
- The same user signs in many times. Do I need a new address each time?
- No. Keep the address and pass a
matchto the helper that only the new email passes, such as a check onreceived_ator a value from this test run.
Test passwordless sign-in on every run
Free plan, no setup: send to any address and read it back.
Start free