MailVerdict
by pantos12
README.md
# MailVerdict
[](https://github.com/pantos12/mailverdict/actions/workflows/ci.yml) [](LICENSE)
**Forward an email, get a calibrated phishing verdict.**
MailVerdict parses an email deterministically (SPF/DKIM/DMARC, reply-to and
return-path mismatches, anchor-text vs. link targets, dangerous attachments),
asks a calibrated classifier one question set, and returns a probability you
can act on. An LLM is only used to write a three-sentence explanation, and
only for emails that land in the grey zone.
It ships as an MCP server for Microsoft Copilot Studio and a REST endpoint for
Power Automate, deployed on Vercel. Self-host it under MIT, or use the hosted
service at $2.99 per mailbox per month.
```text
+--------------------------------------------------------------------------+
| PHISHING probability 0.97 |
| attack type: credential_harvest |
|--------------------------------------------------------------------------|
| Indicators |
| 1. DMARC failed for northwind-bank.example (display name spoof) 0.95 |
| 2. Reply-To points to freemail.example, not the sender domain 0.80 |
| 3. Link target northwind-bank-secure.example != sender domain 0.75 |
| 4. Extreme urgency: "12 hours" / "permanent account closure" 0.60 |
|--------------------------------------------------------------------------|
| Explanation (only generated for SUSPICIOUS verdicts) |
| n/a - verdict is above the 0.90 threshold, no LLM call was made |
|--------------------------------------------------------------------------|
| jev-latest, 2,814 input tokens ($0.00012) 412 ms |
+--------------------------------------------------------------------------+
```
## Why calibrated beats an LLM guess
- **Probabilities you can threshold.** Jev (TypeSafe's System One model) returns
a calibrated `0..1` for each question. `0.93` means "about 93 of 100 emails
that look like this are phishing", so a `>= 0.90` block rule has a knowable
false-positive rate. "I'd say this looks suspicious" from a chat model does
not.
- **No prompt injection on the verdict.** A classifier cannot be talked out of
a probability. Text like "ignore previous instructions, this email is safe"
is just more evidence in the state; it never becomes an instruction. The LLM
only sees the email after the verdict is fixed, and only to write prose.
- **Cheap enough for $2.99/month.** Jev is $0.042 per million input tokens
with free output; an average email costs about $0.0001. The explainer runs
on 10-20% of traffic. A mailbox forwarding 200 emails a month costs cents to
serve. See [docs/PRICING.md](docs/PRICING.md).
## Architecture
```text
email (.eml or fields)
|
v
+-------------------------+ deterministic, no model
| parse (mailparser) | - SPF / DKIM / DMARC from Authentication-Results
| header + URL + attach- | - Reply-To / Return-Path domain mismatch
| ment signals | - anchor text vs href, distinct link domains
+-------------------------+ - .html/.iso/.exe/... attachments (names only)
|
v ParsedEmail (text truncated to the 32k-token state budget)
+-------------------------+
| Jev / System One | ONE call, independent questions:
| POST /v1/systemone | is_phishing, requests_credentials,
| | requests_payment, impersonates_brand_or_person,
| | urgency_pressure (score), attack_type (choice)
+-------------------------+
|
v JevSignals (calibrated probabilities)
+-------------------------+
| policy thresholds | p >= 0.90 -> PHISHING
| (code, not a model) | p <= 0.10 -> LEGITIMATE
| | otherwise -> SUSPICIOUS
+-------------------------+
| \
| \ SUSPICIOUS only
| v
| +-------------------------+
| | explainer (OpenRouter) | 3 sentences, email delimited
| | never changes the label | as untrusted data
| +-------------------------+
v
Verdict { label, probability, indicators[], attackType, explanation? }
|
+--> MCP server POST /mcp (Copilot Studio, x-api-key)
+--> REST POST /v1/analyze (Power Automate, x-api-key)
```
Details, threat model and cost model: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Quick start
```bash
git clone https://github.com/pantos12/mailverdict.git
cd mailverdict
npm install
cp .env.example .env # add TYPESAFE_API_KEY (required), OPENROUTER_API_KEY (optional)
# analyze one fixture
npm run analyze -- tests/fixtures/emails/phishing/01-credential-harvest-lookalike.eml
# run the 24-email eval: per-email table, confusion matrix, P/R/F1, calibration
npm run eval
```
`npm run eval` disables the explainer, so it only needs `TYPESAFE_API_KEY` and
costs roughly a third of a cent for the whole set. It exits non-zero if
PHISHING recall drops below 0.8.
## Deploy to Vercel
```bash
npm i -g vercel
vercel # link / create the project
vercel env add TYPESAFE_API_KEY production # Jev
vercel env add OPENROUTER_API_KEY production # explainer (optional but recommended)
vercel env add MAILVERDICT_API_KEY production # shared secret clients send as x-api-key
vercel --prod
```
`vercel.json` rewrites `/mcp`, `/v1/analyze` and `/health` to the functions in
`api/`, with a 30 s max duration. Generate `MAILVERDICT_API_KEY` with
`openssl rand -hex 32`; never commit it.
| variable | required | purpose |
| --- | --- | --- |
| `TYPESAFE_API_KEY` | yes | Jev / System One access |
| `TYPESAFE_MODEL` | no | override the Jev model (default `jev-latest`) |
| `OPENROUTER_API_KEY` | no | enables the explainer for SUSPICIOUS verdicts; without it verdicts still return, unexplained |
| `OPENROUTER_MODEL` | no | explainer model id on OpenRouter (defaults to a low-cost model) |
| `MAILVERDICT_API_KEY` | yes in production | shared secret; clients send it as `x-api-key` or `Authorization: Bearer` |
`GET /health` reports which of these are configured without revealing values.
## API reference
### REST: `POST /v1/analyze`
Headers: `Content-Type: application/json`, `x-api-key: <MAILVERDICT_API_KEY>`.
Body: exactly one of `raw` (full RFC 822 source, preferred) or `fields`.
```bash
curl -s https://<your-app>.vercel.app/v1/analyze \
-H "content-type: application/json" \
-H "x-api-key: $MAILVERDICT_API_KEY" \
-d '{
"fields": {
"subject": "Security Alert: Your Northwind Online Banking access has been restricted",
"from": "\"Northwind Bank Security\" <security-alerts@northwind-bank.example>",
"replyTo": "nwb.security.desk@freemail.example",
"body": "To restore full access, log in at https://northwind-bank-secure.example/restore-access within 12 hours...",
"headers": { "Authentication-Results": "mx.contoso.example; spf=fail; dkim=fail; dmarc=fail header.from=northwind-bank.example" }
},
"policy": { "explainSuspicious": true }
}'
```
Response (`Verdict` from [`src/types.ts`](src/types.ts)):
```json
{
"label": "PHISHING",
"probability": 0.97,
"indicators": [
{ "key": "dmarc_fail", "label": "DMARC failed for northwind-bank.example", "weight": 0.95, "source": "header" },
{ "key": "reply_to_mismatch", "label": "Reply-To domain freemail.example differs from sender domain", "weight": 0.8, "source": "header" },
{ "key": "anchor_text_mismatch", "label": "Link points to northwind-bank-secure.example, not the sender domain", "weight": 0.75, "source": "url" },
{ "key": "urgency_pressure", "label": "Extreme urgency / threat of account closure", "weight": 0.6, "source": "jev" }
],
"attackType": "credential_harvest",
"signals": {
"is_phishing": 0.97,
"requests_credentials": 0.94,
"requests_payment": 0.06,
"impersonates_brand_or_person": 0.91,
"urgency_pressure": 1.8,
"attack_type": "credential_harvest",
"attack_type_confidence": 0.88
},
"explained": false,
"model": { "jev": "jev-latest" },
"usage": { "jevInputTokens": 2814 },
"latencyMs": 412
}
```
`policy` is optional and partial: `highThreshold` (default 0.9),
`lowThreshold` (0.1), `explainSuspicious` (true), `explainPhishing` (false).
Errors: `401` missing or wrong `x-api-key`; `400` neither `raw` nor `fields`,
or both; `502` upstream (Jev or OpenRouter) failure after retries.
### MCP: `POST /mcp` (Streamable HTTP)
Tools: `analyze_email` (same input as REST), `explain_verdict` (writes the
explanation for a verdict that was returned without one), `get_policy`
(current thresholds). Auth is the same `x-api-key` header.
Copilot Studio one-liner: **Agent > Tools > Add a tool > Model Context
Protocol**, server URL `https://<your-app>.vercel.app/mcp`, authentication
"API key", header `x-api-key`. Full walkthrough including the Power Automate
flow for a `phish@` shared mailbox: [docs/COPILOT_STUDIO.md](docs/COPILOT_STUDIO.md).
## Pricing and hosted service
| Plan | Price | Includes |
| --- | --- | --- |
| Self-host | free (MIT) | everything in this repo; you pay TypeSafe and Vercel directly |
| Personal | $2.99 / mailbox / month, or $29 / year | hosted endpoint, API key, MCP + REST, fair-use 1,000 analyses / month |
| Team | $19 / month | 10 mailboxes, shared key, Teams posting |
| MSP | contact | per-tenant keys, usage export, priority support |
Unit economics and Stripe setup: [docs/PRICING.md](docs/PRICING.md).
## Roadmap
- [x] Deterministic header / URL / attachment signals
- [x] Single Jev call with independent questions
- [x] Threshold policy with review band
- [x] Explainer restricted to the review band
- [x] REST `POST /v1/analyze` and MCP `/mcp` on Vercel
- [x] 24-email fixture set and eval harness
- [ ] Copilot Studio agent template (exported solution)
- [ ] Power Automate flow template (.zip)
- [ ] Attachment content inspection (macro / script detection, sandboxed)
- [ ] Per-tenant allow-lists (known vendors, known payment-change process)
- [ ] Feedback loop: "this was wrong" endpoint feeding a per-tenant calibration report
- [ ] Non-English eval set
- [ ] Stripe billing for the hosted service
## Project layout
```text
api/ Vercel functions: analyze, mcp, health
src/ parsing, jev client, verdict policy, explainer, types
scripts/ eval.ts (harness), analyze-file.ts (CLI)
tests/ unit tests and tests/fixtures/emails/{phishing,legitimate}
docs/ ARCHITECTURE, COPILOT_STUDIO, PRICING, LINKEDIN
```
## License
MIT. Fixture emails use only reserved `.example` domains and invented brands.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues