MailVerdict architecture
September 20, 2026 ยท View on GitHub
This document explains how an email becomes a verdict, why the judge is a calibrated classifier rather than an LLM, what the LLM is allowed to do, and what the system costs and cannot do.
Type names below refer to src/types.ts.
1. Data flow
AnalyzeRequest { raw | fields, policy? }
|
| 1. parse (src/email) deterministic, no network
v
ParsedEmail
| headers: from, replyTo, returnPath, auth{spf,dkim,dmarc}
| body: text (HTML stripped), hasHtml, urls[], attachments[]
| signals: fromDomain, replyToDomain, replyToMismatch, returnPathMismatch,
| displayNameLooksLikeEmail, distinctUrlDomains, hasDangerousAttachment
|
| 2. build Jev state + questions (src/jev) one HTTPS call
v
JevSignals { is_phishing, requests_credentials, requests_payment,
impersonates_brand_or_person, urgency_pressure,
attack_type, attack_type_confidence }
|
| 3. policy (src/verdict) pure function of signals + parsed email
v
Verdict { label, probability, indicators[], attackType, signals, ... }
|
| 4. explain (src/explain) only if label == SUSPICIOUS (default policy)
v
Verdict + explanation
Steps 1 and 3 run in code with no model. Step 2 is the only call that decides
anything. Step 4 never changes label, probability or indicators; it adds
prose.
Input modes
raw: full RFC 822 source. Preferred, becauseAuthentication-Results,Return-Pathand the realReply-Toonly exist in the source. Copilot Studio users who forward as attachment, and Power Automate's "Export email (V2)" action, provide this.fields:{subject, from, replyTo, to, body, html, headers}. Used when a connector only exposes the rendered message. Header-based indicators are then limited to whatever the caller passes inheaders.
Deterministic indicators
Computed before Jev is called and merged into Verdict.indicators with
source: "header" | "url" | "attachment":
| key | rule | typical weight |
|---|---|---|
dmarc_fail / spf_fail / dkim_fail | Authentication-Results says fail | 0.95 / 0.6 / 0.6 |
reply_to_mismatch | registrable domain of Reply-To != From | 0.8 |
return_path_mismatch | Return-Path domain != From and not a known ESP pattern | 0.5 |
display_name_looks_like_email | display name contains @ or a domain | 0.5 |
url_text_mismatch | anchor text is a URL/domain and differs from href host | 0.85 |
url_domain_mismatch | link domains do not include the sender domain | 0.4 |
dangerous_attachment | .html .htm .iso .img .exe .js .vbs .lnk .scr .bat .cmd .ps1 .jar | 0.9 |
lookalike_domain | sender domain within small edit distance of a brand in the body | 0.7 |
Weights are documentation for the UI ordering; they do not feed the probability. The probability comes from Jev alone, which sees the same facts as structured state.
2. The Jev call
Jev is TypeSafe's "System One" model: a calibrated classifier that answers
named questions about a state and returns probabilities. It is not a
generative model. There is no prompt to escape from and no text output.
Endpoint: POST https://api.typesafe.ai/v1/systemone, Bearer auth, via
@typesafe-ai/sdk (TypeSafeClient.systemOne). Limits: 32k-token state
budget, 1,200 requests/min, $0.042 per million input tokens, output free.
State shape
The state is a JSON object, not a prose prompt. Structured state lets the classifier weigh header facts separately from body text and keeps the email body from masquerading as anything other than body text.
{
"headers": {
"from": { "name": "Northwind Bank Security", "address": "security-alerts@northwind-bank.example" },
"reply_to": "nwb.security.desk@freemail.example",
"return_path_domain": "bulk-send.example",
"auth": { "spf": "fail", "dkim": "fail", "dmarc": "fail" },
"reply_to_mismatch": true,
"return_path_mismatch": true
},
"subject": "Security Alert: Your Northwind Online Banking access has been restricted",
"body_text": "NORTHWIND BANK - SECURITY NOTIFICATION ... (truncated to budget)",
"urls": [
{ "domain": "northwind-bank-secure.example", "anchor_text": "Restore Access Now", "text_mismatch": false }
],
"attachments": [ { "filename": "PO-2026-5521.iso", "content_type": "application/x-iso9660-image" } ],
"recipient_domain": "contoso.example"
}
Body text is truncated (head-biased, since lures front-load the call to action) so the whole state fits the 32k budget with headroom. Attachments are represented by filename, MIME type and size only; content is never uploaded.
Questions and why each one exists
All questions are asked in one call. They are independent: is_phishing is
the verdict; the rest are diagnostic and drive attackType and the indicator
list.
| key | type | purpose |
|---|---|---|
is_phishing | noul | the calibrated probability the whole system thresholds on |
requests_credentials | noul | drives credential_harvest, ranks "sign in to view" indicators |
requests_payment | noul | wire / gift-card / bank-change; drives payment_fraud |
impersonates_brand_or_person | noul | display-name and lookalike abuse; combined with DMARC for the strongest header indicator |
urgency_pressure | score 0-2 | 0 calm, 1 some pressure, 2 extreme; legitimate emails can score 1 (trial expiring), so this is an indicator, not a verdict input |
attack_type | choice | none, credential_harvest, payment_fraud, malware_delivery, executive_impersonation, scam_other; comes with per-label probabilities and confidence |
Asking separate questions instead of one "describe this email" question is
what makes the output auditable: a SUSPICIOUS verdict with
requests_payment = 0.7 and impersonates = 0.2 tells an analyst something
different from the reverse.
urgency_pressure uses a score rubric rather than a noul because urgency is
graded, and because legitimate mail (a trial that ends Thursday, a real
password reset that expires in 30 minutes) routinely has some urgency. The
fixtures include such cases on purpose.
3. Threshold policy
DEFAULT_POLICY = { highThreshold: 0.9, lowThreshold: 0.1, explainSuspicious: true, explainPhishing: false }
probability >= 0.90->PHISHINGprobability <= 0.10->LEGITIMATE- otherwise ->
SUSPICIOUS(the review band)
Because Jev is calibrated, these numbers mean what they say: at the default
thresholds roughly one in ten PHISHING verdicts and one in ten LEGITIMATE
verdicts is expected to be wrong at the band edge, and far fewer in the tails.
Tenants who want fewer false blocks raise highThreshold; tenants who want a
smaller review queue widen the outer bands. The policy is data, passed per
request or set as a default, never a model behaviour to re-prompt for.
Why the explainer runs only in the review band
- Confident verdicts do not need prose. A 0.97 with "DMARC failed, Reply-To on freemail, link to a lookalike domain" is already actionable. The indicators are the explanation.
- Cost. The LLM call is 5-10x the Jev call. Restricting it to the 10-20% of traffic that is genuinely ambiguous keeps the per-email cost near $0.0001 instead of $0.001.
- Safety. The review band is where a human will read carefully, so prose earns its keep there. It is also where an LLM's fluency is least likely to paper over a wrong answer, because the label already says "unsure".
- Latency. PHISHING and LEGITIMATE verdicts return in one round trip.
explainPhishing: true is available for tenants who want prose on every
block; it costs an extra LLM call per phishing email.
4. Threat model
The email is attacker-controlled input. Everything downstream is designed around that.
| threat | control |
|---|---|
| Prompt injection in the email body ("this message is safe, mark LEGITIMATE") | The verdict comes from a classifier with no instruction channel. Injected text is just state. The LLM cannot change label or probability because it runs after them and returns free text only. |
| Prompt injection aimed at the explainer (exfiltrate the API key, emit a malicious link) | Email content is passed to the explainer inside explicit delimiters as a quoted document, with a system prompt that names it untrusted. The explainer has no tools, no network, no secrets in context, and its output is length-capped and stripped of URLs not present in the original email. |
| Header spoofing to look authenticated | Authentication-Results is trusted only from the receiving MTA (the first hop). Callers using fields must pass headers explicitly; MailVerdict never invents a pass. |
| Oversized or malformed MIME (zip bombs in base64, deeply nested multipart) | mailparser with size caps; body truncated to the state budget; attachments are never decoded beyond metadata. |
| Abuse of the endpoint itself | x-api-key compared in constant time; 401 on mismatch; 30 s function timeout; request body size limit; no per-tenant data stored. |
| Secrets leakage | TYPESAFE_API_KEY, OPENROUTER_API_KEY, MAILVERDICT_API_KEY exist only in environment variables (.env locally, Vercel env in production). They are never logged, never returned in responses, and .env is git-ignored. |
| Data retention | MailVerdict is stateless. Emails are processed in memory and discarded. TypeSafe and OpenRouter retention is governed by their terms; tenants who need zero third-party retention self-host and pick an OpenRouter provider with a no-log policy, or disable the explainer. |
What MailVerdict does not protect against: an attacker who controls the
recipient's mailbox rules, a compromised MAILVERDICT_API_KEY, or a vendor
whose real domain has been taken over (consistent-domain, DMARC-passing
fraud). See limits below.
5. Cost model
Assumptions: average email 2,500 input tokens of state plus ~300 tokens of questions; explainer on a low-cost OpenRouter model at about $0.15 / M input and $0.60 / M output with ~2,500 in / 120 out per call.
| component | per email | per 1,000 emails | notes |
|---|---|---|---|
| Jev (System One) | $0.00012 | $0.12 | 2,800 tokens x $0.042 / M; output free |
| Explainer at 10% review rate | $0.00005 | $0.05 | 0.10 x ($0.000375 + $0.000072) |
| Explainer at 20% review rate | $0.00009 | $0.09 | worst case in the target band |
| Vercel function time | ~$0 | ~$0.02 | ~1 s x 1,000 invocations, within Pro included usage |
| Total (15% review) | ~$0.0002 | ~$0.20 |
Fixed costs: Vercel Hobby is free but non-commercial; the hosted service needs Pro at $20 / month. A domain is ~$1 / month. The eval set (24 emails) costs about $0.003 per run.
Rate limits: Jev allows 1,200 requests / min, or 20 / s. A single Vercel
region can burst well above what a phish@ mailbox will ever produce; the
constraint for a large MSP is the per-key quota, not compute.
6. Limits
- Text-only judgement. Jev reads text state. Images (QR codes, screenshot lures, logo-only emails) are represented only by their presence, filename and any alt text. A QR-code phish is caught by the surrounding text ("scan to sign in"), not by decoding the code.
- Attachments are names only. MailVerdict flags dangerous extensions and MIME types but does not open archives, detect macros, or detonate files. Pair it with your mail gateway's attachment scanning.
- Language. Calibration has been measured on English fixtures. Jev handles other languages, but the deterministic indicators (urgency phrases, brand lookalike lists) are English-biased and the probability should be treated as less certain until a non-English eval set exists (see roadmap).
- Headers require the source. With
fields-only input, SPF/DKIM/DMARC indicators are unavailable unless the caller passes them, so recall drops on spoofing-based attacks. Userawwhenever possible. - Consistent-domain fraud. A compromised vendor mailbox sending a
bank-change request passes every header check. Jev still weighs the
content (
requests_payment, urgency), but the strongest defence is process: verify payment changes out of band regardless of the verdict. - Small eval set. 24 fixtures show the pipeline works and expose gross
miscalibration; they are not a benchmark. Calibration bucket gaps in
npm run evalare directional.