Local: Mailpit at port 54324
supabase start runs Mailpit next to the database, at localhost:54324, and Supabase Auth sends every sign-up, magic-link and reset email there. That is enough to test your UI and redirects locally. It does not cover your hosted project's templates, SMTP settings or redirect allow-list; the Mailpit post goes through where a local catcher stops.
Hosted: the built-in service won't reach a test inbox
Supabase's docs list three limits of the default email service:
- It refuses to deliver to addresses that are not part of the project's team.
- It sends 2 emails per hour, and that number can change without notice.
- It is best-effort, with no delivery or uptime guarantee.
So an E2E test that signs up run-123@… on a staging project gets no email at all. Set up custom SMTP for staging (the same provider as production, ideally), and the email rate limit becomes a setting you control.
Rate limits that fail tests
- 60 seconds per user. By default a user can request a magic link or OTP once every 60 seconds, and the same window applies to sign-up confirmation and password reset. A test that requests a second link right after the first gets an error, not an email. Use a fresh address per test.
- Per IP address. The sign-up, recover, resend, magic-link and OTP endpoints are limited to 30 requests per 5 minutes per IP. Parallel CI workers share one IP, so a large suite can hit it.
- Link lifetime. Magic links and OTPs expire after one hour by default. Not a problem for a test that reads the email at once; a problem for a fixture that reuses yesterday's link.
The test: magic-link sign-in on staging
With the default template, {{ .ConfirmationURL }} is a link that goes through Supabase Auth and then redirects to your redirectTo, which has to match the project's Redirect URLs list. Staging projects often have a Site URL or redirect list copied from production, and only a real email shows where the link actually lands.
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("magic link signs the user in", async ({ page }) => {
test.setTimeout(90_000);
const address = testAddress("supabase-magic");
await page.goto("/login");
await page.getByLabel("Email").fill(address);
await page.getByRole("button", { name: "Send magic link" }).click();
const email = await lastEmail(address);
const supabaseHost = new URL(process.env.SUPABASE_URL!).host;
const link = email.links.filter((l) => new URL(l.url).host === supabaseHost);
expect(link).toHaveLength(1);
await page.goto(link[0]!.url);
// Lands on redirectTo, signed in.
await expect(page).toHaveURL(/\/dashboard/);
});Email change sends two emails
Supabase Auth has "secure email change" on by default (secure_email_change_enabled defaults to true in the Auth server): updateUser({ email }) sends a confirmation link to both the current and the new address. A test of that flow needs two inboxes; see the email-change post.