Testing Email OTP Codes and Magic Links

Read one-time codes and magic links from the real email over a REST API, then test resend, reuse, and single-use links. Playwright specs you can copy.

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.dev and 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.

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 GET alone signs the user in, the scanner spends the link and the person sees "expired". request.get plays 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's POST uses 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

CaseSetupExpect
Wrong codeEnter six digits that weren't sentAn error, and the real code still works after it
Expired codeRun the test environment with a short lifetime, like OTP_TTL_SECONDS=3, and wait it outThe same error as a wrong code
Used codeVerify, then submit the same code in a new sessionRefused
Someone else's codeSign up as two addresses and enter the first one's code for the secondRefused
Too many wrong attemptsEnter wrong codes up to your limit, then the right oneRefused 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

SymptomCause
The app errors when it sendssend.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 errorThe app still sends through its usual provider, and the address isn't on a domain whose MX points at the sandbox
codeIn returns undefinedThe email has no plain text part, so text is empty. Read the code from the rendered HTML
The wrong digitsThe pattern is too loose. Anchor it to the words around the code
Passes alone, fails in parallelTwo tests share an address. Give each its own alias