Testing Email OTP Codes and Magic Links
A one-time code (OTP) or a magic link is the one email a user can't sign in without. Most suites never read it. They pull the code from the database or stub the mailer, and the test passes while the email shows the wrong code or a link to localhost. Here the app sends the real email to the sandbox, and the test reads it the way a user would.
Get the Email into the Sandbox
The route depends on who sends the email:
- Your app sends it. Point its SMTP settings at
send.smtp.devand log in as a sandbox account (settings per framework). - An auth provider sends it. The code comes from the provider's servers, not your app, so changing your app's SMTP settings does nothing. Sign up with an address on a domain whose MX points at the sandbox, and the provider delivers there like to any other mailbox.
The tests below use one account, qa@test.example.com. Every test signs up as a plus alias of it, such as qa+3f2a9c@test.example.com. The alias delivers to qa@ and stays in the message's to, so parallel tests never read each other's codes. Plus aliases work on every plan.
The Mail Helper
A small helper next to the tests, tests/mail.ts:
const API = 'https://api.smtp.dev'
const headers = { 'X-API-KEY': process.env.SMTP_DEV_API_KEY! }
async function api(path: string) {
const res = await fetch(API + path, { headers })
if (!res.ok)
throw new Error(`${path} returned ${res.status}`)
return res.json()
}
export async function inboxOf(address: string) {
const { member } = await api(`/accounts?address=${encodeURIComponent(address)}`)
const account = member.find((a: { address: string }) => a.address === address)
const { mailboxes } = await api(`/accounts/${account.id}`)
const inbox = mailboxes.find((m: { path: string }) => m.path === 'INBOX')
return `/accounts/${account.id}/mailboxes/${inbox.id}/messages`
}
export async function findMessage(inbox: string, to: string, seen: string[] = []) {
const { member } = await api(inbox)
const found = member.find((m: { id: string, to: { address: string }[] }) =>
!seen.includes(m.id) && m.to.some(t => t.address === to))
return found && api(`${inbox}/${found.id}`)
}
findMessage returns the newest message sent to an address, or undefined while there is none. seen lists message ids to skip, which is how a test waits for the second email after a resend.
Reading a One-Time Code
import { expect, test } from '@playwright/test'
import { findMessage, inboxOf } from './mail'
let inbox: string
test.beforeAll(async () => {
inbox = await inboxOf('qa@test.example.com')
})
const newAddress = () => `qa+${crypto.randomUUID()}@test.example.com`
const codeIn = (text: string) => text.match(/verification code is\s*((?:\d\s*){6})/)?.[1]?.replace(/\s/g, '')
async function nextMessage(to: string, seen: string[] = []) {
await expect.poll(() => findMessage(inbox, to, seen), { timeout: 30_000 }).toBeTruthy()
return findMessage(inbox, to, seen)
}
test('signup with an emailed code', async ({ page }) => {
const address = newAddress()
await page.goto('/signup')
await page.getByLabel('Email').fill(address)
await page.getByRole('button', { name: 'Create account' }).click()
const message = await nextMessage(address)
const code = codeIn(message.text)
expect(code).toBeDefined()
const mail = await page.context().newPage()
await mail.setContent(message.html.join(''))
expect(codeIn(await mail.locator('body').textContent() ?? '')).toBe(code)
await mail.close()
await page.getByLabel('Code').fill(code!)
await page.getByRole('button', { name: 'Verify' }).click()
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
})
codeIn matches the sentence the template writes, not any six digits. Take this email:
Acme account 739201
Your verification code is 482913.
It expires in 10 minutes.
/\b\d{6}\b/ returns 739201. A loose pattern also picks up an order number, a street address, or an id inside a tracking link, whichever comes first.
The HTML part often draws each digit in its own styled element, so the markup holds no six-digit run at all. The test renders the HTML in a second page and reads the code from the page's text with the same codeIn, which catches two templates that have drifted apart. The pattern allows whitespace after each digit because that text reads 4 8 2 9 1 3 when the template puts each element on its own line.
When an email has no text part, read the code from that page instead: codeIn(await mail.locator('body').textContent() ?? '').
Testing a Resent Code
test('a resent code replaces the first one', async ({ page }) => {
const address = newAddress()
await page.goto('/signup')
await page.getByLabel('Email').fill(address)
await page.getByRole('button', { name: 'Create account' }).click()
const first = await nextMessage(address)
await page.getByRole('button', { name: 'Resend code' }).click()
const second = await nextMessage(address, [first.id])
await page.getByLabel('Code').fill(codeIn(first.text)!)
await page.getByRole('button', { name: 'Verify' }).click()
await expect(page.getByRole('alert')).toBeVisible()
await page.getByLabel('Code').fill(codeIn(second.text)!)
await page.getByRole('button', { name: 'Verify' }).click()
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
})
Passing [first.id] makes nextMessage wait for a different message instead of returning the first one again. Skipping by id is safer than comparing createdAt with the test machine's clock, because the two clocks differ.
Whether a resend should cancel the earlier code is your call. If both should stay valid, flip the first assertion.
Testing a Magic Link
test('a magic link signs in once', async ({ page, request, baseURL }) => {
const address = newAddress()
await page.goto('/signin')
await page.getByLabel('Email').fill(address)
await page.getByRole('button', { name: 'Email me a link' }).click()
const message = await nextMessage(address)
const mail = await page.context().newPage()
await mail.setContent(message.html.join(''))
const link = await mail.getByRole('link', { name: 'Sign in to Acme' }).getAttribute('href')
await mail.close()
expect(new URL(link!).origin).toBe(new URL(baseURL!).origin)
await request.get(link!)
await page.goto(link!)
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByRole('heading', { name: `Signed in as ${address}` })).toBeVisible()
await page.goto(link!)
await expect(page.getByRole('alert')).toHaveText('This link has expired')
})
Three checks in one test:
- The origin. A link built from the wrong base URL points at production, or at
localhost. - A scanner's visit. Mail filters and link previewers fetch the URLs in a message before the recipient opens it. If a
GETalone signs the user in, the scanner spends the link and the person sees "expired".request.getplays the scanner, and the link has to work afterwards. This app passes because the link opens a page with a Continue button, and only that button'sPOSTuses the token. - Single use. The second visit is refused.
getAttribute('href') returns the URL as the browser parsed it, so & in the markup comes back as &.
Asserting That No Email Arrives
Some tests pass when nothing arrives: a password reset for an address with no account, a resend past the rate limit, a user who unsubscribed.
test('an unknown address gets no reset email', async ({ page }) => {
const address = newAddress()
await page.goto('/forgot-password')
await page.getByLabel('Email').fill(address)
await page.getByRole('button', { name: 'Send reset link' }).click()
await expect(page.getByText('If that address has an account')).toBeVisible()
await page.waitForTimeout(5000)
expect(await findMessage(inbox, address)).toBeUndefined()
})
There's no event to wait for when nothing should happen. Wait a fixed time, longer than your other tests take to see a message, then check once.
More Cases
| Case | Setup | Expect |
|---|---|---|
| Wrong code | Enter six digits that weren't sent | An error, and the real code still works after it |
| Expired code | Run the test environment with a short lifetime, like OTP_TTL_SECONDS=3, and wait it out | The same error as a wrong code |
| Used code | Verify, then submit the same code in a new session | Refused |
| Someone else's code | Sign up as two addresses and enter the first one's code for the second | Refused |
| Too many wrong attempts | Enter wrong codes up to your limit, then the right one | Refused until a new code is requested |
Other Runners
The helper is plain fetch, so it runs anywhere Node does. Cypress has to keep the API key out of the browser, so the wait runs in a task: the Cypress guide has it. For pytest, the API quickstart has the same wait as a fixture. The Playwright guide shows how to start the app with its SMTP settings for a CI run.
When It Fails
| Symptom | Cause |
|---|---|
| The app errors when it sends | send.smtp.dev refuses recipients that aren't sandbox accounts. The account behind the alias, qa@test.example.com here, has to exist |
| No email, and the app sent without error | The app still sends through its usual provider, and the address isn't on a domain whose MX points at the sandbox |
codeIn returns undefined | The email has no plain text part, so text is empty. Read the code from the rendered HTML |
| The wrong digits | The pattern is too loose. Anchor it to the words around the code |
| Passes alone, fails in parallel | Two tests share an address. Give each its own alias |
Size Limits
Why a 25 MB limit fits about 18 MB of attachments, the limits Gmail, Microsoft 365, SES, SendGrid, Mailgun, and Postmark publish, and how to test the boundary.
IMAP & POP3
IMAP works on the server's copy of a mailbox, POP3 downloads it. Ports, the commands to type into openssl, and curl, Python, and Node scripts for both.