Testing & CI

OTP codes from email in automated tests, without flaking

A one-time code is six digits inside a message that also contains a year, a price, an order number and a phone number. Getting the right six out — every time, in every runner — is a small discipline: anchor the pattern, strip the markup, ignore the message you already saw, respect the expiry. This is that discipline, with drop-in code for the shell, Python and TypeScript.

  • Intermediate
  • 14 min read
An open blue envelope with six small grey blocks lined up in front of it, each marked with a blue dot, under a grey magnifying glass

Where the code actually is

A verification email has up to three places the code can live, and which one you should read decides everything downstream. The message JSON from the API gives you all three at once: subject, text (the plain-text part, or null) and html (the HTML part, or null).

WhereWhat it looks likeHow to read it
The plain-text part (text)Your code is 481920. It expires in 10 minutes.Parse this first when it exists. No markup, nothing to decode, and the wording is stable.
The HTML part (html)The same sentence inside a table, often with the digits styled one per cell, and every & written as an entity.Strip tags to spaces, decode entities, fold whitespace, then apply the pattern. Never regex raw HTML.
The subject line481920 is your verification codeA gift when the sender does it: no body to parse at all. Match it on the subject and fall back to the body.
An imageThe code drawn as a picture, to defeat exactly this kind of script.Rare, and a sign the sender does not want automation. Change the sender’s template if it is yours; there is no honest workaround if it is not.

The plain-text part is the one to prefer, and it is the one most templating systems generate automatically from the HTML — so it is usually there. When it is null, the HTML part is the only body, and the next two sections are about reading it safely.

Anchor the pattern on your own wording

The instinct is \d{6}. It matches the code, and it also matches the year in the footer, the postcode in the address block, the last six digits of a phone number and the order number that appears two lines above the code. Whichever comes first wins, and the test types it into the form with complete confidence.

PatternAlso matchesVerdict
\d{6}Years, postcodes, prices without a separator, order numbers, phone numbers, tracking numbers.Never. It is not a pattern, it is a coin toss.
\b\d{6}\bEverything above that happens to be exactly six digits with a space either side — still most of it.Barely better. Word boundaries do not know what a code is.
code is\D{0,12}(\d{6})Only the six digits that follow the words your template puts before the code, with room for a colon, a space or a tag’s worth of leftover whitespace.Yes. It matches the code and nothing else, and it fails the day somebody rewords the email — which is a failure you want to hear about.

The \D{0,12} is the practical detail: after tags have been replaced by spaces, the words and the digits can be separated by a colon, a run of spaces, or the debris of a <strong> that used to sit between them. Up to a dozen non-digits covers all of that without letting the pattern skip to a different number.

Templates that split the digits up

A popular design puts each digit of the code in its own box, so it reads well on a phone. In the HTML that is six table cells, or six <span>s, and the number never appears as six consecutive characters anywhere in the source:

what the HTML part actually contains
<p>Your code is</p>
<table><tr>
  <td class="digit">4</td><td class="digit">8</td><td class="digit">1</td>
  <td class="digit">9</td><td class="digit">2</td><td class="digit">0</td>
</tr></table>

A regex on the raw HTML finds nothing. The fix is not a cleverer regex; it is to turn the HTML into text first, in a fixed order:

  1. Replace every tag with a space. A space, not nothing — <td>4</td><td>8</td> must become 4 8, not 48 glued to whatever followed.
  2. Decode the entities. &amp;, &nbsp;, &#39;. A non-breaking space between two digits is not a space to a regex until it is decoded.
  3. Fold whitespace, then match with the digits allowed to be spaced. For the boxed design, code is\D{0,12}(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d)\s*(\d) and join the groups; for a normal template the plain pattern from the previous section is enough.

The helpers below do steps one and two for you and search both parts at once, so a test does not have to know which design the template uses this month.

The newest message is not always the right one

Every mailbox listing here comes back newest first, and messages[0] is what most first drafts read. Three situations make that the wrong message:

Two messages from one action
Sign-up sends a welcome mail and a code mail, in whichever order the sender’s queue empties. Half the time the welcome is newest. Filter on the subject, or on the sender, before taking anything.
A resend
The test asked for the code twice — once by mistake, once on purpose — and the server only accepts the latest. The older message is still in the mailbox, still matches the pattern, and still parses into six digits that are now invalid.
A previous test run
Only if the address was reused, which it should never be. A fresh address per run makes this case impossible; if you cannot have one, the snapshot below is the fallback.

The robust shape is the same in every runner: look at what is in the mailbox before you trigger the mail, then accept only a message that was not there yet and matches the subject you expect.

wait for a message that did not exist before the resend
// Remember what is already there, THEN trigger the resend, THEN wait for something new.
const before = new Set((await listMailbox(address)).messages.map(m => m.id));

await page.getByRole('button', { name: 'Resend code' }).click();

const fresh = await waitFor(address, m => !before.has(m.id) && /code/i.test(m.subject));

Codes that expire during the run

Most one-time codes are valid for five to fifteen minutes. That sounds generous until a test suite queues twenty specs, each of which requested its code at the start and typed it at the end. Three rules keep the code alive:

  • Request the code as late as possible. Trigger the send immediately before the wait, not in a setup step that runs while other tests are queued.
  • Keep the wait deadline well under the code’s lifetime. A sixty-second deadline for a ten-minute code leaves nine minutes to type it. A ten-minute deadline leaves nothing.
  • Never store a code for another test. Codes are single-use as well as short-lived; a shared fixture that hands one out is a race between two tests for one number.

Drop-in extractors

Three versions of the same four lines: both parts joined, tags to spaces, entities decoded, whitespace folded, then the anchored pattern. Change the pattern to match your template’s wording and nothing else needs touching.

From a shell, with jq

shell
curl -sG "https://grabmail.io/api/v1/message/$ID" --data-urlencode "mailbox=$ADDR" \
| jq -r '[.text, .html] | map(select(. != null)) | join(" ") | gsub("<[^>]*>"; " ")' \
| grep -oiE 'code is[^0-9]{0,12}[0-9]{6}' | grep -oE '[0-9]{6}' | head -1

Python

extract.py
import html
import re

TAGS = re.compile(r"<[^>]+>")
CODE = re.compile(r"code is\D{0,12}(\d{6})", re.I)      # anchored on YOUR template's wording


def text_of(message: dict) -> str:
    """Both parts as plain text: tags out, entities decoded, whitespace folded."""
    raw = f"{message.get('text') or ''}\n{message.get('html') or ''}"
    return re.sub(r"\s+", " ", html.unescape(TAGS.sub(" ", raw)))


def code_from(message: dict, pattern: re.Pattern = CODE) -> str:
    hit = pattern.search(text_of(message))
    if not hit:
        raise AssertionError(f"no code in message {message['id']!r} ({message['subject']!r})")
    return hit.group(1)

TypeScript

extract.ts
export type Message = { id: string; subject: string; text: string | null; html: string | null };

const TAGS = /<[^>]+>/g;
const ENTITIES: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ' };

/** Both parts as plain text: tags out, the common entities decoded, whitespace folded. */
export const textOf = (m: Message): string =>
  `${m.text ?? ''}\n${m.html ?? ''}`
    .replace(TAGS, ' ')
    .replace(/&(amp|lt|gt|quot|#39|nbsp);/g, e => ENTITIES[e])
    .replace(/\s+/g, ' ');

/** Anchored on your own wording. A reworded template fails loudly. */
export function codeFrom(m: Message, pattern = /code is\D{0,12}(\d{6})/i): string {
  const hit = textOf(m).match(pattern);
  if (!hit) throw new Error(`no code in message ${m.id} ("${m.subject}")`);
  return hit[1];
}

The message JSON these read comes from GET /api/v1/message/{id}, documented in the API reference; the waiting that gets you the id in the first place is in the end-to-end testing guide, and as ready-made helpers for Playwright, Cypress, Python and Node.js.

Before you call it done

  • The pattern anchored on your template’s wording, and kept beside the template.
  • Both parts searched, as text: tags to spaces, entities decoded, whitespace folded.
  • A filter on subject or sender, so a welcome mail never wins over a code mail.
  • A snapshot before any resend, and only new messages accepted after it.
  • The wait deadline well under the code’s lifetime, and the send triggered right before the wait.
  • A failure message that names the message id and subject it looked in.

That covers every way a six-digit extractor has been seen to pass on the wrong number. An agent reading the same mail has the same problems and one fewer tool for them, which is why the MCP server hands it the whole message rather than a guess — an inbox an AI agent can read goes through that.

Questions

Should I read the text part or the HTML part?

The text part when it exists: it is stable and has nothing to decode. Search both anyway, as the helpers do, so a template that only ships HTML still works and a template that only ships text never trips on empty HTML.

My code has letters in it. Does the pattern change?

Only the character class: ([A-Z0-9]{6}), or whatever alphabet the sender uses, still anchored on the wording before it. Add the i flag if the case is not guaranteed, and be careful that the class does not also match an English word that follows the anchor.

What about magic links instead of codes?

Same discipline, different pattern: match the URL on a path fragment you know — /confirm/, /auth/magic/ — rather than “the first link”, because a transactional email usually carries five links and the one you want is rarely first. Decode &amp; before visiting it.

How long is a message available to read?

5 days after it arrives, whether or not it was read. That is far longer than any code stays valid, so a test never has to hurry the read — only the typing.

Can I get the code without polling the mailbox?

Over REST, no: you poll once a second with a deadline, which is the documented rhythm and is never throttled. Over MCP there is a wait_for_message tool that holds the call open until the message lands, which is the shape an AI agent needs.

Do I need an API key?

No. The public domains take no key, no account and no header. Only the paid pool of domains kept off the disposable-mail blocklists uses a bearer token, and the extraction code is identical either way.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.