Use case · email verification

Test email verification in your sign-up flow

The test signs up with a fresh address, waits for the confirmation email, opens the link from it and checks the account is confirmed. Without a test inbox this step is usually skipped, and a broken template or a wrong link in production is found by a user who cannot sign up.

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

test("sign-up sends a working confirmation link", async ({ page }) => {
  test.setTimeout(90_000);
  const address = newAddress("verify");

  await page.goto("/signup");
  await page.getByLabel("Email").fill(address);
  await page.getByLabel("Password").fill("a-long-test-password");
  await page.getByRole("button", { name: "Sign up" }).click();

  const email = await waitForEmail(address);
  expect(email.subject).toContain("Confirm your email");

  // Links are already pulled out of the HTML and text.
  const confirm = email.links.find((l) => l.url.includes("/verify"));
  expect(confirm, "confirmation link").toBeDefined();

  await page.goto(confirm!.url);
  await expect(page.getByText("Your email is confirmed")).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.

What the test proves

  • Sign-up sends an email at all, through your real provider.
  • It goes to the address the user typed, with the subject you expect.
  • The link in it points to the right environment and confirms the account.

Finding the link

Every email comes with links: each link found in the HTML and the plain text, as { url, text }. Pick the confirmation link by its path or its button text; no HTML parsing in the test. If your app sends a code instead of a link, see testing codes sent by email.

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

Do I have to create the inbox before the test signs up?
No. Any address with your team ID receives mail; the test makes up a new one each run.
My app's sign-up blocks unknown email domains. Will this work?
Addresses end in @zyntramail.com. If staging only accepts certain domains, add this one to the list for your test environment.
Can I check the email's HTML as well?
Yes. The response has html and the plain-text body, so the test can assert on wording, the user's name or anything else in the message.

Cover email verification in your next test run

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

Start free