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
// 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();
});
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.

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/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.

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 match to the helper that only the new email passes, such as a check on received_at or 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