Pulling a one-time code out of an email is pattern matching over text someone else wrote. The sender picks the wording, the layout and the code format, and the extractor either recognizes the result or does not. This post describes the approach Lumbox's receiving pipeline uses on incoming mail, lists the phrasings it matches and the ones it misses, and ends with a fallback for the misses. It is all regular expressions. No language model reads your mail to find the code, and a BYOK AI key does not change the extraction.

Which text gets scanned

The extractor reads the subject line and the plain-text body, joined together. It does not scan the HTML part separately. For a message sent as HTML only, the MIME parser generates a text version from the HTML, so a code there is still seen. When a message has both parts, only the plain-text part is read, so a code that appears in the HTML but not in the text part is missed. Scanning text also avoids a common HTML problem: templates that put each digit in its own styled cell or span, which a pattern run over raw markup cannot see as one number.

The six patterns

A code is a run of 4 to 8 digits. The patterns, in the order they run:

  1. A keyword, then "code", "PIN", "number" or "token", then "is" or a colon, then the digits: "Your verification code is 847291", "Login code: 482913". The keywords are verification, confirm or confirmation, security, auth or authentication, login, sign in (also sign-in and signin), one-time, OTP, 2FA and MFA.
  2. "Your" or "the", then "code", "PIN", "OTP" or "token", then "is" or a colon: "Your code is 482913".
  3. Enter, use, type, input or submit, then the digits, then "to", "for", "as" or "in": "Use 482913 to complete your login".
  4. The digits, then "is your", then optionally one of a shorter list of words (verification, confirm, security, auth, login, one-time, OTP), then "code", "PIN", "number" or "token": "482913 is your verification code".
  5. The digits alone on their own line.
  6. "Code", "PIN", "OTP" or "token", then a colon or equals sign, then the digits: "Code: 482913".

Matching ignores case. A four-digit result that looks like a year from 1900 through 2099 is thrown away, duplicates are merged, and the survivors land in parsed.otp_codes. The array is ordered by which pattern matched first, not by where the number sits in the message, and the /otp endpoint's code field is simply the first element.

Measured against common phrasings

These results come from running the production patterns and category rules on each line below, with the subject "Your Acme code". In a real email the rest of the message, footer and headers included, also feeds the category, so a full message can come out differently from the line alone.

Line in the emailotp_codescategory
Your verification code is 847291["847291"]verification
847291 is your verification code["847291"]verification
Your login code is 482913["482913"]general
Use 482913 to complete your login["482913"]general
Code: 482913 (expires in 10 minutes)["482913"]general
482913 on its own line["482913"]general
847291 is your one-time password[]verification
482913 is your Acme code[]general
Enter 482913 on the sign-in page[]general
Your code is 482 913[]general
Your code is K7M2QX[]general
Ihr Bestaetigungscode lautet 482913[]general
Order number: then 58213 on its own line["58213"]general
Your code: 3024["3024"]general

The misses group into a few kinds. Codes containing letters are never extracted, and neither are digits split by a space or hyphen, codes shorter than 4 or longer than 8 digits, or wording in languages other than English. The "is your" pattern needs "code" or one of its short-list words right after "your", so a brand name there ("your Acme code") or the word "password" ("your one-time password") breaks the match. "Enter 482913 on the sign-in page" fails because "on" is not one of the words pattern 3 accepts after the digits.

Why the category column matters

The category comes from weighted keyword rules: verification (10), security (9), support (8), calendar (8), transactional (7), notification (5), conversation (4) and newsletter (3), with general when nothing matches. The /otp endpoint only considers emails in the verification category. Six of the eight rows with an extracted code came out as general, and /otp will wait on messages like those until it returns 408. The one-time-password row fails the other way: it counts as a verification email but has no extracted code, so /otp keeps waiting on it too.

GET /v1/inboxes/:id/wait?has_otp=true has no category condition. It takes the newest message received since your since timestamp and returns it once that message has at least one extracted code, which makes it the more predictable choice when you do not control the sender's wording.

False positives

The own-line pattern is the broadest. It turns any 4 to 8 digit number on a line by itself into a code, which covers the code in most templates and also order numbers, ZIP codes and invoice totals formatted that way. The year filter only drops four-digit numbers from 1900 through 2099, so "3024" passes. When otp_codes has more than one entry, the first one is the earliest pattern's match rather than the most likely code, and a test that asserts the array has exactly one element catches that case before the wrong code gets typed.

What else the same pass extracts

The same regex pass fills the rest of the parsed block. verification_links are URLs containing verify, confirm, activate, validate or auth, or a token=, code= or key= parameter with at least 8 characters. magic_links are URLs mentioning a magic link, passwordless or auto-login, or a login URL with a token. backup_codes are read from a block after words like "backup codes" or "recovery codes". code_expiry is set when the text says something like "expires in 10 minutes" or "valid for 2 hours", converted to an absolute time when the email is parsed. confidence starts at 0.5 and adds 0.3 for a code, 0.1 for a verification link and 0.1 for any category other than general.

A fallback for the formats it misses

The /wait response includes text_body, so when otp_codes is empty you can run a pattern written for the sender you care about:

function codeFrom(email) {
  if (email.parsed.otp_codes.length === 1) return email.parsed.otp_codes[0];
  const text = email.text_body ?? "";
  const split = text.match(/\b(\d{3})[ -](\d{3})\b/);
  if (split) return split[1] + split[2];
  const alnum = text.match(/\bcode(?: is)?[:\s]+([A-Z0-9]{6,8})\b/i);
  return alnum ? alnum[1] : null;
}

Against the misses in the table, this returns 482913 for "482 913" and "482-913" and K7M2QX for "Your code is K7M2QX". The API wraps text_body in marker lines that label it as untrusted email content. Those lines contain no digits and not the word "code", so they do not disturb either pattern. Keep a fallback like this narrow: the split-digit pattern would also match a phone number fragment in a footer, which is why it belongs in code aimed at one known sender.

If you write the templates

When the emails come from your own app, and agents or tests will read them, you can make extraction reliable with the template alone. Put the code in the plain-text part, as 4 to 8 digits with no spaces, after the words "Your verification code is", and keep other numbers off lines of their own. That wording matches the first pattern and puts the message in the verification category, so both /otp and /wait?has_otp=true return it.

The email object reference documents every parsed field, the inbox reference covers /otp and /wait, and handling OTPs with headless browsers and agents shows these endpoints inside real flows.