Skip to content

The first version of every OTP extractor is a regex. It works on the first three messages, then a template changes the wording, a code moves into the subject line, or an order number grabs the match instead. The bug is not the pattern. The bug is treating code extraction as text matching when it is a scoped parsing problem with structure you can use.

Start with what a code is not

Most false positives come from four sources, and all four appear in ordinary transactional mail:

  • Order and invoice numbers. Long digit strings that look like codes and are not.
  • Dates and timestamps. Split across separators, so a naive \d{4,8} catches fragments.
  • Tracking numbers. Alphanumeric, often 8 to 12 characters, frequently the longest token in the body.
  • Phone and postal data in the footer. Number-dense, structurally unlike a code but textually identical.

A regex tuned against one template encodes that template's layout. The moment the sender reflows the layout, the pattern needs an author.

Use the labels before the digits

The reliable signal is not the digit count, it is the label the sender puts next to the code, because the label is what the human is meant to read. Anchor on the vocabulary — code, OTP, PIN, token, verification — then take the nearest following digit run:

/\b(?:code|otp|pin|token|verification\s+code)\D{0,20}(\d{4,8})\b/i

This is the pattern Inboxmil runs server-side on arrival, with two fallbacks: a bare six-digit run, then a trailing 4-to-8 digit run near the end of the text. It survives reflows because it keys on language, not position. It also degrades predictably — when no label exists, it says so rather than guessing at an order number.

Strip the noise before matching

Raw HTML is the other half of the problem. A code rendered as <span style="letter-spacing:6px">4</span><span>8</span><span>2</span><span>9</span><span>1</span><span>5</span> is one code and six separate text nodes. Matching against the markup finds nothing; matching against the rendered text finds 482915. So the order is: take the multipart text/plain part if present, else strip tags, collapse whitespace, decode entities, then match. Never match markup.

One more: leading zeros. A code of 004821 is a string, not a number. The moment anything parses it as an integer, the code is wrong and the verification fails with no useful error.

TOTP is a different problem entirely

Not every code arrives by mail, and conflating the two creates bad expectations. A TOTP code is computed, not delivered. RFC 6238 defines it: the shared secret plus the current time, divided into a time-step that defaults to 30 seconds, with a start epoch T0 normally zero. The verifier and the prover must use the same time-step, and the standard explicitly allows a small window of adjacent steps because clocks drift.

That means a TOTP code cannot be scraped from an inbox, because it was never in one. If a service presents a QR code or a base32 secret, you are in RFC 6238 territory and the answer is a library and a clock, not a parser. Mixing the two up is how a test harness ends up polling a mailbox for a code that will never arrive.

Where the code should live

Doing extraction at the edge — inside the client — means every consumer reimplements the same fragile patterns. Doing it once on the server, on arrival, means the API can return otp as a first-class field on the message row. Inboxmil's inbox endpoint returns it that way, alongside subject, preview, and received_at, so a client renders the code without any parsing logic of its own.

The takeaway

Treat a verification code as structured data that a sender labels, not as digits to find. Anchor on the label, strip markup first, keep the code as a string, and keep TOTP in a separate lane. The patterns get shorter and the failures get honest.

Keep reading