Email testing guides

Hands-on email testing guides for developers and QA teams

← All testing guides
QA Testing

Reading OTP Codes from Emails in Automated Tests (Without the Flakiness)

October 28, 2026·10 min read
A puzzled tester looks at a sign-in email; the order number and the year are crossed out and the six-digit code is circled.

A one-time code email looks harmless: "Your Bookshop code is 482913." A person glances at it and types six digits. A test script, on the other hand, has to find those six digits in a block of text that might also contain an order number, a year in the footer, a phone number and a CSS color. Then it has to be sure it's reading today's email and not the one from the previous test step.

Most flaky OTP tests come down to one of those two problems. This guide fixes both, then adds the tests people tend to skip (expiry, resend, wrong-code limits) and a GitHub Actions workflow to run them on every push.

The examples read mail from Mailpit, a free local mail catcher. In the test environment your app sends through Mailpit's SMTP port (1025) instead of a real provider, and tests read what arrived through its REST API on port 8025. Every message is caught, nothing is delivered, and each test can make up its own address. Near the end we look at where a real inbox still fits.

Problem 1: finding the right six digits

The first version of an OTP helper is almost always body.match(/\d+/) or \d{6}. Here's a realistic email that breaks both:

Your order #100234 is ready for pickup. To sign in, use code 482 913. The code expires in 10 minutes. Questions? Call 555 123 4567. © 2026 Bookshop

\d+ returns 100234, the order number. \d{6} also returns 100234, and it would miss the real code anyway because of the space in the middle. Change the email layout a little and the test starts reading the year. These failures are maddening because they come and go with template changes nobody told QA about.

A few rules make extraction dependable:

  1. Prefer the plain-text body. Mailpit's message JSON has Text and HTML. The text has no CSS colors like #123456, no tracking pixels and no hidden preheader tricks. When an email has no plain-text part, Mailpit builds Text from the HTML for you; the helper still strips the HTML itself if Text comes back empty.
  2. Look near a keyword. Search for the code within a short distance of words like "code", "passcode", "one-time" or "verification", with no other digits in between.
  3. Don't match pieces of longer numbers. Use lookarounds so (?<!\d) and (?!\d) keep you from grabbing six digits out of a ten-digit tracking number.
  4. Allow spaces and dashes inside the code. Many templates show 482 913 or 482-913, and HTML emails often put each digit in its own table cell, which becomes 4 8 2 9 1 3 as text.
  5. Check the subject too. Lots of services put the code first in the subject ("482913 is your Bookshop code") so it shows in phone notifications.

Here is that logic in JavaScript. It works in Node 18+, Playwright, Cypress tasks or plain scripts:

JavaScript
// otp.js
const CODE = String.raw`(?<!\d)(\d(?:[ -]?\d){5})(?!\d)`; // 482913, 482 913, 4 8 2 9 1 3
const WORD = String.raw`\b(?:code|passcode|one-time|otp|verification)\b`;
const PATTERNS = [
  new RegExp(`${WORD}\\D{0,40}?${CODE}`, 'i'), // "your code is 482913" (tried first)
  new RegExp(`${CODE}\\D{0,40}?${WORD}`, 'i'), // "482913 is your code"
];

function htmlToText(html) {
  return html
    .replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;|&#160;/g, ' ')
    .replace(/\s+/g, ' ');
}

// email is Mailpit's message JSON: Subject, Text, HTML
export function extractOtp(email) {
  const text = email.Text || htmlToText(email.HTML || '');
  for (const pattern of PATTERNS) {
    for (const source of [email.Subject || '', text]) {
      const match = source.match(pattern);
      if (match) return match[1].replace(/\D/g, '');
    }
  }
  throw new Error(`No 6-digit code found in "${email.Subject}"`);
}

On the sample email above, this returns 482913. The keyword-first pattern runs first on purpose: in "order #100234 ... use code 482 913", the code-first pattern alone would happily pair the order number with the word "code" that follows it.

And the same thing in Python:

Python
# otp.py
import html
import re

CODE = r"(?<!\d)(\d(?:[ -]?\d){5})(?!\d)"
WORD = r"\b(?:code|passcode|one-time|otp|verification)\b"
PATTERNS = [
    re.compile(WORD + r"\D{0,40}?" + CODE, re.IGNORECASE),  # tried first
    re.compile(CODE + r"\D{0,40}?" + WORD, re.IGNORECASE),
]


def html_to_text(body: str) -> str:
    body = re.sub(r"(?is)<(style|script)\b.*?</\1>", " ", body)
    body = re.sub(r"<[^>]+>", " ", body)
    return re.sub(r"\s+", " ", html.unescape(body))


def extract_otp(email: dict) -> str:
    """email is Mailpit's message JSON (Subject, Text, HTML)."""
    text = email.get("Text") or html_to_text(email.get("HTML") or "")
    for pattern in PATTERNS:
        for source in (email.get("Subject") or "", text):
            match = pattern.search(source)
            if match:
                return re.sub(r"\D", "", match.group(1))
    raise AssertionError(f"No 6-digit code found in {email.get('Subject')!r}")
Tip: The best fix is a friendly chat with whoever owns the template. If every OTP email says "Your code is" followed by the code on its own line, the regex becomes boring, and boring is what you want in a test helper. Ask for it once and write it into the template's acceptance criteria.

If your codes contain letters (K7Q-29X), swap \d for a character class like [A-Z0-9] and keep the keyword rule. Without a keyword, a pattern that matches letters will find words everywhere.

Problem 2: reading the newest email, not an old one

The second classic flake: the test signs in, asks for a code, and reads the inbox. But the inbox already holds a code from the sign-up step a few seconds earlier. Sometimes the new email has arrived and the test is fine; sometimes it hasn't, and the test types an old code and fails. Run it again and it passes, which is the worst kind of bug report.

The fix is to remember when you asked for the code, and only accept an email that arrived after that. Three details matter:

  • Mailpit stamps each message with Created, an RFC 3339 time that carries its zone: 2026-10-28T15:27:53.597Z, or -04:00 on the end if the container runs with a TZ setting. Date.parse handles both, and so does Python's datetime.fromisoformat from 3.11 on.
  • Filter by subject when the flow sends more than one email. A welcome email that lands a moment after you click "Send me a code" passes the time check too, and then the test hunts for a code in the wrong message. Adding subject:"code" to the search fixes it.
  • Clocks never agree exactly, even on one machine (Mailpit stores milliseconds; your test may round). Take the start time a second early, and when you need a second code in the same test, also skip the email ID you already used.

Mailpit's search returns the newest match first, so we only ever look at one result. Here's the JavaScript version, with a Playwright test that uses it:

JavaScript
// inbox.js (reads mail from Mailpit)
import { randomUUID } from 'node:crypto';

const MAILPIT = process.env.MAILPIT_URL ?? 'http://localhost:8025';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export const newAddress = () => `otp-${randomUUID()}@example.test`;

async function mailpit(path) {
  const res = await fetch(`${MAILPIT}${path}`);
  if (!res.ok) throw new Error(`Mailpit answered ${res.status} for ${path}`);
  return res.json();
}

export async function waitForNewEmail(address, { since, afterId = null, subject = '', timeoutMs = 20_000 }) {
  const filter = subject ? ` subject:"${subject}"` : '';
  const query = encodeURIComponent(`to:"${address}"${filter}`);
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const { messages } = await mailpit(`/api/v1/search?query=${query}&limit=1`); // newest first
    const newest = messages[0];
    if (newest && Date.parse(newest.Created) >= since && newest.ID !== afterId) {
      return mailpit(`/api/v1/message/${newest.ID}`); // Subject, Text, HTML, ...
    }
    await sleep(250); // a local catcher answers fast
  }
  throw new Error(`No new email for ${address} within ${timeoutMs / 1000}s`);
}
JavaScript
// sign-in.spec.js (Playwright)
import { test, expect } from '@playwright/test';
import { newAddress, waitForNewEmail } from './inbox.js';
import { extractOtp } from './otp.js';

test('signs in with an emailed code', async ({ page }) => {
  const address = newAddress();
  await page.goto('/sign-in');
  await page.getByLabel('Email').fill(address);

  const since = Date.now() - 1_000; // a little slack for clock rounding
  await page.getByRole('button', { name: 'Send me a code' }).click();

  const code = extractOtp(await waitForNewEmail(address, { since, subject: 'code' }));
  await page.getByLabel('6-digit code').fill(code);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Your bookshelf' })).toBeVisible();
});

Why not skip the checks, since every test gets a fresh address anyway? For a flow that sends exactly one email, that works. But the moment a flow sends two (a welcome message and a code, or a code and a resend), "the newest email" becomes a race. Drop the subject filter from the Playwright test above, and a sign-in that also sends a welcome email will hand the welcome message to extractOtp. The since, subject and afterId checks cost three lines and remove the guesswork.

If you use Playwright heavily, our Playwright email testing guide shows how to fold this into fixtures.

The tests people skip: expiry, resend and wrong codes

Reading one code proves the email is sent. The security of a one-time code comes from its limits, and those need tests too. Say Bookshop signs people in with a code and no password. The first code request creates the account. Here are the rules worth checking, in Python 3.11+ with pytest and requests against your app's API (your endpoints will have their own names):

Python
# test_otp.py
import os
import time
import uuid
from datetime import datetime, timedelta, timezone

import pytest
import requests

from otp import extract_otp

MAILPIT = os.environ.get("MAILPIT_URL", "http://localhost:8025")
APP = os.environ["APP_URL"]


def wait_for_new_email(address, since, after_id=None, subject="code", timeout=20):
    query = f'to:"{address}" subject:"{subject}"'
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        r = requests.get(f"{MAILPIT}/api/v1/search", params={"query": query, "limit": 1}, timeout=5)
        r.raise_for_status()
        newest = next(iter(r.json()["messages"]), None)  # newest first
        # Python 3.11+ reads Mailpit's "Z" or "+02:00" offsets
        if newest and datetime.fromisoformat(newest["Created"]) >= since and newest["ID"] != after_id:
            return requests.get(f"{MAILPIT}/api/v1/message/{newest['ID']}", timeout=5).json()
        time.sleep(0.25)
    raise TimeoutError(f"No new email for {address} within {timeout}s")
Python
@pytest.fixture
def inbox():
    return f"otp-{uuid.uuid4().hex[:12]}@example.test"  # Mailpit accepts any address


def request_code(address):
    requests.post(f"{APP}/api/auth/email-code", json={"email": address}, timeout=10).raise_for_status()


def verify(address, code):
    return requests.post(f"{APP}/api/auth/verify-code", json={"email": address, "code": code}, timeout=10)


def test_resend_replaces_the_old_code(inbox):
    since = datetime.now(timezone.utc) - timedelta(seconds=1)
    request_code(inbox)
    first = wait_for_new_email(inbox, since)
    request_code(inbox)  # "Send me a new code"
    second = wait_for_new_email(inbox, since, after_id=first["ID"])
    assert verify(inbox, extract_otp(first)).status_code == 400
    assert verify(inbox, extract_otp(second)).status_code == 200


def test_code_works_only_once(inbox):
    since = datetime.now(timezone.utc) - timedelta(seconds=1)
    request_code(inbox)
    code = extract_otp(wait_for_new_email(inbox, since))
    assert verify(inbox, code).status_code == 200
    assert verify(inbox, code).status_code == 400


def test_five_wrong_codes_burn_the_real_one(inbox):
    since = datetime.now(timezone.utc) - timedelta(seconds=1)
    request_code(inbox)
    code = extract_otp(wait_for_new_email(inbox, since))
    wrong = f"{(int(code) + 1) % 1_000_000:06d}"
    for _ in range(5):
        assert verify(inbox, wrong).status_code == 400
    assert verify(inbox, code).status_code == 400  # locked out; ask for a new code

Expiry is the one you can't test quickly against a 10-minute code. Don't put a 10-minute sleep in CI. Ask for a configurable lifetime instead, say 30 seconds in the test environment, and let the test read the same setting:

Python
@pytest.mark.skipif("OTP_TTL_SECONDS" not in os.environ, reason="needs a short code lifetime")
def test_expired_code_is_rejected(inbox):
    since = datetime.now(timezone.utc) - timedelta(seconds=1)
    request_code(inbox)
    code = extract_otp(wait_for_new_email(inbox, since))
    time.sleep(int(os.environ["OTP_TTL_SECONDS"]) + 2)
    assert verify(inbox, code).status_code == 400

Your exact rules may differ: some apps keep the old code valid after a resend, some lock after three tries. That's fine. The value is in writing the rule down and having a test fail when it quietly changes. The OWASP Multifactor Authentication Cheat Sheet is a good reference when deciding what those rules should be. If you are on the building side, how to build email verification covers generating, hashing and rate-limiting codes.

Running it in GitHub Actions

In CI, Mailpit runs as a service container beside the job, and the app under test starts in the job with its SMTP settings pointed at it. Here's a workflow that runs on every push, every pull request and on demand:

YAML
# .github/workflows/otp-tests.yml
name: OTP email tests
on:
  push:
    branches: [main]
  pull_request:
  workflow_dispatch:

jobs:
  otp:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    services:
      mailpit:
        image: axllent/mailpit:v1.32   # pin the version you tested with
        ports:
          - 1025:1025   # SMTP: the app sends here
          - 8025:8025   # REST API: the tests read here
    env:
      APP_URL: http://localhost:3000
      MAILPIT_URL: http://localhost:8025
      OTP_TTL_SECONDS: '30'   # the app reads the same value
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: pip install pytest requests
      - name: Start the app with mail going to Mailpit
        run: |
          SMTP_HOST=localhost SMTP_PORT=1025 ./scripts/start-test-server.sh &
          curl -sf --retry 30 --retry-delay 2 --retry-connrefused "$APP_URL/health"
      - run: pytest tests/test_otp.py -v --junitxml=otp-results.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: otp-results
          path: otp-results.xml

A few choices in there are deliberate. timeout-minutes means a stuck test costs 15 minutes, not GitHub's default of six hours. The service's ports are mapped to the runner, so the app sends to localhost:1025 and the tests read localhost:8025. The curl line waits until the app answers before any test starts (swap /health for whatever your app has). Nothing secret is needed for email: Mailpit lives only as long as the job, and every run starts with an empty mailbox. The JUnit XML upload gives you a readable report even when the job fails.

One practical note: if your app has a resend cooldown (it should), the resend test needs a short cooldown in the test configuration, or the second request will get a 429.

The last mile: a real inbox before release

Mailpit proves your app builds the right email with a working code. It can't prove the email gets through your real provider and lands somewhere real, and that's where the remaining surprises live: a staging API key that's wrong, a sender domain the provider never verified, a code email that a spam filter dislikes.

So before a release, ask for a code by hand on staging, where mail goes through your real provider. Open temp-email.ai in a new tab to get a disposable inbox for testing, sign in with that address, and check that the email arrives within a minute or two, that the code shows in the subject or preview, that it works, and that it's easy to copy on a phone. Anyone with an inbox link can read it, so use it for test accounts only. Mailpit handles the hundreds of automated runs; a real inbox handles the few checks a person should make.

Checklist for OTP email tests

  • Read Text first; strip <style> and <script> before searching HTML.
  • Match the code near a keyword, allow spaces and dashes, and block digits on either side.
  • Check the subject line, where many services put the code.
  • Record the time before you request a code, compare it with Mailpit's Created (time zone included), and only accept newer emails.
  • Filter by subject when the flow sends more than one email.
  • For a second code in the same test, also skip the email ID you already used.
  • Use one fresh address per test user, so parallel tests never share codes.
  • Give every wait a deadline and an error message that names the address.
  • Test the rules: single use, resend, wrong-code lockout and expiry.

Wrapping up

OTP tests get their bad reputation from two small mistakes: grabbing the wrong number and reading the wrong email. Fix those with a keyword-aware regex and a "newer than when I asked" filter, and the rest is ordinary test code. Catch the mail in Mailpit, add the expiry and resend checks, let GitHub Actions run them on every push, and keep one real-inbox check before release. You'll hear about a broken code email from CI instead of from a customer. Happy testing!