The emulator keeps the links instead of sending them
When your app calls sendEmailVerification, sendPasswordResetEmail or sendSignInLinkToEmail against the Auth emulator, the emulator prints the link to its terminal and stores it. A test fetches the stored links from GET /emulator/v1/projects/{projectId}/oobCodes; each entry has email, oobCode, oobLink and requestType (VERIFY_EMAIL, PASSWORD_RESET, EMAIL_SIGNIN or VERIFY_AND_CHANGE_EMAIL).
import { test, expect } from "@playwright/test";
const EMULATOR = "http://127.0.0.1:9099";
const PROJECT_ID = process.env.FIREBASE_PROJECT_ID!;
type OobCode = { email: string; oobLink: string; requestType: string };
async function oobLink(email: string, requestType: string) {
const res = await fetch(`${EMULATOR}/emulator/v1/projects/${PROJECT_ID}/oobCodes`);
const { oobCodes } = (await res.json()) as { oobCodes: OobCode[] };
const mine = oobCodes.filter((c) => c.email === email && c.requestType === requestType);
if (mine.length === 0) throw new Error(`No ${requestType} link for ${email}`);
return mine[mine.length - 1]!.oobLink;
}
test("sign-up sends a verification link", async ({ page }) => {
const email = `user-${Date.now()}@example.com`;
await page.goto("/signup");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill("a-long-test-password");
await page.getByRole("button", { name: "Sign up" }).click();
await expect(page.getByText("Check your inbox")).toBeVisible();
// Opening the emulator's link applies the code, as clicking it would.
const res = await fetch(await oobLink(email, "VERIFY_EMAIL"));
expect(res.ok).toBe(true);
});Addresses here can be anything, because nothing is delivered. Clear the emulator between runs with DELETE /emulator/v1/projects/{projectId}/accounts, or give every test its own address.
What the emulator cannot tell you
- The template. The subject, the wording and the sender you set in the console are never used.
- The action URL. Emulator links point at the emulator. In a real project they point at the default handler on your Firebase Hosting domain, or at the custom action URL you configured, and a wrong custom URL only shows up there.
- Delivery. Whether the email arrives at all, and how long it takes.
Against a real project: pick the link by mode
Firebase adds mode, oobCode, apiKey and optionally continueUrl and lang to every action link. Choose the link whose mode matches the flow (verifyEmail, resetPassword, recoverEmail) instead of trusting its position in the email.
type Email = {
subject: string;
body: string;
html: string | null;
links: { url: string; text: string }[];
};
// Waits up to 30 s on the server for the first email to the address.
export async function lastEmail(address: string): Promise<Email> {
const res = await fetch(
`https://api.zyntra.app/emails/last?email=${encodeURIComponent(address)}`,
{
headers: { "X-API-Key": process.env.ZYNTRA_API_KEY! },
signal: AbortSignal.timeout(45_000),
},
);
if (!res.ok) throw new Error(`No email for ${address}: ${res.status}`);
return res.json();
}
export const testAddress = (tag: string) =>
`${process.env.ZYNTRA_TEAM_ID}.${tag}-${Date.now()}@zyntramail.com`;import { test, expect } from "@playwright/test";
import { lastEmail, testAddress } from "./zyntra";
test("sign-up email verifies the account", async ({ page }) => {
test.setTimeout(90_000);
const address = testAddress("firebase-signup");
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 lastEmail(address);
const verify = email.links.filter(
(l) => new URL(l.url).searchParams.get("mode") === "verifyEmail",
);
expect(verify).toHaveLength(1);
await page.goto(verify[0]!.url);
await expect(page.getByText(/verified/i)).toBeVisible();
});Email enumeration protection changes two flows
Projects created on or after September 15, 2023 have email enumeration protection on by default. Two consequences for tests:
- Password reset for an unknown address returns no error and sends no email. The only thing to assert is that nothing arrives.
- Changing the email with
updateEmailno longer works. UseverifyBeforeUpdateEmail: the address changes after the user opens the link sent to the new one. In the emulator that link has the request typeVERIFY_AND_CHANGE_EMAIL. The email-change post covers the full test.