Testing email-change and double opt-in flows

Updated October 17, 2026 · Zyntra

Give the test two inboxes for an email change and expect two emails in a double opt-in. Then assert the order of events: the address changes only after the right link is opened, and the newsletter arrives only after the subscription is confirmed. The one-email helpers most suites have pass these flows while the bugs sit in the second email.

Email change: what to assert

  • The new address gets a confirmation link, and the account still shows the old address until it is opened.
  • The old address gets what your design says it should: a confirmation link (Supabase's "secure email change", on by default, sends one to both) or a notice with a way to undo it (Firebase's recoverEmail action).
  • After the change, signing in with the new address works and with the old one doesn't.
  • Opening the same confirmation link a second time doesn't change anything again.

One test, two addresses

Both addresses come from the same run tag, so a failed run leaves two inboxes that are easy to find. This version follows Supabase's default, where both links have to be opened.

email-change.spec.ts
import { test, expect } from "@playwright/test";

const API = "https://api.zyntra.app";
const headers = { "X-API-Key": process.env.ZYNTRA_API_KEY! };

type Email = { subject: string; links: { url: string }[] };

const changeLinks = (email: Email) =>
  email.links.filter((l) => new URL(l.url).searchParams.get("type") === "email_change");

// Asks for the newest email every 2 s until one passes the check.
async function waitFor(address: string, ok: (e: Email) => boolean, timeoutMs = 60_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`${API}/emails/last?email=${encodeURIComponent(address)}`, {
      headers,
      signal: AbortSignal.timeout(45_000),
    });
    if (res.ok) {
      const email: Email = await res.json();
      if (ok(email)) return email;
    } else if (res.status !== 404) {
      throw new Error(`API ${res.status}`);
    }
    await new Promise((r) => setTimeout(r, 2_000));
  }
  throw new Error(`No matching email for ${address}`);
}

const confirmLink = (email: Email) => {
  const found = changeLinks(email);
  expect(found).toHaveLength(1);
  return found[0]!.url;
};

test("changing the email needs both confirmations", async ({ page }) => {
  test.setTimeout(120_000);
  const run = `${process.env.ZYNTRA_TEAM_ID}.change-${Date.now()}`;
  const oldAddress = `${run}[email protected]`;
  const newAddress = `${run}[email protected]`;
  await signUpAndSignIn(page, oldAddress); // your own helper

  await page.goto("/settings");
  await page.getByLabel("Email").fill(newAddress);
  await page.getByRole("button", { name: "Change email" }).click();

  // The old address already has the sign-up email: wait for the one with a change link.
  const isChange = (e: Email) => changeLinks(e).length > 0;
  const [toOld, toNew] = await Promise.all([
    waitFor(oldAddress, isChange),
    waitFor(newAddress, isChange),
  ]);

  await page.goto(confirmLink(toNew));
  await page.goto("/settings");
  await expect(page.getByLabel("Email")).toHaveValue(oldAddress); // not yet

  await page.goto(confirmLink(toOld));
  await page.goto("/settings");
  await expect(page.getByLabel("Email")).toHaveValue(newAddress);
});

The old address already holds the sign-up email, so the test can't take its newest email blindly: it polls until an email with a change link arrives, as in the polling post. Supabase Auth builds both confirmation links with type=email_change; with another provider, match its link instead.

Firebase: verifyBeforeUpdateEmail

In Firebase projects with email enumeration protection (the default for projects created on or after September 15, 2023), updateEmail no longer works. verifyBeforeUpdateEmail sends a link to the new address, and the email changes when it is opened. With protection on, the verification email is sent only if the new address is not already registered, so a test that changes to a taken address should expect no email rather than an error. The Firebase post has the emulator side.

Double opt-in: two emails to one address

A subscription with confirmation produces, in order:

  1. A confirmation email with one link, and nothing else until it is opened.
  2. After the click, a welcome email or the first issue.

The useful assertions are about order. Right after subscribing, the newest email at the address must be the confirmation, not a welcome email sent too early. After the click, poll for the welcome email by subject. A second click on the confirmation link should not send a second welcome email; check that the newest email is still the same one a little later.

Questions

Why not reuse one fixed pair of addresses?
Once an address has been changed to, it belongs to an account, and the next run fails at sign-up or picks up the previous run's mail. New addresses per run avoid both. The wrong-email post lists the other ways reused inboxes mislead tests.