How to test magic links in Playwright without a real inbox

Magic link and OTP tests break when they lean on real inboxes. Here's how to read the link straight from the MailSandbox API inside your Playwright suite.

• 10 min read •

Passwordless auth is lovely for users and a headache for whoever writes the tests. A magic link or a one-time code lands in an inbox you don't control, and your Playwright test just sits there waiting for mail it can't read. If you've gone looking for how to write a playwright test magic link email flow, you've probably hit the same wall: the browser drives the login form fine, but the token it needs is stuck in a mailbox somewhere.

The common ways of testing this fall apart for a few reasons, and they tend to fail at the worst possible time.

Delivery is never instant. An auth email might arrive in 200ms or it might take eight seconds, depending on the provider and how backed up the queue is. If your test hard-codes a wait, you either pad every step with a long sleep and drag out the whole suite, or you guess too low and watch the test flake. Debugging that at the end of a long day is nobody's idea of fun.

Then there's DOM scraping. Some teams point Playwright at a real Gmail or Outlook web UI, log in, open the message, and pull the link out of the page. That holds up until Google ships a redesign, adds a consent screen, or decides your headless Chrome looks like a bot and serves a CAPTCHA. Now a login test is failing for a reason that has nothing to do with your login code.

Sharing one personal inbox across the suite makes things worse. Run ten workers in parallel and they all hammer the same mailbox, race each other for the newest message, and trip the provider's rate limits. A few hundred automated logins later, the account gets flagged or the sending IP gets throttled, and your pipeline goes red with no obvious cause.

Read the email from an API instead

The fix is to stop treating the inbox like a UI and start treating it like data. Send the auth email to a MailSandbox address, then ask the API for that message directly. No browser tab, no webmail login, no scraping. The mail never reaches a real person, so there's nothing to clean up and nobody to accidentally spam.

The flow comes down to four steps:

  1. Trigger the auth. Playwright fills the email field with a MailSandbox address and submits the form, the same way a user would.
  2. Poll the API. Your test asks MailSandbox for messages sent to that address. Since delivery isn't instant, you poll on a short interval with a timeout rather than a fixed sleep.
  3. Extract the token. Once the message shows up, pull the magic link or OTP out of the body with a regex or by parsing the HTML.
  4. Finish in the browser. Hand the link back to Playwright, navigate to it, and assert the user is logged in.

The Playwright setup

Here's a small helper that polls the API and pulls the link out of the message body. It uses plain fetch, so there's nothing extra to install in your test container.

import { test, expect } from '@playwright/test';
 
const API = 'https://api.mailsandbox.com/v1';
const TOKEN = process.env.MAILSANDBOX_TOKEN!;
 
// Poll the API until an email arrives, or give up after `timeout` ms.
async function waitForEmail(address: string, timeout = 15000) {
const deadline = Date.now() + timeout;
 
while (Date.now() < deadline) {
const res = await fetch(`${API}/messages?to=${encodeURIComponent(address)}`, {
headers: { Authorization: `Bearer ${TOKEN}` },
});
const { messages } = await res.json();
 
if (messages.length > 0) return messages[0];
await new Promise((r) => setTimeout(r, 500));
}
 
throw new Error(`No email for ${address} within ${timeout}ms`);
}
 
// Pull the first auth link out of the email body.
function extractMagicLink(html: string) {
const match = html.match(/https:\/\/[^\s"'<>]+\/auth\/[^\s"'<>]+/);
if (!match) throw new Error('No magic link found');
return match[0];
}

With that in place, the test itself stays short. A test.beforeEach hook hands each test its own address, which keeps parallel runs from stepping on each other.

import { randomUUID } from 'crypto';
 
let address: string;
 
test.beforeEach(async () => {
// A unique address per test keeps parallel runs from colliding.
address = `login-${randomUUID()}@inbox.mailsandbox.com`;
});
 
test('user logs in with a magic link', async ({ page }) => {
// 1. Trigger the auth email.
await page.goto('/login');
await page.getByLabel('Email').fill(address);
await page.getByRole('button', { name: 'Send link' }).click();
 
// 2. Read it straight from the API.
const email = await waitForEmail(address);
const link = extractMagicLink(email.html);
 
// 3. Finish the flow in the browser.
await page.goto(link);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

That's the whole thing. The browser never touches a webmail UI, so there's no redesign or CAPTCHA waiting to break the run. If the email is slow, waitForEmail keeps polling until the timeout, so you're not padding the test with a fixed sleep that's either too long or too short.

If your auth sends a numeric code rather than a link, the only thing that changes is the regex. A six-digit OTP is usually just this, run against the plain-text part of the message:

const otp = email.text.match(/\b\d{6}\b/)?.[0];

Feed that code into your verification form and assert the result. The rest of the setup is identical, whether you're testing a link, a code, or both in the same flow.

Isolated addresses for parallel runs

Generating a fresh address in beforeEach matters more than it looks. When every test owns its own address, ten parallel workers never fight over one inbox. Worker three won't read the login email meant for worker seven, and you don't need fragile logic to figure out which of five near-identical messages belongs to you.

Because the mail stays inside the sandbox, no real provider is in the loop to rate-limit you or flag a sending IP. You can run the full suite dozens of times a day in CI without a Gmail account getting locked or a shared team inbox filling with test noise. Each address is disposable, so there's no cleanup step: you let sandbox inboxes expire instead of deleting messages out of a real mailbox.

Tip
The one rule that fixes flakiness
Give every test its own address. Parallel workers then never race for the same message, and a slow email in one test can't leak into another.

The same idea in Cypress

The shape carries straight over to Cypress. The command syntax is chained rather than async/await, but the steps are the same: trigger the email, request it from the API, pull the link, then visit it.

Cypress.Commands.add('getMagicLink', (address) => {
return cy
.request({
url: `${Cypress.env('MAILSANDBOX_API')}/messages?to=${address}`,
headers: { Authorization: `Bearer ${Cypress.env('MAILSANDBOX_TOKEN')}` },
})
.its('body.messages.0.html')
.then((html) => html.match(/https:\/\/[^\s"'<>]+\/auth\/[^\s"'<>]+/)[0]);
});

Drop that into your commands file and you can call cy.getMagicLink(address) wherever a test needs to complete a login. Same API, same regex, different runner.

No SDK, just a fetch call

One detail that matters here: there's no client library to add to your project. Tools like MailSlurp and Mailosaur offer dedicated SDKs, which some teams like, though it does mean another dependency in your test image and another version to keep current. In this setup the integration is a fetch and an auth header. It reads clearly in review, it's easy to mock if you need to, and it keeps your CI container lean. For a suite that already has plenty of moving parts, one less package to maintain is a fair trade.

FAQ

Do I have to disable email verification to test the login flow?

No, and you shouldn't. The point of this setup is to test the real verification path. You send the actual auth email, then read it from the API instead of a real inbox, so the token, expiry, and redirect all get exercised the way a real user would hit them.

How do I deal with email delivery latency?

Poll the API on a short interval with a timeout, like the waitForEmail helper above. A fast email returns immediately and a slow one still gets caught, without a hard-coded sleep that's wrong in one direction or the other.

Can I run these tests in parallel?

Yes. Give each test a unique address in a beforeEach hook. With one address per test, parallel workers never read each other's messages, and you avoid the rate limits and IP bans that come from pounding a shared real inbox.

Does this work headless in CI?

It does. Reading the email is a plain HTTP call, so no browser is involved in that step. The only browser work is filling the login form and visiting the final link, which Playwright already handles headless.

Yes. Swap the link regex for one that matches your code format, such as a six-digit \b\d{6}\b against the plain-text body. Everything else stays the same.

Test your magic links without a single real inbox
Spin up a sandbox, point your auth emails at it, and read the link straight from the API in your next Playwright run.
Get started