docs
v1 Pricing Dashboard
Concepts / extraction

Extraction

Your assertion should read otp.best, not regex an HTML blob. Every message is run through the extractors at receive time, and the results are stored as typed fields — this page explains exactly what they produce and where the heuristics draw their lines.

The extracted object

Extraction happens once, when the message is stored — the same instant your long-poll wakes. Every message detail carries it:

message.extracted
{
  "otp": {
    "best": "482913",
    "candidates": [ { "value": "482913", "score": 9.5 },  ]
  },
  "links": [
    {
      "url": "https://acme.test/verify?t=…",
      "text": "Confirm your email",
      "class": "verify"
    }
  ],
  "auth": {
    "spf":   { "result": "pass", "domain": "acme.test" },
    "dkim":  [ { "result": "pass", "domain": "acme.test", "selector": "s1" } ],
    "dmarc": { "result": "pass", "domain": "acme.test", "policy": "reject" }
  },
  "text": "…"  // only when the email had no plain-text part
}

Two shortcut endpoints skip the full detail: GET /v1/messages/{id}/otp returns just the otp object, GET /v1/messages/{id}/links just the links array. The SDK wait helpers (wait_for_otp, wait_for_link) combine the long-poll and the read into one call.

OTP codes

The OTP extractor looks for 4–8 digit sequences — including split groupings like 613 254 or 4210-9987 (two equal groups joined by one space or hyphen; chains of three or more groups read as phone numbers or dates and are never joined). Each candidate is scored by proximity to keywords like code, verification, OTP, and PIN, by standalone-line placement (real codes tend to sit alone on their own line), and penalized when it looks like a year, a price, a phone number, or a date.

The top candidate becomes otp.best — but only above a confidence threshold. A newsletter full of zip codes and years yields best: null rather than a guess, so your test fails honestly instead of asserting on garbage. Up to five ranked candidates ride along in candidates if you want to apply your own tie-breaking.

Every http(s) URL is collected from both the HTML and plain-text parts, with its anchor text and a class guess: verify, reset, unsubscribe, or other, derived from keywords in the URL path and anchor text. Magic-link and sign-in links classify as verify — a test that says "follow the sign-in link" treats them the same way. The first 100 links are kept; URLs are capped at 2,048 characters.

One naming quirk: the wire field is class, but both SDKs expose it as kindclass is a reserved word in too many languages.

You don't have to fetch the link yourself: POST /v1/messages/{id}/links/follow clicks it server-side (the first verify link by default) and returns the target's status and final URL — see the API reference.

Authentication (SPF / DKIM / DMARC)

Every message is verified at receive time: SPF against the connecting IP and envelope sender (the HELO identity for bounces), each DKIM signature against its published key, and DMARC alignment on the From-header domain. The verdicts land in extracted.auth so your suite can assert that the mail your app sends actually authenticates — before a real inbox provider starts junking it.

Results use the RFC 8601 strings: pass, fail, softfail, neutral, none, temperror, permerror. dkim has one entry per signature — unsigned mail is an empty array, and a signature that doesn't verify reports neutral (RFC 6376 treats it as unsigned) with the reason in error, e.g. a body-hash mismatch. dmarc.result is pass when either aligned mechanism passed; dmarc.policy (none | quarantine | reject) appears only when the domain publishes a record.

Three honesty rules: verification never affects delivery — a hard-fail message still lands in your inbox with its failing verdict, because seeing the failure is the point. A missing auth key means not verified (the message predates this feature), never "failed". And mail your CI runner delivers straight to us will honestly read none across the board — the check earns its keep against your real sending path (SES, SendGrid, Postmark, …) in staging. Both SDKs expose this as a typed auth field on the message. The why and the wiring: Testing SPF, DKIM & DMARC in CI.

Text fallback

HTML-only emails (no text/plain part) get a plain-text rendering of their HTML stored as extracted.text, so string assertions never need to parse markup. When the email has a real text part, this key is absent — use text_body.

SMS

Inbound SMS runs through the same extractors and produces the same shape (minus the text fallback — an SMS body is already text — and minus auth, which only exists for SMTP delivery). GET /v1/sms/{id}/otp and /links answer byte-identically to their email counterparts, so one assertion helper covers both channels.

When extraction misses

The extractors are heuristics, tuned against a corpus of real-world emails — every fix starts by adding the email that fooled them as a test fixture. If a real email extracts the wrong code or misclassifies a link, send it (raw, if you can — the Raw tab has a download) to support@mailfixture.com. Misses become fixtures; fixtures become fixes that stay fixed.

tip Belt and suspenders for critical flows: assert otp.best is non-null and that it appears in text_body. If the heuristic ever degrades on your template, the second assertion catches it loudly.

Next

Messages
Where extracted fields live.
Testing OTP flows
Typed extraction vs. regex, in depth.
Quickstart: SMS
The same extraction over text messages.
Was this page useful? yes no Concepts: Custom domains →
ON THIS PAGE
edit this page ↗
report an issue ↗