diff --git a/.changeset/wide-moons-shave.md b/.changeset/wide-moons-shave.md
new file mode 100644
index 0000000..45dbad8
--- /dev/null
+++ b/.changeset/wide-moons-shave.md
@@ -0,0 +1,57 @@
+---
+"mailgrade": major
+---
+
+Rework the public interface around the calls people actually make, put a
+letter on the grade, and ship the grade as a CLI.
+
+**`npx mailgrade acme.com`.** The package now has a bin: the grade in a
+terminal (letter, verdict, per-mechanism headlines, and the fixes) with
+`--json` for scripts and `--strict` for CI, where a non-zero exit catches the
+day someone "temporarily" weakens the DMARC record. Zero dependencies, like
+everything else here.
+
+**A letter on the grade.** `DomainGrade` now carries `letter`, `"A+"` down to
+`"F"`. The verdict sets the band (enforcing, partial, nothing) and the details
+move within it; `+all` is an automatic `F`. The rubric lives in the spec
+corpus (`spec/grade.json`), so ports share it.
+
+**One import.** `gradeDomain`, `verifyMessage` and `analyzeHeaders` are now on
+`mailgrade` itself, so the common cases need no second import and no guessing
+at a subpath. Every existing entry point still resolves; `mailgrade/doh` and
+`mailgrade/verify` are now for narrowing a bundle, not for finding the API.
+
+**No setup.** `resolver` is optional everywhere it was required, defaulting to
+DNS over HTTPS. `verifyMessage(raw, { ip, sender })` works on its own, the way
+`checkDomain` already did. A Node service that wants the system resolver still
+passes `nodeResolver()` from `mailgrade/node-dns`.
+
+**Headers you can use.** `verifyMessage` takes `mta` and returns `authResults`
+and `headers`: a folded, CRLF-terminated `Received-SPF` and
+`Authentication-Results` block, ready to prepend to the message. The values
+embedded there arrive off the wire, so they are sanitised on the way in: a
+CRLF in a hostile MAIL FROM becomes a space, never a forged header field. The
+folder is quote-aware, so a legal `"a;b"@example.com` survives intact.
+
+`DmarcAnalysis` also gains `reporting`: whether the record carries a `rua=`
+tag, read with the same tag parsing as everything else.
+
+**Names that match the package.** The headline call is `gradeDomain`, not
+`checkDomain`: this library grades, and the README section has always been
+called "Grade a domain". The pure function that used to hold that name is now
+`gradeRecords`, which is what it always took; `gradeDomain(records)` named a
+domain and accepted `DomainRecords`. The trio now reads in order:
+`resolveDomain` -> `gradeRecords`, and `gradeDomain` for both at once.
+
+Breaking: the JSON conformance corpus is no longer published in the tarball,
+and the `mailgrade/spec/*` subpath is gone with it. The corpus stays in the
+repository, which is where a port reads it from.
+
+Breaking: the root barrel is now a curated list rather than `export *`, so
+roughly 25 internals no longer appear on `mailgrade`. Each is still exported
+from the entry point for its own area, so `blankCheck`, `editDistance`,
+`parseHeaders` and `stripComments` move to `mailgrade/headers`, `txtChunks`
+and `recordTags` to `mailgrade/dmarc`, and so on. `toAuthResults` keeps its
+signature and now accepts any object with the five fields it reads. The
+options type for `gradeDomain` and `resolveDomain` is now named
+`GradeDomainOptions` (was `DohOptions`).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f78166a..0a0cbcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@
- [`e576838`](https://github.com/FallaxIO/mailgrade/commit/e576838dc1c305406f755bb4b8cb8aa3d58e2b02) Thanks [@IgnaceMaes](https://github.com/IgnaceMaes)! - First stable release.
- No API changes from 0.2.0 — this marks the surface as settled and brings it
+ No API changes from 0.2.0; this marks the surface as settled and brings it
under semver: the grading, verification, DMARC record and header-analysis
entry points will not break without a major bump.
@@ -21,17 +21,17 @@
let a caller supply the real Public Suffix List.
The built-in suffix rules were a fixed list of 42 two-label pairs. A pair not
- on it — `gov.uk`, `ac.jp`, `org.cn`, `com.ec` — collapsed every host under it
+ on it (`gov.uk`, `ac.jp`, `org.cn`, `com.ec`) collapsed every host under it
to the pair itself, so `dept.gov.uk` and `attacker.gov.uk` shared an
organizational domain and relaxed alignment reported them as aligned. In
`mailgrade/verify` that is a DMARC pass for a signature the From domain's
owner never made.
- The suffix rules now treat the registry's own labels (`co`, `com`, `gov`,
- `ac`, `org`, `ne`, `sch`, …) as part of the suffix under any country code,
- rather than only for enumerated pairs. The remaining approximation errs
- toward reading a host as more specific than it is, which under-reports
- alignment instead of over-reporting it.
+ `ac`, `org`, `ne`, `sch`, and so on) as part of the suffix under any
+ country code, rather than only for enumerated pairs. The remaining
+ approximation errs toward reading a host as more specific than it is,
+ which under-reports alignment instead of over-reporting it.
- `registrableDomain`, `domainLabel` and `aligns` take an optional
`{ publicSuffixes }`, and `verifyDmarc` and `verifyMessage` accept and
forward it. Supplying a list replaces the built-in rules entirely: the
diff --git a/README.md b/README.md
index f539cc6..9834e49 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-
+
@@ -12,51 +12,59 @@
- Grade and verify SPF, DKIM and DMARC.
+ Grade and verify SPF, DKIM and DMARC. One call, no setup.
Zero dependencies. Runs wherever fetch and WebCrypto do: Node, Bun, Deno, Cloudflare Workers, browsers.
-
```sh
-npm i mailgrade
+npm i mailgrade # the library
+npx mailgrade acme.com # the grade, right now
+```
+
+## In the terminal
+
+```
+$ npx mailgrade acme.com
+
+ acme.com
+
+ D spoofable
+ mail claiming to be this domain can reach inboxes
+
+ ✓ SPF SPF ends in -all
+ ✓ DKIM DKIM keys published (google)
+ ✗ DMARC DMARC is monitoring only (p=none)
+ · MX Google Workspace
+
+ → Graduate DMARC from p=none. Monitoring mode was designed as a
+ transition, not a destination: once the reports show your real senders
+ passing, move to p=quarantine, then p=reject.
```
+`--json` prints the whole grade for scripts, and `--strict` exits non-zero
+unless the verdict is `protected`, so one line of CI catches the day someone
+"temporarily" weakens the DMARC record.
+
## Grade a domain
Is this domain spoofable, and what is the fix?
```ts
-import { checkDomain } from "mailgrade/doh";
+import { gradeDomain } from "mailgrade";
-const grade = await checkDomain("acme.com");
+const grade = await gradeDomain("acme.com");
+grade.letter; // "D", from "A+" down to "F"
grade.verdict; // "spoofable" | "partial" | "protected"
grade.dmarc.headline; // "DMARC is monitoring only (p=none)"
grade.recommendations[0]; // { id: "graduate-dmarc", text: "Graduate DMARC from p=none. ..." }
```
-DNS goes over DNS-over-HTTPS by default, against Cloudflare. Swap it:
-
-| To resolve with | Pass |
-| --- | --- |
-| Another DoH provider | `{ endpoint }` |
-| The system resolver | `{ resolver: nodeResolver() }` from `mailgrade/node-dns` |
-| Anything else | `{ resolver }`, any `(name, type) => Promise` |
-| Nothing at all | `gradeDomain({ domain, txt, dmarc, ... })`, records you already hold |
-
-> [!NOTE]
-> One `checkDomain` is about 21 lookups: three records, plus a probe per DKIM
-> selector. Public DoH endpoints publish no rate limit and throttle bulk
-> traffic from one IP at their discretion, so for volume trim `selectors`,
-> lower `concurrency`, or resolve through `mailgrade/node-dns`. An HTTP 429
-> arrives as `DnsError`; backoff is yours to add.
+The letter is the verdict as a report card. DMARC policy sets the band
+(enforcing, partial, or nothing) and the details move within it, while an SPF
+`+all` is an automatic `F`, because it authorises the spoof by name.
+`gradeDomain` is `resolveDomain` and `gradeRecords` composed, and both are
+exported: resolve once and grade later, or grade records you already hold.
## Verify a message
@@ -64,47 +72,71 @@ For when you are the receiver: a Workers email handler, an inbound webhook, an
SMTP server.
```ts
-import { verifyMessage, toAuthResults } from "mailgrade/verify";
-import { dohResolver } from "mailgrade/doh";
+import { verifyMessage } from "mailgrade";
const result = await verifyMessage(rawMessage, {
- resolver: dohResolver(),
ip: "203.0.113.50", // who connected
sender: "bounce@example.com", // MAIL FROM
+ helo: "mail.example.com", // HELO/EHLO
+ mta: "mx.acme.com", // you, the authserv-id in the headers below
});
result.dmarc?.result; // "pass" | "fail" | "none"
-result.dmarc?.disposition; // "none" | "quarantine" | "reject"
+result.dmarc?.disposition; // what to do with it, once the policy is applied:
+ // "none" | "quarantine" | "reject"
+```
+
+`result.headers` is the block to prepend before handing the message on,
+folded and CRLF-terminated:
-toAuthResults(result, "mx.acme.com");
-// "mx.acme.com; spf=pass smtp.mailfrom=bounce@example.com; dkim=pass header.d=..."
+```
+Received-SPF: pass (mx.acme.com: domain of bounce@example.com designates
+ 203.0.113.50 as permitted sender) client-ip=203.0.113.50;
+ envelope-from="bounce@example.com"; helo=mail.example.com
+Authentication-Results: mx.acme.com;
+ spf=pass smtp.mailfrom=bounce@example.com;
+ dkim=pass header.d=example.com header.s=mail;
+ dmarc=pass header.from=example.com
```
SPF is evaluated against the connecting IP (RFC 7208, macros and lookup limits
included), DKIM signatures are verified with WebCrypto (rsa-sha256 and
ed25519-sha256; rsa-sha1 and sub-1024-bit keys refused per RFC 8301), and DMARC
-alignment ties them to the From domain. `verifySpf`, `verifyDkim` and
-`verifyDmarc` are also standalone calls. A DNS failure is always a `temperror`,
-never a `fail`. In tests, `staticResolver({ "example.com": { TXT: [...] } })`
-evaluates against a plain object.
+alignment ties them to the From domain. A DNS failure is always a `temperror`,
+never a `fail`. `verifySpf`, `verifyDkim` and `verifyDmarc` are also standalone
+calls.
> [!IMPORTANT]
-> Where a verdict decides whether mail is delivered, pass `{ publicSuffixes }`
-> too. The built-in list is an approximation, and it under-reports alignment
-> rather than over-reporting it. [Why](#alignment-and-the-public-suffix-list).
+> Alignment needs a public suffix list, and the built-in rules are an
+> approximation that under-reports alignment rather than over-reporting it.
+> Where a verdict decides whether mail is delivered, pass `{ publicSuffixes }`,
+> the ICANN section of the real list, to `verifyMessage`, `verifyDmarc`,
+> `registrableDomain` or `aligns`.
-## Triage pasted headers
+## Triage a header block
-For "is this email real": no cryptography, no I/O. A pasted header block
-carries private data and never leaves the process.
+For "is this email real": the headers off a reported message, no cryptography,
+no I/O, nothing leaving the process.
```ts
-import { analyzeHeaders } from "mailgrade/headers";
-
-const result = analyzeHeaders(paste);
+import { analyzeHeaders } from "mailgrade";
+
+const headers = `Received: from vps.hosting.example (185.22.11.9) by mx.acme.example;
+ Mon, 2 Jun 2025 03:41:02 +0000
+Authentication-Results: mx.acme.example;
+ spf=pass smtp.mailfrom=billing@acme-invoices.example;
+ dkim=none;
+ dmarc=fail header.from=acme.example
+Return-Path:
+From: Acme Finance
+Reply-To: finance.acme@mail-secure.example
+Subject: Overdue invoice
+X-PHP-Originating-Script: 0:sendmail.php`;
+
+const result = analyzeHeaders(headers);
result.verdict; // "suspicious" | "inconclusive" | "authentic"
-result.spf.aligned; // false, the pass was for somebody else's domain
+result.spf.aligned; // false, the SPF pass was for acme-invoices.example
result.flags.map((f) => f.id);
// ["dmarc-fail", "reply-to-mismatch", "return-path-mismatch", "php-origin"]
```
@@ -116,7 +148,7 @@ redirected replies.
## DMARC records
```ts
-import { buildDmarcRecord, reviewDmarc, rolloutPlan } from "mailgrade/dmarc";
+import { buildDmarcRecord, reviewDmarc, rolloutPlan } from "mailgrade";
const options = {
domain: "acme.com", policy: "reject", subdomainPolicy: "none",
@@ -129,83 +161,44 @@ reviewDmarc(options)[0]; // { id: "sp-weaker", severity: "high", ... }
rolloutPlan(options); // four staged records, monitoring to full reject
```
-## Compared
-
-| | `mailgrade` | [`mailauth`](https://github.com/postalsys/mailauth) | [`spf-check`](https://www.npmjs.com/package/spf-check) | [`dkim`](https://www.npmjs.com/package/dkim) |
-| --- | :-: | :-: | :-: | :-: |
-| Grade a domain, with fixes | ✓ | ✗ | ✗ | ✗ |
-| SPF, DKIM and DMARC verification | ✓ | ✓ | SPF only | DKIM only |
-| Analyze pasted headers | ✓ | ✗ | ✗ | ✗ |
-| DMARC record tooling | ✓ | ✗ | ✗ | ✗ |
-| ARC and BIMI | ✗ | ✓ | ✗ | ✗ |
-| DNS transport | DoH or `node:dns` | `node:dns` | `node:dns` | `node:dns` |
-| Workers / browsers / Deno | ✓ | ✗ | ✗ | ✗ |
-| Runtime dependencies | **0** | 10 | 4 | 2 |
-| Install size | **0.4 MB** | 15 MB | 7 MB | 1 MB |
-
-
-Full matrix, and when to use something else
-
-
-
-| | `mailgrade` | `mailauth` | `spf-check` | `dkim` |
-| --- | :-: | :-: | :-: | :-: |
-| SPF evaluation (RFC 7208) | ✓ | ✓ | ✓ | ✗ |
-| DKIM verification | ✓ | ✓ | ✗ | ✓ |
-| DMARC discovery and alignment | ✓ | ✓ | ✗ | ✗ |
-| Authentication-Results output | ✓ | ✓ | ✗ | ✗ |
-| SPF lookup limits enforced | 10 + 2 void | 10 + 2 void | 10 | n/a |
-| Bring-your-own resolver | ✓ | ✓ | ✗ | ✗ |
-| Written in TypeScript | ✓ | ✗ | ✗ | ✗ |
-| Types | generated | hand-written | ✗ | hand-written |
-| Published format | ESM + CJS | CJS | CJS | CJS |
-| Last published | current | current | 2019 | 2022 |
-
-Sizes are `npm install` into an empty project, measured 2026-08.
-
-**Use [`mailauth`](https://github.com/postalsys/mailauth)** for a high-volume
-Node MTA, or when you need ARC or BIMI. No ARC has a consequence worth stating
-plainly: mail that arrived through a forwarder or a mailing list has usually
-had its SPF broken and its body rewritten, so DMARC fails here exactly as it
-does at any receiver that ignores ARC. Honest for a grader, a real gap for an
-inbox.
-
-**Use [`parsedmarc`](https://github.com/domainaware/parsedmarc)** to parse
-DMARC aggregate reports. That is not this library's job.
-
-
+## Where DNS comes from
-## Entry points
+Every call that needs DNS takes an optional `resolver`, and defaults to
+DNS-over-HTTPS against Cloudflare so that nothing needs configuring. Swap it:
-| Import | What is in it |
+| To resolve with | Pass |
| --- | --- |
-| `mailgrade` | everything below except `doh` and `node-dns` |
-| `mailgrade/verify` | `verifyMessage`, `verifySpf`, `verifyDkim`, `verifyDmarc`, `toAuthResults`, `staticResolver` |
-| `mailgrade/headers` | `analyzeHeaders` and its parsers |
-| `mailgrade/dmarc` | build, parse, review and roll out a record |
-| `mailgrade/spf` | `analyzeSpf`, `isSpfRecord` |
-| `mailgrade/dkim` | `analyzeDkim`, `isDkimKey`, `dkimHost`, `DKIM_SELECTORS` |
-| `mailgrade/domain` | `registrableDomain`, `aligns`, `coerceDomain` |
-| `mailgrade/doh` | `checkDomain`, `resolveDomain`, `dohResolver`. The only code that opens a socket |
-| `mailgrade/node-dns` | `nodeResolver`. The only code that imports a Node built-in |
-
-## Notes that matter
+| The system resolver | `{ resolver: nodeResolver() }` from `mailgrade/node-dns` |
+| Another DoH provider | `{ resolver: dohResolver({ endpoint }) }` |
+| A plain object, in tests | `{ resolver: staticResolver({ "example.com": { TXT: [...] } }) }` |
+| Anything else | `{ resolver }`, any `(name, type) => Promise` |
+| Nothing at all | `gradeRecords({ domain, txt, dmarc, ... })`, records you already hold |
+
+A long-running Node service usually wants `nodeResolver()`: it keeps lookups
+inside the network you already trust.
+
+## Entry points
+
+`mailgrade` carries the whole documented API, so a single import is the normal
+way in. The subpaths (`/verify`, `/headers`, `/dmarc`, `/spf`, `/dkim`,
+`/domain`, `/doh`) narrow what a bundle pulls in and reach the helpers each
+area is built from. Only `mailgrade/node-dns` has to stay separate: it is the
+one file that touches `node:dns`, so nothing else can drag a Node built-in
+into a Worker or a browser bundle.
+
+## Notes
- **Ids are the contract.** Every finding carries a stable `id` next to its
English. Branch on ids; swap or translate the prose freely.
- **The rules are portable.** They live as a language-neutral JSON corpus in
- [`spec/`](./spec). A port passes the same files; the TypeScript adapter is
- about 150 lines.
-
-
Alignment and the public suffix list
-
-Alignment needs a public suffix list, and the built-in rules are an
-approximation: known two-label suffixes, plus the registry's own labels (`co`,
-`gov`, `ac`, `org`) under any country code. They err toward reading a host as
-*more* specific than it is, so alignment is under-reported rather than
-over-reported. Pass `{ publicSuffixes }`, the ICANN section of the real list,
-to `verifyMessage`, `verifyDmarc`, `registrableDomain` or `aligns` wherever a
-wrong answer costs something.
+ [`spec/`](https://github.com/FallaxIO/mailgrade/tree/main/spec) in the
+ repository. A port passes the same files; the TypeScript adapter is about
+ 150 lines.
+- **No ARC.** Mail that arrived through a forwarder or a mailing list has
+ usually had its SPF broken and its body rewritten, so DMARC fails here
+ exactly as it does at any receiver that ignores ARC. For ARC or BIMI, or for
+ a high-volume Node MTA, use
+ [`mailauth`](https://github.com/postalsys/mailauth).
## About
diff --git a/package.json b/package.json
index 29545a6..d6223a3 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "mailgrade",
"version": "1.0.0",
- "description": "Grade a domain's SPF, DMARC and DKIM. Zero dependencies, no network, runs anywhere.",
+ "description": "Grade and verify SPF, DMARC and DKIM. One call or `npx mailgrade `, no setup, zero dependencies, runs anywhere.",
"keywords": [
"dmarc",
"spf",
@@ -12,11 +12,15 @@
"phishing",
"dns",
"deliverability",
- "email-headers"
+ "email-headers",
+ "cli"
],
"license": "MIT",
- "author": "Fallax",
- "homepage": "https://github.com/FallaxIO/mailgrade#readme",
+ "author": {
+ "name": "Fallax",
+ "url": "https://fallax.io"
+ },
+ "homepage": "https://fallax.io/mailgrade",
"repository": {
"type": "git",
"url": "git+https://github.com/FallaxIO/mailgrade.git"
@@ -28,9 +32,11 @@
"node": ">=20"
},
"files": [
- "dist",
- "spec"
+ "dist"
],
+ "bin": {
+ "mailgrade": "./dist/cli.js"
+ },
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -125,7 +131,6 @@
"default": "./dist/node-dns.cjs"
}
},
- "./spec/*": "./spec/*",
"./package.json": "./package.json"
},
"scripts": {
diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs
index fb35f19..9f6ab0d 100644
--- a/scripts/smoke.mjs
+++ b/scripts/smoke.mjs
@@ -1,7 +1,7 @@
// Loads the built package the way a consumer would, on whichever runtime runs
// this file. The zero-dependency claim is only worth something if the output
// actually imports outside Node.
-import { gradeDomain } from "../dist/index.js";
+import { gradeRecords } from "../dist/index.js";
import { analyzeHeaders } from "../dist/headers/index.js";
import { buildDmarcRecord, DEFAULT_OPTIONS } from "../dist/dmarc/index.js";
import { resolveDomain } from "../dist/doh.js";
@@ -13,8 +13,8 @@ function check(label, actual, expected) {
}
check(
- "gradeDomain",
- gradeDomain({
+ "gradeRecords",
+ gradeRecords({
domain: "acme.com",
txt: ["v=spf1 -all"],
dmarc: ["v=DMARC1; p=reject; rua=mailto:d@acme.com"],
diff --git a/spec/README.md b/spec/README.md
index 420ee1e..a495514 100644
--- a/spec/README.md
+++ b/spec/README.md
@@ -53,7 +53,7 @@ finished when all of them pass.
| `dkim.json` | DKIM key detection and grading |
| `dmarc-lookup.json` | grading a published DMARC record |
| `dmarc-record.json` | building, parsing, reviewing and rolling out a record |
-| `grade.json` | the whole-domain verdict and its recommendations |
+| `grade.json` | the whole-domain verdict, its letter grade and its recommendations |
| `spf-eval.json` | RFC 7208 evaluation: mechanisms, macros, limits, exp= |
| `headers.json` | header parsing, authentication results, impersonation |
diff --git a/spec/grade.json b/spec/grade.json
index a47d362..358e249 100644
--- a/spec/grade.json
+++ b/spec/grade.json
@@ -1,7 +1,7 @@
{
"cases": [
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "calls a fully locked-down domain protected",
"input": {
"domain": "acme.com",
@@ -20,6 +20,7 @@
},
"expect": {
"verdict": "protected",
+ "letter": "A+",
"spfId": "spf-hardfail",
"dmarcId": "dmarc-enforcing",
"dkimId": "dkim-published",
@@ -30,7 +31,7 @@
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "calls a domain with nothing published spoofable, and tells it to lock the parking",
"input": {
"domain": "parked.example",
@@ -41,6 +42,7 @@
},
"expect": {
"verdict": "spoofable",
+ "letter": "F",
"spfId": "spf-missing",
"dmarcId": "dmarc-missing",
"dkimId": "dkim-absent",
@@ -52,7 +54,7 @@
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "says a parked domain that is already locked has nothing left to do",
"input": {
"domain": "parked.example",
@@ -67,13 +69,14 @@
},
"expect": {
"verdict": "protected",
+ "letter": "A+",
"recommendationIds": [
"nothing-to-add"
]
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "lets +all defeat an enforcing DMARC policy",
"input": {
"domain": "acme.com",
@@ -90,6 +93,7 @@
},
"expect": {
"verdict": "spoofable",
+ "letter": "F",
"spfId": "spf-allow-all",
"dmarcId": "dmarc-enforcing",
"provider": "Microsoft 365",
@@ -100,7 +104,7 @@
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "calls monitoring-only spoofable however good the SPF is",
"input": {
"domain": "acme.com",
@@ -119,6 +123,7 @@
},
"expect": {
"verdict": "spoofable",
+ "letter": "D",
"dmarcId": "dmarc-monitor",
"recommendationIds": [
"graduate-dmarc"
@@ -126,7 +131,7 @@
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "calls quarantine partial",
"input": {
"domain": "acme.com",
@@ -145,6 +150,7 @@
},
"expect": {
"verdict": "partial",
+ "letter": "C",
"dmarcId": "dmarc-quarantine",
"recommendationIds": [
"tighten-dmarc"
@@ -152,7 +158,7 @@
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "tells a sending domain with no DMARC to publish one",
"input": {
"domain": "acme.com",
@@ -167,6 +173,7 @@
},
"expect": {
"verdict": "spoofable",
+ "letter": "D",
"spfId": "spf-softfail",
"recommendationIds": [
"enable-dkim",
@@ -175,7 +182,7 @@
}
},
{
- "target": "gradeDomain",
+ "target": "gradeRecords",
"name": "does not tell a domain with no record to fix its record",
"input": {
"domain": "acme.com",
@@ -192,10 +199,86 @@
},
"expect": {
"verdict": "protected",
+ "letter": "B",
"recommendationIds": [
"publish-spf"
]
}
+ },
+ {
+ "target": "gradeRecords",
+ "name": "gives softfail SPF behind full enforcement an A, not an A+",
+ "input": {
+ "domain": "acme.com",
+ "txt": [
+ "v=spf1 include:_spf.google.com ~all"
+ ],
+ "dmarc": [
+ "v=DMARC1; p=reject; rua=mailto:dmarc@acme.com"
+ ],
+ "dkimSelectors": [
+ "google"
+ ],
+ "mx": [
+ "aspmx.l.google.com"
+ ]
+ },
+ "expect": {
+ "verdict": "protected",
+ "letter": "A",
+ "spfId": "spf-softfail",
+ "dmarcId": "dmarc-enforcing"
+ }
+ },
+ {
+ "target": "gradeRecords",
+ "name": "withholds the A+ when nobody is reading the reports",
+ "input": {
+ "domain": "acme.com",
+ "txt": [
+ "v=spf1 include:_spf.google.com -all"
+ ],
+ "dmarc": [
+ "v=DMARC1; p=reject"
+ ],
+ "dkimSelectors": [
+ "google"
+ ],
+ "mx": [
+ "aspmx.l.google.com"
+ ]
+ },
+ "expect": {
+ "verdict": "protected",
+ "letter": "A",
+ "spfId": "spf-hardfail",
+ "dmarcId": "dmarc-enforcing"
+ }
+ },
+ {
+ "target": "gradeRecords",
+ "name": "reads a null MX as a domain that takes no mail, and grades the lockdown",
+ "input": {
+ "domain": "parked.example",
+ "txt": [
+ "v=spf1 -all"
+ ],
+ "dmarc": [
+ "v=DMARC1; p=reject; rua=mailto:d@parked.example"
+ ],
+ "dkimSelectors": [],
+ "mx": [
+ "."
+ ]
+ },
+ "expect": {
+ "verdict": "protected",
+ "letter": "A+",
+ "provider": null,
+ "recommendationIds": [
+ "nothing-to-add"
+ ]
+ }
}
]
}
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 0000000..c968f33
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+/**
+ * The bin. All logic lives in `./cli/run.ts`; this file is the one place the
+ * CLI reads `process`, and it reads only globals, so the built output still
+ * runs on any runtime with a Node-compatible `process` (Node, Bun, Deno).
+ */
+
+import { runCli } from "./cli/run.ts";
+
+void runCli(process.argv.slice(2), {
+ write: (text) => void process.stdout.write(text),
+ writeError: (text) => void process.stderr.write(text),
+ columns: process.stdout.columns,
+ color:
+ process.env["NO_COLOR"] === undefined &&
+ process.env["TERM"] !== "dumb" &&
+ process.stdout.isTTY === true,
+}).then((code) => {
+ if (code !== 0) process.exitCode = code;
+});
diff --git a/src/cli/run.ts b/src/cli/run.ts
new file mode 100644
index 0000000..26e9400
--- /dev/null
+++ b/src/cli/run.ts
@@ -0,0 +1,262 @@
+/**
+ * `npx mailgrade acme.com`: the grade, in a terminal.
+ *
+ * Everything here is pure rendering over `gradeDomain`; the bin wrapper in
+ * `../cli.ts` is the only place that touches `process`. Exit codes are part
+ * of the contract, because the second thing people do with this is put it in
+ * CI: 0 means graded (and `--strict` satisfied), 1 means graded but below
+ * `--strict`, 2 means the grade could not be produced at all.
+ */
+
+import pkg from "../../package.json" with { type: "json" };
+import { coerceDomain } from "../domain.ts";
+import { gradeDomain, type GradeDomainOptions } from "../doh.ts";
+import type { DomainGrade, GradeLetter } from "../grade.ts";
+import type { FetchLike } from "../doh-resolver.ts";
+import type { Resolver } from "../verify/resolver.ts";
+
+export type CliIo = {
+ readonly write: (text: string) => void;
+ readonly writeError: (text: string) => void;
+ /** Terminal width, for wrapping. 80 when unknown. */
+ readonly columns?: number | undefined;
+ /** Whether to emit colour; `--no-color` wins over it. */
+ readonly color?: boolean;
+ /** Injectable for tests, so no test ever leaves the process. */
+ readonly fetch?: FetchLike;
+ readonly resolver?: Resolver;
+};
+
+const HELP = `
+ Grade a domain's SPF, DKIM and DMARC, and say what to fix.
+
+ Usage
+ $ mailgrade
+
+ Options
+ --json print the full grade as JSON
+ --strict exit 1 unless the verdict is "protected" (for CI)
+ --selectors a,b DKIM selectors to probe; "none" skips the probe
+ --endpoint a DNS-over-HTTPS endpoint other than Cloudflare's
+ --no-color plain output
+ -v, --version print the version
+ -h, --help this text
+
+ Exit codes
+ 0 graded
+ 1 graded, but --strict was set and the verdict is not "protected"
+ 2 no grade: bad arguments, or DNS could not be reached
+
+ Examples
+ $ mailgrade acme.com
+ $ mailgrade acme.com --json | jq .letter
+ $ mailgrade acme.com --strict # fail the build when protection slips
+`.replace(/^\n/, "");
+
+type ParsedArgs =
+ | { readonly kind: "help" }
+ | { readonly kind: "version" }
+ | { readonly kind: "error"; readonly message: string }
+ | {
+ readonly kind: "grade";
+ readonly domain: string;
+ readonly json: boolean;
+ readonly strict: boolean;
+ readonly noColor: boolean;
+ readonly selectors: readonly string[] | null;
+ readonly endpoint: string | null;
+ };
+
+export function parseArgs(argv: readonly string[]): ParsedArgs {
+ let domain: string | null = null;
+ let json = false;
+ let strict = false;
+ let noColor = false;
+ let selectors: readonly string[] | null = null;
+ let endpoint: string | null = null;
+
+ const take = (i: number): string | null => {
+ const arg = argv[i] as string;
+ const eq = arg.indexOf("=");
+ if (eq !== -1) return arg.slice(eq + 1);
+ const next = argv[i + 1];
+ return next === undefined || next.startsWith("-") ? null : next;
+ };
+
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i] as string;
+ const name = arg.startsWith("--") ? (arg.split("=")[0] as string) : arg;
+
+ if (name === "-h" || name === "--help") return { kind: "help" };
+ if (name === "-v" || name === "--version") return { kind: "version" };
+ if (name === "--json") json = true;
+ else if (name === "--strict") strict = true;
+ else if (name === "--no-color") noColor = true;
+ else if (name === "--selectors" || name === "--endpoint") {
+ const value = take(i);
+ if (value === null) {
+ return { kind: "error", message: `${name} needs a value` };
+ }
+ if (!arg.includes("=")) i++;
+ if (name === "--endpoint") endpoint = value;
+ else {
+ selectors =
+ value === "none" || value === ""
+ ? []
+ : value.split(",").map((s) => s.trim()).filter((s) => s !== "");
+ }
+ } else if (name.startsWith("-")) {
+ return { kind: "error", message: `unknown option ${name}` };
+ } else if (domain === null) {
+ domain = arg;
+ } else {
+ return { kind: "error", message: "one domain at a time" };
+ }
+ }
+
+ if (domain === null) {
+ return { kind: "error", message: "which domain? try: mailgrade acme.com" };
+ }
+ const coerced = coerceDomain(domain);
+ if (coerced === "" || !coerced.includes(".")) {
+ return { kind: "error", message: `"${domain}" does not look like a domain` };
+ }
+ return { kind: "grade", domain: coerced, json, strict, noColor, selectors, endpoint };
+}
+
+/* ---------------------------------------------------------------- paint --- */
+
+type Paint = (text: string, code: string) => string;
+
+const CODES: Record = {
+ bold: "1", dim: "2", red: "31", green: "32", yellow: "33",
+};
+
+function painter(color: boolean): Paint {
+ if (!color) return (text) => text;
+ return (text, code) => `\u001b[${CODES[code] ?? "0"}m${text}\u001b[0m`;
+}
+
+const LETTER_CODE: Record = {
+ "A+": "green", A: "green", B: "yellow", C: "yellow", D: "red", F: "red",
+};
+
+const TAGLINE = {
+ spoofable: "mail claiming to be this domain can reach inboxes",
+ partial: "spoofed mail is degraded, not refused",
+ protected: "receivers are told to refuse mail that fails authentication",
+} as const;
+
+function wrap(text: string, width: number, indent: string): string {
+ const words = text.split(/\s+/).filter((w) => w !== "");
+ const lines: string[] = [];
+ let line = "";
+ for (const word of words) {
+ if (line !== "" && line.length + 1 + word.length > width) {
+ lines.push(line);
+ line = word;
+ } else {
+ line = line === "" ? word : `${line} ${word}`;
+ }
+ }
+ if (line !== "") lines.push(line);
+ return lines.join(`\n${indent}`);
+}
+
+export function renderGrade(
+ grade: DomainGrade,
+ options: { readonly columns?: number | undefined; readonly color?: boolean } = {},
+): string {
+ const paint = painter(options.color ?? false);
+ const width = Math.min(options.columns ?? 80, 96) - 2;
+ const out: string[] = [""];
+
+ const letter = paint(paint(` ${grade.letter} `, "bold"), LETTER_CODE[grade.letter]);
+ out.push(` ${paint(grade.domain, "bold")}`);
+ out.push("");
+ out.push(` ${letter} ${paint(grade.verdict, "bold")}`);
+ out.push(` ${paint(wrap(TAGLINE[grade.verdict], width - 2, " "), "dim")}`);
+ out.push("");
+
+ const mark = (status: "pass" | "warn" | "fail") =>
+ status === "pass"
+ ? paint("\u2713", "green")
+ : status === "warn"
+ ? paint("!", "yellow")
+ : paint("\u2717", "red");
+ const row = (label: string, symbol: string, text: string) =>
+ out.push(
+ ` ${symbol} ${paint(label.padEnd(7), "dim")}${wrap(text, width - 11, " ".repeat(11))}`,
+ );
+
+ row("SPF", mark(grade.spf.status), grade.spf.headline);
+ row("DKIM", mark(grade.dkim.status), grade.dkim.headline);
+ row("DMARC", mark(grade.dmarc.status), grade.dmarc.headline);
+ row(
+ "MX",
+ paint("\u00b7", "dim"),
+ grade.mx.provider ??
+ (grade.mx.hosts.length > 0
+ ? grade.mx.hosts.join(", ")
+ : "no MX records"),
+ );
+
+ for (const rec of grade.recommendations) {
+ out.push("");
+ out.push(` ${paint("\u2192", "dim")} ${wrap(rec.text, width - 4, " ")}`);
+ }
+
+ out.push("");
+ return out.map((line) => `${line}\n`).join("");
+}
+
+/* ------------------------------------------------------------------ run --- */
+
+export async function runCli(
+ argv: readonly string[],
+ io: CliIo,
+): Promise {
+ const args = parseArgs(argv);
+
+ if (args.kind === "help") {
+ io.write(HELP);
+ return 0;
+ }
+ if (args.kind === "version") {
+ io.write(`${pkg.version}\n`);
+ return 0;
+ }
+ if (args.kind === "error") {
+ io.writeError(`mailgrade: ${args.message}\n`);
+ return 2;
+ }
+
+ const options: GradeDomainOptions = {
+ ...(args.selectors ? { selectors: args.selectors } : {}),
+ ...(args.endpoint ? { endpoint: args.endpoint } : {}),
+ ...(io.fetch ? { fetch: io.fetch } : {}),
+ ...(io.resolver ? { resolver: io.resolver } : {}),
+ };
+
+ let grade: DomainGrade;
+ try {
+ grade = await gradeDomain(args.domain, options);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ io.writeError(`mailgrade: ${message}\n`);
+ return 2;
+ }
+
+ if (args.json) {
+ io.write(`${JSON.stringify(grade, null, 2)}\n`);
+ } else {
+ io.write(
+ renderGrade(grade, {
+ columns: io.columns,
+ color: args.noColor ? false : (io.color ?? false),
+ }),
+ );
+ }
+
+ return args.strict && grade.verdict !== "protected" ? 1 : 0;
+}
diff --git a/src/dmarc/analyze.ts b/src/dmarc/analyze.ts
index 2b4c717..968e284 100644
--- a/src/dmarc/analyze.ts
+++ b/src/dmarc/analyze.ts
@@ -24,6 +24,8 @@ export type DmarcAnalysis = {
readonly status: Status;
readonly record: string | null;
readonly policy: DmarcPolicy | null;
+ /** Whether the record requests aggregate reports (a rua= tag). */
+ readonly reporting: boolean;
/** Host the record was found at; the org domain when a subdomain inherited it. */
readonly source: string | null;
readonly headline: string;
@@ -69,6 +71,7 @@ export function analyzeDmarc(
status: "fail",
record: null,
policy: null,
+ reporting: false,
source: null,
headline: "No DMARC record",
detail:
@@ -82,6 +85,7 @@ export function analyzeDmarc(
status: "fail",
record: records.join(" | "),
policy: null,
+ reporting: false,
source,
headline: "Multiple DMARC records",
detail:
@@ -92,6 +96,7 @@ export function analyzeDmarc(
const record = records[0] as string;
const tags = dmarcTags(record);
const policyTag = tags.get("p")?.toLowerCase();
+ const reporting = tags.has("rua");
if (
policyTag !== "none" &&
@@ -100,6 +105,7 @@ export function analyzeDmarc(
) {
return {
id: "dmarc-no-policy",
+ reporting,
status: "fail",
record,
policy: null,
@@ -123,6 +129,7 @@ export function analyzeDmarc(
if (policyTag === "none") {
return {
id: "dmarc-monitor",
+ reporting,
status: "fail",
record,
policy: "none",
@@ -137,6 +144,7 @@ export function analyzeDmarc(
if (policyTag === "quarantine") {
return {
id: "dmarc-quarantine",
+ reporting,
status: "warn",
record,
policy: "quarantine",
@@ -151,6 +159,7 @@ export function analyzeDmarc(
if (pct < 100) {
return {
id: "dmarc-sampled",
+ reporting,
status: "warn",
record,
policy: "reject",
@@ -165,6 +174,7 @@ export function analyzeDmarc(
if (subPolicy === "none" || subPolicy === "quarantine") {
return {
id: "dmarc-weak-subdomain",
+ reporting,
status: "warn",
record,
policy: "reject",
@@ -178,6 +188,7 @@ export function analyzeDmarc(
return {
id: "dmarc-enforcing",
+ reporting,
status: "pass",
record,
policy: "reject",
diff --git a/src/doh-resolver.ts b/src/doh-resolver.ts
new file mode 100644
index 0000000..1ca1614
--- /dev/null
+++ b/src/doh-resolver.ts
@@ -0,0 +1,132 @@
+/**
+ * A `Resolver` made of nothing but `fetch`.
+ *
+ * This is the library's default DNS, and it is deliberately the smallest
+ * module that can be one: it imports no grading code and no verification
+ * code, so `mailgrade/verify` can default its `resolver` without dragging the
+ * domain grader in behind it.
+ *
+ * DNS over HTTPS rather than a platform resolver, because `fetch` is the one
+ * lookup mechanism that exists in every runtime this targets. A Node service
+ * that would rather not leave the process for every lookup passes the
+ * `resolver` from `mailgrade/node-dns` instead.
+ */
+
+import type { DnsRecordType, Resolver } from "./verify/resolver.ts";
+
+export class DnsError extends Error {
+ readonly host: string;
+ readonly type: string;
+
+ constructor(message: string, host: string, type: string) {
+ super(message);
+ this.name = "DnsError";
+ this.host = host;
+ this.type = type;
+ }
+}
+
+/**
+ * The slice of `fetch` this module uses, named structurally so the published
+ * types do not depend on which DOM or Node globals a consumer has loaded.
+ */
+export type FetchLike = (
+ url: string,
+ init: {
+ headers: Record;
+ signal?: AbortSignal | undefined;
+ },
+) => Promise<{ ok: boolean; status: number; json(): Promise }>;
+
+export type DohResolverOptions = {
+ /** A DNS-over-HTTPS JSON endpoint. Defaults to Cloudflare's. */
+ readonly endpoint?: string;
+ /** Injectable for tests, or for a runtime with a non-global fetch. */
+ readonly fetch?: FetchLike;
+ readonly signal?: AbortSignal;
+};
+
+export const DEFAULT_ENDPOINT = "https://cloudflare-dns.com/dns-query";
+
+const RECORD_TYPE = { A: 1, AAAA: 28, MX: 15, PTR: 12, TXT: 16 } as const;
+
+type DohAnswer = { name: string; type: number; data: string };
+type DohResponse = { Status: number; Answer?: DohAnswer[] };
+
+/**
+ * A TXT value arrives quoted, and one longer than 255 bytes arrives as several
+ * quoted strings that the reader is expected to concatenate.
+ */
+function unquoteTxt(data: string): string {
+ const parts = data.match(/"(?:[^"\\]|\\.)*"/g);
+ if (!parts) return data.trim();
+ return parts.map((p) => p.slice(1, -1).replace(/\\(.)/g, "$1")).join("");
+}
+
+function stripRootDot(host: string): string {
+ return host.replace(/\.$/, "");
+}
+
+async function query(
+ host: string,
+ type: DnsRecordType,
+ options: DohResolverOptions,
+): Promise {
+ const doFetch: FetchLike = options.fetch ?? globalThis.fetch;
+ const url = `${options.endpoint ?? DEFAULT_ENDPOINT}?name=${encodeURIComponent(host)}&type=${type}`;
+
+ const response = await doFetch(url, {
+ headers: { accept: "application/dns-json" },
+ signal: options.signal,
+ });
+ if (!response.ok) {
+ throw new DnsError(
+ `DNS query failed with HTTP ${response.status}`,
+ host,
+ type,
+ );
+ }
+
+ const body = (await response.json()) as DohResponse;
+
+ // 0 is NOERROR and 3 is NXDOMAIN, which for a DMARC host or a DKIM selector
+ // is a legitimate "nothing published here". Anything else means the answer
+ // is unknown, and reporting unknown as absent would grade a protected
+ // domain as spoofable.
+ if (body.Status !== 0 && body.Status !== 3) {
+ throw new DnsError(`DNS query returned status ${body.Status}`, host, type);
+ }
+
+ return (body.Answer ?? [])
+ .filter((a) => a.type === RECORD_TYPE[type])
+ .map((a) => (type === "TXT" ? unquoteTxt(a.data) : a.data));
+}
+
+/**
+ * Honours the resolver contract: empty answers come back as `[]`, failures
+ * throw (and verification reports temperror rather than fail), MX values are
+ * bare host names.
+ */
+export function dohResolver(options: DohResolverOptions = {}): Resolver {
+ return async (name, type) => {
+ const records = await query(name, type, options);
+ if (type === "MX") {
+ return records.map((r) => stripRootDot(r.split(/\s+/).pop() ?? r));
+ }
+ if (type === "PTR") return records.map(stripRootDot);
+ return records;
+ };
+}
+
+/**
+ * The resolver every call falls back to when the caller names none.
+ *
+ * Built once and reused, so that defaulting costs nothing per call, and built
+ * lazily so that merely importing the library never reads a global that a
+ * runtime may not have set up yet.
+ */
+let fallback: Resolver | undefined;
+
+export function defaultResolver(): Resolver {
+ return (fallback ??= dohResolver());
+}
diff --git a/src/doh.ts b/src/doh.ts
index 1d09f76..284d52a 100644
--- a/src/doh.ts
+++ b/src/doh.ts
@@ -1,51 +1,29 @@
/**
- * The only part of this library that touches the network, kept behind its own
- * entry point so that importing `mailgrade` never pulls it in.
+ * Resolving a whole domain, and grading what comes back.
*
- * DNS over HTTPS rather than a platform resolver, because `fetch` is the one
- * lookup mechanism that exists in every runtime this targets. A Node service
- * that would rather not leave the process for every lookup passes the
- * `resolver` from `mailgrade/node-dns` instead.
+ * The DNS itself lives in `./doh-resolver.ts`; this module is the part that
+ * knows which names a grade needs and in what order to ask for them.
*/
import { DKIM_SELECTORS, dkimHost, isDkimKey } from "./dkim.ts";
import { dmarcHost } from "./dmarc/analyze.ts";
+import {
+ defaultResolver,
+ dohResolver,
+ type DohResolverOptions,
+} from "./doh-resolver.ts";
import { registrableDomain } from "./domain.ts";
-import { gradeDomain, type DomainGrade, type DomainRecords } from "./grade.ts";
-import type { DnsRecordType, Resolver } from "./verify/resolver.ts";
+import { gradeRecords, type DomainGrade, type DomainRecords } from "./grade.ts";
+import type { Resolver } from "./verify/resolver.ts";
-export class DnsError extends Error {
- readonly host: string;
- readonly type: string;
+export { DEFAULT_ENDPOINT, DnsError } from "./doh-resolver.ts";
+export { defaultResolver, dohResolver };
+export type { DohResolverOptions, FetchLike } from "./doh-resolver.ts";
- constructor(message: string, host: string, type: string) {
- super(message);
- this.name = "DnsError";
- this.host = host;
- this.type = type;
- }
-}
-
-/**
- * The slice of `fetch` this module uses, named structurally so the published
- * types do not depend on which DOM or Node globals a consumer has loaded.
- */
-export type FetchLike = (
- url: string,
- init: {
- headers: Record;
- signal?: AbortSignal | undefined;
- },
-) => Promise<{ ok: boolean; status: number; json(): Promise }>;
-
-export type DohOptions = {
- /** A DNS-over-HTTPS JSON endpoint. Defaults to Cloudflare's. */
- readonly endpoint?: string;
- /** Injectable for tests, or for a runtime with a non-global fetch. */
- readonly fetch?: FetchLike;
+/** Options for `gradeDomain`, and for `resolveDomain`, its lookup half. */
+export type GradeDomainOptions = DohResolverOptions & {
/** Selectors to probe for DKIM keys. Pass an empty array to skip DKIM. */
readonly selectors?: readonly string[];
- readonly signal?: AbortSignal;
/** Lookups in flight at once. */
readonly concurrency?: number;
/**
@@ -55,64 +33,6 @@ export type DohOptions = {
readonly resolver?: Resolver;
};
-const DEFAULT_ENDPOINT = "https://cloudflare-dns.com/dns-query";
-
-const RECORD_TYPE = { A: 1, AAAA: 28, MX: 15, PTR: 12, TXT: 16 } as const;
-
-type DohAnswer = { name: string; type: number; data: string };
-type DohResponse = { Status: number; Answer?: DohAnswer[] };
-
-/**
- * A TXT value arrives quoted, and one longer than 255 bytes arrives as several
- * quoted strings that the reader is expected to concatenate.
- */
-function unquoteTxt(data: string): string {
- const parts = data.match(/"(?:[^"\\]|\\.)*"/g);
- if (!parts) return data.trim();
- return parts
- .map((p) => p.slice(1, -1).replace(/\\(.)/g, "$1"))
- .join("");
-}
-
-async function query(
- host: string,
- type: DnsRecordType,
- options: DohOptions,
-): Promise {
- const doFetch: FetchLike = options.fetch ?? globalThis.fetch;
- const url = `${options.endpoint ?? DEFAULT_ENDPOINT}?name=${encodeURIComponent(host)}&type=${type}`;
-
- const response = await doFetch(url, {
- headers: { accept: "application/dns-json" },
- signal: options.signal,
- });
- if (!response.ok) {
- throw new DnsError(
- `DNS query failed with HTTP ${response.status}`,
- host,
- type,
- );
- }
-
- const body = (await response.json()) as DohResponse;
-
- // 0 is NOERROR and 3 is NXDOMAIN, which for a DMARC host or a DKIM selector
- // is a legitimate "nothing published here". Anything else means the answer
- // is unknown, and reporting unknown as absent would grade a protected
- // domain as spoofable.
- if (body.Status !== 0 && body.Status !== 3) {
- throw new DnsError(
- `DNS query returned status ${body.Status}`,
- host,
- type,
- );
- }
-
- return (body.Answer ?? [])
- .filter((a) => a.type === RECORD_TYPE[type])
- .map((a) => (type === "TXT" ? unquoteTxt(a.data) : a.data));
-}
-
async function pool(
items: readonly T[],
limit: number,
@@ -138,7 +58,7 @@ async function pool(
}
/**
- * Every record `gradeDomain` needs, in one round of lookups.
+ * Every record `gradeRecords` needs, in one round of lookups.
*
* DMARC falls back to the organizational domain the way a receiver does, so a
* subdomain is graded on the policy that actually applies to it, and `dmarcSource`
@@ -146,12 +66,12 @@ async function pool(
*/
export async function resolveDomain(
domain: string,
- options: DohOptions = {},
+ options: GradeDomainOptions = {},
): Promise {
const name = domain.trim().toLowerCase();
const selectors = options.selectors ?? DKIM_SELECTORS;
const concurrency = options.concurrency ?? 10;
- const resolve = options.resolver ?? dohResolver(options);
+ const resolve = resolverFor(options);
const [txt, mx, dmarcAtDomain] = await Promise.all([
resolve(name, "TXT"),
@@ -183,32 +103,31 @@ export async function resolveDomain(
};
}
+/**
+ * A caller's own resolver wins; otherwise DoH, configured if they tuned it and
+ * the shared default if they did not.
+ */
+function resolverFor(options: GradeDomainOptions): Resolver {
+ if (options.resolver) return options.resolver;
+ if (options.endpoint || options.fetch || options.signal) {
+ return dohResolver(options);
+ }
+ return defaultResolver();
+}
+
function stripRootDot(host: string): string {
return host.replace(/\.$/, "");
}
/**
- * A `Resolver` for `mailgrade/verify`, made of nothing but `fetch`.
+ * The headline call: everything a domain publishes, in one verdict.
*
- * Honours the resolver contract: empty answers come back as `[]`, failures
- * throw (and verification reports temperror rather than fail), MX values are
- * bare host names.
+ * `resolveDomain` then `gradeRecords`, which are also exported separately for
+ * a caller who wants to cache the records or grade ones they already hold.
*/
-export function dohResolver(options: DohOptions = {}): Resolver {
- return async (name, type) => {
- const records = await query(name, type, options);
- if (type === "MX") {
- return records.map((r) => stripRootDot(r.split(/\s+/).pop() ?? r));
- }
- if (type === "PTR") return records.map(stripRootDot);
- return records;
- };
-}
-
-/** Resolve a domain and grade it. */
-export async function checkDomain(
+export async function gradeDomain(
domain: string,
- options: DohOptions = {},
+ options: GradeDomainOptions = {},
): Promise {
- return gradeDomain(await resolveDomain(domain, options));
+ return gradeRecords(await resolveDomain(domain, options));
}
diff --git a/src/grade.ts b/src/grade.ts
index 031910a..9ee1f77 100644
--- a/src/grade.ts
+++ b/src/grade.ts
@@ -1,5 +1,9 @@
/**
- * The headline call: everything a domain publishes, in one verdict.
+ * Grading, given records somebody else fetched.
+ *
+ * `gradeDomain` in `mailgrade/doh` is this composed with the lookups; this is
+ * the half that is pure, so a caller who already holds the DNS answers (a
+ * cache, a bulk job, a fixture) never pays for a second round trip.
*
* The grade answers "how far does mail claiming to be this domain get", which
* is why DMARC policy decides the verdict and SPF and DKIM decide whether that
@@ -14,6 +18,8 @@ import type { Recommendation } from "./types.ts";
export type DomainVerdict = "spoofable" | "partial" | "protected";
+export type GradeLetter = "A+" | "A" | "B" | "C" | "D" | "F";
+
/** Raw DNS answers, however the caller obtained them. */
export type DomainRecords = {
readonly domain: string;
@@ -33,6 +39,8 @@ export type DomainRecords = {
export type DomainGrade = {
readonly domain: string;
readonly verdict: DomainVerdict;
+ /** The verdict as a report card, from A+ down to F. */
+ readonly letter: GradeLetter;
readonly spf: SpfAnalysis;
readonly dmarc: DmarcAnalysis;
readonly dkim: DkimAnalysis;
@@ -55,42 +63,93 @@ export function domainVerdict(
return "protected";
}
-export function gradeDomain(records: DomainRecords): DomainGrade {
+export function gradeRecords(records: DomainRecords): DomainGrade {
const domain = records.domain.trim().toLowerCase();
- const mxHosts = records.mx ?? [];
+ // RFC 7505: a null MX ("MX 0 .") is a deliberate "this domain takes no
+ // mail", and arrives as "." or, root dot stripped, as "". Neither is a mail
+ // host, and treating one as a mail host would tell a no-mail domain to turn
+ // on DKIM.
+ const mxHosts = (records.mx ?? []).filter((h) => h !== "" && h !== ".");
const spf = analyzeSpf(records.txt);
const dmarc = analyzeDmarc(records.dmarc, records.dmarcSource ?? domain);
const dkim = analyzeDkim(
records.dkimSelectors ?? [],
records.dkimProbed ?? DKIM_SELECTORS.length,
);
+ const verdict = domainVerdict(spf, dmarc);
+ const parked = looksParked(spf, mxHosts);
return {
domain,
- verdict: domainVerdict(spf, dmarc),
+ verdict,
+ letter: letterFor(verdict, spf, dmarc, dkim, parked),
spf,
dmarc,
dkim,
mx: { hosts: mxHosts, provider: mailProvider(mxHosts) },
- recommendations: recommend(domain, spf, dmarc, dkim, mxHosts),
+ recommendations: recommend(domain, spf, dmarc, dkim, parked),
};
}
+/**
+ * No MX and either no SPF or a bare "v=spf1 -all" reads as a domain that
+ * does not do mail, so advice about signing and providers would be noise,
+ * and absent DKIM is not held against the grade.
+ */
+function looksParked(spf: SpfAnalysis, mxHosts: readonly string[]): boolean {
+ return (
+ mxHosts.length === 0 &&
+ (spf.record === null || /^v=spf1\s+-all$/i.test(spf.record.trim()))
+ );
+}
+
+/**
+ * The verdict as a report card. The verdict sets the band and the details
+ * move within it:
+ *
+ * - `F`: spoofable with nothing effective published, or an SPF `+all`,
+ * which actively authorises the spoof.
+ * - `D`: spoofable, but something is in place (a monitoring-only DMARC, or
+ * decent SPF with no DMARC behind it).
+ * - `C`: partial enforcement: quarantine, a sampled reject, or a weaker
+ * subdomain policy.
+ * - `B`: enforcing, but SPF is broken or missing, or a sending domain shows
+ * no DKIM keys, so enforcement rests on fewer legs than it should.
+ * - `A`: enforcing, with SPF and DKIM both standing behind it.
+ * - `A+`: as `A`, with SPF ending in `-all` and aggregate reports flowing
+ * somewhere (`rua=`), so drift will be noticed.
+ */
+function letterFor(
+ verdict: DomainVerdict,
+ spf: SpfAnalysis,
+ dmarc: DmarcAnalysis,
+ dkim: DkimAnalysis,
+ parked: boolean,
+): GradeLetter {
+ if (verdict === "partial") return "C";
+
+ if (verdict === "spoofable") {
+ if (spf.id === "spf-allow-all") return "F";
+ if (dmarc.record === null && spf.status === "fail") return "F";
+ return "D";
+ }
+
+ const spfBroken = spf.status === "fail";
+ const unsignedSender = dkim.selectorsFound.length === 0 && !parked;
+ if (spfBroken || unsignedSender) return "B";
+
+ return spf.id === "spf-hardfail" && dmarc.reporting ? "A+" : "A";
+}
+
function recommend(
domain: string,
spf: SpfAnalysis,
dmarc: DmarcAnalysis,
dkim: DkimAnalysis,
- mxHosts: readonly string[],
+ sendsNothing: boolean,
): Recommendation[] {
const recs: Recommendation[] = [];
- // No MX and either no SPF or a bare "v=spf1 -all" reads as a domain that
- // does not do mail, so advice about signing and providers would be noise.
- const sendsNothing =
- mxHosts.length === 0 &&
- (spf.record === null || /^v=spf1\s+-all$/i.test(spf.record.trim()));
-
if (sendsNothing && (spf.record === null || dmarc.status !== "pass")) {
recs.push({
id: "lock-parked-domain",
diff --git a/src/index.ts b/src/index.ts
index 8bb29dd..4486928 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,9 +1,106 @@
-export * from "./dkim.ts";
-export * from "./dmarc/index.ts";
-export * from "./domain.ts";
-export * from "./grade.ts";
-export * from "./headers/index.ts";
-export * from "./mx.ts";
-export * from "./spf.ts";
-export * from "./types.ts";
-export * from "./verify/index.ts";
+/**
+ * The public API, named one at a time.
+ *
+ * This barrel is curated rather than a set of `export *`: the point of the
+ * root import is that autocomplete on `mailgrade` shows the calls a reader
+ * would recognise from the README, not the sixty helpers those calls are
+ * built out of. Every internal remains reachable from the entry point for its
+ * own area (`mailgrade/headers`, `mailgrade/dmarc`, `mailgrade/spf`, ...),
+ * which is also where a rename is a smaller promise to keep.
+ */
+
+/* -------------------------------------------------------------- domain --- */
+
+export { gradeDomain, resolveDomain } from "./doh.ts";
+export type { GradeDomainOptions } from "./doh.ts";
+export { gradeRecords, domainVerdict } from "./grade.ts";
+export type {
+ DomainGrade,
+ DomainRecords,
+ DomainVerdict,
+ GradeLetter,
+} from "./grade.ts";
+
+/* ----------------------------------------------------------- messages --- */
+
+export { verifyMessage, toAuthResults } from "./verify/index.ts";
+export type {
+ AuthResultsInput,
+ MessageVerification,
+ VerifyMessageOptions,
+} from "./verify/index.ts";
+export { verifySpf, verifyDkim, verifyDmarc } from "./verify/index.ts";
+export type {
+ DkimResult,
+ DkimResultId,
+ DkimVerification,
+ DkimVerifyOptions,
+ DmarcParams,
+ DmarcVerification,
+ SpfParams,
+ SpfResult,
+ SpfVerification,
+} from "./verify/index.ts";
+
+/* ------------------------------------------------------------ headers --- */
+
+export { analyzeHeaders } from "./headers/index.ts";
+export type {
+ AuthSource,
+ Flag,
+ HeaderAnalysis,
+ HeaderVerdict,
+ Identity,
+ MessageSummary,
+ Route,
+} from "./headers/index.ts";
+
+/* -------------------------------------------------------- dmarc records --- */
+
+export {
+ buildDmarcRecord,
+ parseDmarcRecord,
+ reviewDmarc,
+ rolloutPlan,
+} from "./dmarc/index.ts";
+export type {
+ Alignment,
+ DmarcError,
+ DmarcErrorId,
+ DmarcNote,
+ DmarcOptions,
+ DmarcParse,
+ DmarcPolicy,
+ RolloutStage,
+ SubdomainPolicy,
+} from "./dmarc/index.ts";
+
+/* ------------------------------------------------------------- records --- */
+
+export { analyzeSpf, isSpfRecord } from "./spf.ts";
+export type { SpfAnalysis, SpfFinding, SpfQualifier } from "./spf.ts";
+export { analyzeDkim, isDkimKey, dkimHost, DKIM_SELECTORS } from "./dkim.ts";
+export type { DkimAnalysis, DkimFinding } from "./dkim.ts";
+export { analyzeDmarc, isDmarcRecord, dmarcHost } from "./dmarc/index.ts";
+export type { DmarcAnalysis, DmarcFinding } from "./dmarc/index.ts";
+export { mailProvider } from "./mx.ts";
+
+/* ----------------------------------------------------------------- dns --- */
+
+export { dohResolver, DnsError } from "./doh.ts";
+export { staticResolver } from "./verify/resolver.ts";
+export type { DnsRecordType, Resolver } from "./verify/resolver.ts";
+export type { DohResolverOptions, FetchLike } from "./doh-resolver.ts";
+
+/* -------------------------------------------------------------- shared --- */
+
+export { registrableDomain, aligns, domainOf, coerceDomain } from "./domain.ts";
+export type { SuffixOptions } from "./domain.ts";
+export type {
+ AuthStatus,
+ Identified,
+ Note,
+ Recommendation,
+ Severity,
+ Status,
+} from "./types.ts";
diff --git a/src/verify/dkim.ts b/src/verify/dkim.ts
index b8d4102..6aa6897 100644
--- a/src/verify/dkim.ts
+++ b/src/verify/dkim.ts
@@ -8,6 +8,7 @@
* never costs a lookup.
*/
+import { defaultResolver } from "../doh-resolver.ts";
import type { Resolver } from "./resolver.ts";
import {
canonicalBodyRelaxed,
@@ -56,7 +57,8 @@ export type DkimVerification = {
};
export type DkimVerifyOptions = {
- readonly resolver: Resolver;
+ /** Where DNS comes from. Defaults to DNS over HTTPS against Cloudflare. */
+ readonly resolver?: Resolver;
/** The clock to judge x= expiry against; defaults to now. */
readonly now?: Date;
};
@@ -312,7 +314,10 @@ async function verifySignature(
let keyRecords: readonly string[];
try {
- keyRecords = await options.resolver(`${selector}._domainkey.${domain}`, "TXT");
+ keyRecords = await (options.resolver ?? defaultResolver())(
+ `${selector}._domainkey.${domain}`,
+ "TXT",
+ );
} catch {
return finish(verdict("dkim-dns-error"));
}
diff --git a/src/verify/dmarc.ts b/src/verify/dmarc.ts
index 19da731..ff4591d 100644
--- a/src/verify/dmarc.ts
+++ b/src/verify/dmarc.ts
@@ -10,6 +10,7 @@
import { aligns, registrableDomain, type SuffixOptions } from "../domain.ts";
import { dmarcHost, isDmarcRecord } from "../dmarc/analyze.ts";
import { parseDmarcRecord, type Alignment, type DmarcPolicy } from "../dmarc/record.ts";
+import { defaultResolver } from "../doh-resolver.ts";
import type { Resolver } from "./resolver.ts";
import type { DkimVerification } from "./dkim.ts";
import type { SpfVerification } from "./spf.ts";
@@ -37,7 +38,8 @@ export type DmarcParams = {
readonly spf?: Pick | null;
/** One outcome per DKIM signature. */
readonly dkim?: readonly Pick[];
- readonly resolver: Resolver;
+ /** Where DNS comes from. Defaults to DNS over HTTPS against Cloudflare. */
+ readonly resolver?: Resolver;
} & SuffixOptions;
function identityAligns(
@@ -55,6 +57,7 @@ function identityAligns(
export async function verifyDmarc(params: DmarcParams): Promise {
const fromDomain = params.fromDomain.trim().toLowerCase();
const org = registrableDomain(fromDomain, params);
+ const resolve = params.resolver ?? defaultResolver();
const blank: DmarcVerification = {
result: "none",
@@ -73,7 +76,7 @@ export async function verifyDmarc(params: DmarcParams): Promise Promise` of your own.
+ */
+ readonly resolver?: Resolver;
/** The IP the message arrived from; without it SPF is skipped. */
readonly ip?: string;
/** The MAIL FROM (envelope sender) address. */
readonly sender?: string;
/** The HELO/EHLO host name, SPF's fallback identity for a null sender. */
readonly helo?: string;
+ /**
+ * The host doing the verifying, which becomes the authserv-id in the
+ * generated headers. Pass the name a downstream filter will trust.
+ */
+ readonly mta?: string;
readonly now?: Date;
} & SuffixOptions;
@@ -51,6 +67,14 @@ export type MessageVerification = {
readonly dkim: readonly DkimVerification[];
/** Null when the message has no readable From domain. */
readonly dmarc: DmarcVerification | null;
+ /** The Authentication-Results field value, without the field name. */
+ readonly authResults: string;
+ /**
+ * Complete header fields, folded and CRLF-terminated, ready to prepend to
+ * the message before handing it on: `Received-SPF` when SPF was evaluated,
+ * then `Authentication-Results`.
+ */
+ readonly headers: string;
};
/**
@@ -60,8 +84,9 @@ export type MessageVerification = {
*/
export async function verifyMessage(
message: string | Uint8Array,
- options: VerifyMessageOptions,
+ options: VerifyMessageOptions = {},
): Promise {
+ const resolver = options.resolver ?? defaultResolver();
const parsed = splitMessage(message);
const from = parseAddress(
parsed.headers.find((h) => h.lower === "from")?.value ?? null,
@@ -75,10 +100,11 @@ export async function verifyMessage(
ip: options.ip,
sender: spfIdentity,
helo: options.helo,
- resolver: options.resolver,
+ receiver: options.mta,
+ resolver,
})
: Promise.resolve(null),
- verifyDkim(parsed, { resolver: options.resolver, now: options.now }),
+ verifyDkim(parsed, { resolver, now: options.now }),
]);
const dmarc = fromDomain
@@ -88,44 +114,238 @@ export async function verifyMessage(
? { result: spf.result, domain: domainOf(options.sender) ?? spf.domain }
: null,
dkim,
- resolver: options.resolver,
+ resolver,
publicSuffixes: options.publicSuffixes,
})
: null;
- return { fromDomain, sender: spf ? spfIdentity : null, spf, dkim, dmarc };
+ const core = {
+ fromDomain,
+ sender: spf ? spfIdentity : null,
+ spf,
+ dkim,
+ dmarc,
+ };
+ const authResults = toAuthResults(core, options.mta ?? ANONYMOUS_MTA);
+
+ return {
+ ...core,
+ authResults,
+ headers: buildHeaders(core, authResults, options),
+ };
}
/**
* The verification as an RFC 8601 Authentication-Results header value, ready
- * to stamp onto the message before handing it on: what `analyzeHeaders` and
- * every downstream filter read.
+ * to stamp onto the message: what `analyzeHeaders` and every downstream
+ * filter read. `verifyMessage` already returns this as `authResults`; call it
+ * directly to restamp under a different authserv-id.
*/
export function toAuthResults(
- verification: MessageVerification,
- authservId = "mailgrade",
+ verification: AuthResultsInput,
+ authservId: string = ANONYMOUS_MTA,
): string {
const parts: string[] = [];
const { spf, dkim, dmarc, sender, fromDomain } = verification;
if (spf) {
const identity = sender?.includes("@") ? sender : (sender ?? spf.domain);
- parts.push(`spf=${spf.result} smtp.mailfrom=${identity}`);
+ parts.push(`spf=${spf.result} smtp.mailfrom=${oneLine(identity)}`);
}
for (const signature of dkim) {
parts.push(
`dkim=${signature.result}` +
- (signature.domain ? ` header.d=${signature.domain}` : "") +
- (signature.selector ? ` header.s=${signature.selector}` : ""),
+ (signature.domain ? ` header.d=${oneLine(signature.domain)}` : "") +
+ (signature.selector ? ` header.s=${oneLine(signature.selector)}` : ""),
);
}
if (dmarc && dmarc.result !== "none") {
- parts.push(`dmarc=${dmarc.result} header.from=${dmarc.fromDomain}`);
+ parts.push(`dmarc=${dmarc.result} header.from=${oneLine(dmarc.fromDomain)}`);
} else if (fromDomain && (spf || dkim.length > 0)) {
- parts.push(`dmarc=none header.from=${fromDomain}`);
+ parts.push(`dmarc=none header.from=${oneLine(fromDomain)}`);
}
return parts.length === 0
- ? `${authservId}; none`
- : `${authservId}; ${parts.join("; ")}`;
+ ? `${oneLine(authservId)}; none`
+ : `${oneLine(authservId)}; ${parts.join("; ")}`;
+}
+
+/** Everything `toAuthResults` reads, so a caller can hand it a partial. */
+export type AuthResultsInput = Pick<
+ MessageVerification,
+ "fromDomain" | "sender" | "spf" | "dkim" | "dmarc"
+>;
+
+/* ------------------------------------------------------------ headers --- */
+
+/**
+ * The values embedded in the generated headers come off the wire (MAIL FROM,
+ * HELO, a hostile message's own DKIM-Signature), so anything that could start
+ * a new header field is folded to a space before it is embedded. A CR or LF
+ * here is a header injection, not a value.
+ */
+function oneLine(value: string): string {
+ return value.replace(/[\r\n\0]/g, " ").replace(/[ \t]+/g, " ").trim();
+}
+
+/** As a quoted-string: RFC 7208's form for envelope-from, quotes escaped. */
+function quotedString(value: string): string {
+ return `"${oneLine(value).replace(/[\\"]/g, (c) => `\\${c}`)}"`;
+}
+
+/**
+ * `Received-SPF` first, then `Authentication-Results`, each folded so no line
+ * runs past the 78 characters RFC 5322 asks for. A receiver prepends the
+ * block verbatim; the order is the order the next hop should read them in.
+ */
+function buildHeaders(
+ verification: AuthResultsInput,
+ authResults: string,
+ options: VerifyMessageOptions,
+): string {
+ const fields: string[] = [];
+ const receivedSpf = buildReceivedSpf(verification, options);
+ if (receivedSpf) fields.push(receivedSpf);
+ fields.push(fold("Authentication-Results", authResults));
+ return fields.map((field) => `${field}\r\n`).join("");
+}
+
+/**
+ * RFC 7208 section 9.1. The comment is generated rather than taken from the
+ * record's own `exp=`: an explanation the sender wrote is for a human bounce,
+ * not for a trace header the next hop parses.
+ */
+function buildReceivedSpf(
+ verification: AuthResultsInput,
+ options: VerifyMessageOptions,
+): string | null {
+ const { spf, sender } = verification;
+ if (!spf || !options.ip) return null;
+
+ const ip = oneLine(options.ip);
+ const identity = oneLine(sender ?? spf.domain);
+ const subject = identity.includes("@")
+ ? `domain of ${identity}`
+ : `${identity}`;
+ const comment =
+ spf.result === "pass"
+ ? `${subject} designates ${ip} as permitted sender`
+ : spf.result === "fail"
+ ? `${subject} does not designate ${ip} as permitted sender`
+ : `${spf.result} for ${subject}`;
+
+ const keys = [`client-ip=${ip}`];
+ if (options.sender) keys.push(`envelope-from=${quotedString(options.sender)}`);
+ if (options.helo) keys.push(`helo=${oneLine(options.helo)}`);
+
+ const authority = oneLine(options.mta ?? ANONYMOUS_MTA);
+ return fold(
+ "Received-SPF",
+ `${spf.result} (${authority}: ${comment}) ${keys.join("; ")}`,
+ );
+}
+
+const LINE_LIMIT = 78;
+
+/**
+ * Fold to the 78 characters RFC 5322 asks for, preferring the semicolons that
+ * separate a header's parts and falling back to plain spaces when one part is
+ * longer than a line on its own, which the SPF comment usually is.
+ *
+ * Never mid-token and never inside quotes: a signature, an address or a
+ * quoted envelope-from broken across a fold is one the next parser gets
+ * wrong, and a header nobody downstream can read is worse than a long line.
+ */
+function fold(name: string, value: string): string {
+ const pieces = segments(value);
+
+ let out = `${name}:`;
+ let line = out.length;
+
+ for (const piece of pieces) {
+ // A part that will not fit starts its own line, unless that would leave
+ // the field name sitting alone on the first one.
+ if (line + 1 + piece.length > LINE_LIMIT && line > name.length + 1) {
+ out += "\r\n";
+ line = 0;
+ }
+ for (const word of unquotedWords(piece)) {
+ if (line + 1 + word.length > LINE_LIMIT && line > 1) {
+ out += `\r\n ${word}`;
+ line = 1 + word.length;
+ } else {
+ out += ` ${word}`;
+ line += 1 + word.length;
+ }
+ }
+ }
+
+ return out;
+}
+
+/**
+ * Split after the semicolons that separate a header's parts, keeping each
+ * semicolon with its part and dropping the whitespace between parts. Only
+ * the semicolons outside quotes count: `envelope-from="\"a;b\"@x.com"` is
+ * one part, not two.
+ */
+function segments(value: string): string[] {
+ const out: string[] = [];
+ let current = "";
+ let quoted = false;
+ let escaped = false;
+
+ for (const char of value) {
+ if (escaped) {
+ current += char;
+ escaped = false;
+ continue;
+ }
+ if (char === "\\" && quoted) {
+ current += char;
+ escaped = true;
+ continue;
+ }
+ if (char === '"') quoted = !quoted;
+ if ((char === " " || char === "\t") && !quoted && current === "") continue;
+ current += char;
+ if (char === ";" && !quoted) {
+ out.push(current);
+ current = "";
+ }
+ }
+ if (current !== "") out.push(current);
+
+ return out;
+}
+
+/** Split on the spaces that are foldable, which is the ones outside quotes. */
+function unquotedWords(value: string): string[] {
+ const out: string[] = [];
+ let current = "";
+ let quoted = false;
+ let escaped = false;
+
+ for (const char of value) {
+ if (escaped) {
+ current += char;
+ escaped = false;
+ continue;
+ }
+ if (char === "\\" && quoted) {
+ current += char;
+ escaped = true;
+ continue;
+ }
+ if (char === '"') quoted = !quoted;
+ if ((char === " " || char === "\t") && !quoted) {
+ if (current !== "") out.push(current);
+ current = "";
+ } else {
+ current += char;
+ }
+ }
+ if (current !== "") out.push(current);
+
+ return out;
}
diff --git a/src/verify/spf.ts b/src/verify/spf.ts
index 4bde378..a46e2e6 100644
--- a/src/verify/spf.ts
+++ b/src/verify/spf.ts
@@ -9,6 +9,7 @@
*/
import { macroIp, parseIp, readableIp, reverseName, inCidr, type ParsedIp } from "./ip.ts";
+import { defaultResolver } from "../doh-resolver.ts";
import type { Resolver } from "./resolver.ts";
export type SpfResult =
@@ -42,7 +43,8 @@ export type SpfParams = {
readonly helo?: string;
/** Receiving host name, used by the %{r} macro. */
readonly receiver?: string;
- readonly resolver: Resolver;
+ /** Where DNS comes from. Defaults to DNS over HTTPS against Cloudflare. */
+ readonly resolver?: Resolver;
};
const LOOKUP_LIMIT = 10;
@@ -100,7 +102,7 @@ export async function verifySpf(params: SpfParams): Promise {
if (!senderDomain || !senderDomain.includes(".")) return blank("none");
const ctx: Context = {
- resolver: params.resolver,
+ resolver: params.resolver ?? defaultResolver(),
ip,
sender: `${local}@${senderDomain}`,
local,
diff --git a/test/cli.test.ts b/test/cli.test.ts
new file mode 100644
index 0000000..eeae024
--- /dev/null
+++ b/test/cli.test.ts
@@ -0,0 +1,182 @@
+/**
+ * The CLI, run entirely in-process: `runCli` takes its I/O and its DNS as
+ * arguments, so these tests never spawn anything and never leave the process.
+ */
+
+import { describe, expect, it } from "vitest";
+
+import { parseArgs, renderGrade, runCli, type CliIo } from "../src/cli/run.ts";
+import { gradeRecords } from "../src/grade.ts";
+import { staticResolver } from "../src/verify/resolver.ts";
+
+const LOCKED_ZONE = {
+ "acme.com": {
+ TXT: ["v=spf1 include:_spf.google.com -all"],
+ MX: ["aspmx.l.google.com"],
+ },
+ "_dmarc.acme.com": { TXT: ["v=DMARC1; p=reject; rua=mailto:d@acme.com"] },
+ "google._domainkey.acme.com": { TXT: ["v=DKIM1; k=rsa; p=MIIB"] },
+} as const;
+
+function io(overrides: Partial = {}) {
+ const out: string[] = [];
+ const err: string[] = [];
+ const handle: CliIo = {
+ write: (t) => void out.push(t),
+ writeError: (t) => void err.push(t),
+ resolver: staticResolver(LOCKED_ZONE),
+ ...overrides,
+ };
+ return { handle, stdout: () => out.join(""), stderr: () => err.join("") };
+}
+
+describe("parseArgs", () => {
+ it("takes a domain and the flags in any order", () => {
+ expect(parseArgs(["--strict", "acme.com", "--json"])).toEqual({
+ kind: "grade",
+ domain: "acme.com",
+ json: true,
+ strict: true,
+ noColor: false,
+ selectors: null,
+ endpoint: null,
+ });
+ });
+
+ it("accepts a pasted URL or address and grades its domain", () => {
+ const parsed = parseArgs(["https://www.acme.com/careers"]);
+ expect(parsed).toMatchObject({ kind: "grade", domain: "acme.com" });
+ expect(parseArgs(["ceo@acme.com"])).toMatchObject({ domain: "acme.com" });
+ });
+
+ it("reads --selectors as a list, with none meaning skip the probe", () => {
+ expect(parseArgs(["acme.com", "--selectors", "google,pm"])).toMatchObject({
+ selectors: ["google", "pm"],
+ });
+ expect(parseArgs(["acme.com", "--selectors=none"])).toMatchObject({
+ selectors: [],
+ });
+ });
+
+ it("refuses what it cannot grade", () => {
+ expect(parseArgs([])).toMatchObject({ kind: "error" });
+ expect(parseArgs(["not a domain"])).toMatchObject({ kind: "error" });
+ expect(parseArgs(["acme.com", "--wat"])).toMatchObject({ kind: "error" });
+ expect(parseArgs(["a.com", "b.com"])).toMatchObject({ kind: "error" });
+ });
+});
+
+describe("runCli", () => {
+ it("grades over the injected resolver and prints the report", async () => {
+ const { handle, stdout } = io();
+ const code = await runCli(["acme.com", "--selectors", "google"], handle);
+
+ expect(code).toBe(0);
+ expect(stdout()).toContain("A+");
+ expect(stdout()).toContain("protected");
+ expect(stdout()).toContain("SPF ends in -all");
+ expect(stdout()).toContain("Google Workspace");
+ });
+
+ it("prints the whole grade as JSON with --json", async () => {
+ const { handle, stdout } = io();
+ const code = await runCli(
+ ["acme.com", "--json", "--selectors", "google"],
+ handle,
+ );
+
+ expect(code).toBe(0);
+ const grade = JSON.parse(stdout());
+ expect(grade.letter).toBe("A+");
+ expect(grade.verdict).toBe("protected");
+ expect(grade.recommendations[0].id).toBe("keep-watching");
+ });
+
+ it("exits 1 under --strict when the domain is not protected", async () => {
+ const { handle } = io({
+ resolver: staticResolver({
+ "acme.com": { TXT: ["v=spf1 -all"], MX: ["aspmx.l.google.com"] },
+ "_dmarc.acme.com": { TXT: ["v=DMARC1; p=none"] },
+ }),
+ });
+ expect(await runCli(["acme.com", "--selectors=none"], handle)).toBe(0);
+ expect(
+ await runCli(["acme.com", "--selectors=none", "--strict"], handle),
+ ).toBe(1);
+ });
+
+ it("exits 2 when DNS cannot answer, and says why on stderr", async () => {
+ const { handle, stderr } = io({
+ resolver: () => Promise.reject(new Error("socket closed")),
+ });
+ expect(await runCli(["acme.com"], handle)).toBe(2);
+ expect(stderr()).toContain("socket closed");
+ });
+
+ it("exits 2 on a usage error", async () => {
+ const { handle, stderr } = io();
+ expect(await runCli([], handle)).toBe(2);
+ expect(stderr()).toContain("mailgrade:");
+ });
+
+ it("keeps ANSI out of the output unless colour is on", async () => {
+ const plain = io();
+ await runCli(["acme.com", "--selectors=none"], plain.handle);
+ expect(plain.stdout()).not.toContain("\u001b[");
+
+ const colored = io({ color: true });
+ await runCli(["acme.com", "--selectors=none"], colored.handle);
+ expect(colored.stdout()).toContain("\u001b[");
+
+ const flagged = io({ color: true });
+ await runCli(["acme.com", "--selectors=none", "--no-color"], flagged.handle);
+ expect(flagged.stdout()).not.toContain("\u001b[");
+ });
+});
+
+describe("renderGrade", () => {
+ it("renders the README's terminal example, exactly as printed", () => {
+ const grade = gradeRecords({
+ domain: "acme.com",
+ txt: ["v=spf1 include:_spf.google.com -all"],
+ dmarc: ["v=DMARC1; p=none; rua=mailto:dmarc@acme.com"],
+ dkimSelectors: ["google"],
+ mx: ["aspmx.l.google.com"],
+ });
+ expect(renderGrade(grade, { columns: 78 })).toBe(
+ [
+ "",
+ " acme.com",
+ "",
+ " D spoofable",
+ " mail claiming to be this domain can reach inboxes",
+ "",
+ " ✓ SPF SPF ends in -all",
+ " ✓ DKIM DKIM keys published (google)",
+ " ✗ DMARC DMARC is monitoring only (p=none)",
+ " · MX Google Workspace",
+ "",
+ " → Graduate DMARC from p=none. Monitoring mode was designed as a",
+ " transition, not a destination: once the reports show your real senders",
+ " passing, move to p=quarantine, then p=reject.",
+ "",
+ ]
+ .map((line) => `${line}\n`)
+ .join(""),
+ );
+ });
+
+ it("wraps recommendation prose to the terminal width", () => {
+ const grade = gradeRecords({
+ domain: "acme.com",
+ txt: ["v=spf1 include:_spf.google.com ~all"],
+ dmarc: ["v=DMARC1; p=none; rua=mailto:d@acme.com"],
+ mx: ["aspmx.l.google.com"],
+ });
+ const narrow = renderGrade(grade, { columns: 50 });
+ for (const line of narrow.split("\n")) {
+ expect(line.length).toBeLessThanOrEqual(50);
+ }
+ expect(narrow).toContain("Graduate DMARC");
+ });
+});
diff --git a/test/doh.test.ts b/test/doh.test.ts
index e1f8c64..fecda11 100644
--- a/test/doh.test.ts
+++ b/test/doh.test.ts
@@ -4,7 +4,7 @@
import { describe, expect, it } from "vitest";
-import { checkDomain, DnsError, resolveDomain, type FetchLike } from "../src/doh.ts";
+import { gradeDomain, DnsError, resolveDomain, type FetchLike } from "../src/doh.ts";
import { staticResolver } from "../src/verify/resolver.ts";
type Zone = Record;
@@ -136,7 +136,7 @@ describe("resolveDomain", () => {
describe("bring your own DNS", () => {
it("routes every lookup through an injected resolver instead of DoH", async () => {
- const grade = await checkDomain("acme.com", {
+ const grade = await gradeDomain("acme.com", {
selectors: ["google"],
fetch: httpFailure, // proves fetch is never touched
resolver: staticResolver({
@@ -154,9 +154,9 @@ describe("bring your own DNS", () => {
});
});
-describe("checkDomain", () => {
+describe("gradeDomain", () => {
it("resolves and grades in one call", async () => {
- const grade = await checkDomain("acme.com", {
+ const grade = await gradeDomain("acme.com", {
selectors: ["google"],
fetch: stubFetch({
"TXT acme.com": { answers: [[TXT, '"v=spf1 include:_spf.google.com -all"']] },
diff --git a/test/exports.test.ts b/test/exports.test.ts
index 626f8a2..6b19955 100644
--- a/test/exports.test.ts
+++ b/test/exports.test.ts
@@ -15,66 +15,40 @@ import * as dkim from "../src/dkim.ts";
import * as domain from "../src/domain.ts";
import * as verify from "../src/verify/index.ts";
import * as nodeDns from "../src/node-dns.ts";
+import * as doh from "../src/doh.ts";
const names = (m: object) => Object.keys(m).toSorted();
describe("entry points", () => {
- it("exposes the headline call and the pieces it is made of", () => {
+ it("exposes the documented API, and only that", () => {
expect(names(root)).toEqual([
- "DEFAULT_OPTIONS",
- "DEFAULT_RI",
"DKIM_SELECTORS",
- "DMARC_TAGS",
- "IMPERSONATED_DOMAINS",
- "INVISIBLE",
+ "DnsError",
"aligns",
"analyzeDkim",
"analyzeDmarc",
"analyzeHeaders",
"analyzeSpf",
- "blankCheck",
"buildDmarcRecord",
- "cleanAddresses",
+ "gradeDomain",
"coerceDomain",
- "decodeEncodedWords",
- "detectImpersonation",
- "dkimCheck",
"dkimHost",
- "dmarcCheck",
- "dmarcGrade",
"dmarcHost",
- "domainLabel",
+ "dohResolver",
"domainOf",
"domainVerdict",
- "editDistance",
- "externalDestinations",
- "gradeDomain",
- "hasMixedScript",
- "headerValue",
- "headerValues",
- "headerVerdict",
- "identifierDomain",
+ "gradeRecords",
"isDkimKey",
"isDmarcRecord",
- "isDomainName",
- "isPrivateIp",
"isSpfRecord",
"mailProvider",
- "parseAddress",
- "parseAuthResults",
"parseDmarcRecord",
- "parseHeaders",
- "parseReceived",
"registrableDomain",
- "reportDomain",
- "recordTags",
+ "resolveDomain",
"reviewDmarc",
"rolloutPlan",
- "spfCheck",
"staticResolver",
- "stripComments",
"toAuthResults",
- "txtChunks",
"verifyDkim",
"verifyDmarc",
"verifyMessage",
@@ -101,18 +75,56 @@ describe("entry points", () => {
"verifyMessage",
"verifySpf",
]);
+ expect(names(doh)).toEqual([
+ "DEFAULT_ENDPOINT",
+ "DnsError",
+ "defaultResolver",
+ "dohResolver",
+ "gradeDomain",
+ "resolveDomain",
+ ]);
});
it("exposes the Node resolver only from its own entry point", () => {
expect(names(nodeDns)).toEqual(["nodeResolver"]);
- for (const module of [root, spf, dkim, domain, dmarc, headers, verify]) {
+ for (const module of [root, spf, dkim, domain, dmarc, headers, verify, doh]) {
expect(names(module)).not.toContain("nodeResolver");
}
});
- it("keeps the network code out of every entry point but its own", () => {
- for (const module of [root, spf, dkim, domain, dmarc, headers, verify]) {
- expect(names(module)).not.toContain("checkDomain");
+ // The headline calls belong on the headline import: a reader reaching for
+ // this library types `mailgrade`, not `mailgrade/doh`. What has to stay out
+ // of the root is `node:`, which is a bundler problem rather than a taste
+ // one, and that is pinned separately below.
+ it("puts the headline calls on the root import", () => {
+ expect(names(root)).toContain("gradeDomain");
+ expect(names(root)).toContain("verifyMessage");
+ expect(names(root)).toContain("analyzeHeaders");
+ });
+
+ // The root is curated by hand, so this is the test that notices when
+ // somebody reaches for `export *` again and the surface doubles.
+ it("keeps the helpers off the root and on their own entry point", () => {
+ for (const name of [
+ "blankCheck",
+ "editDistance",
+ "headerValue",
+ "parseHeaders",
+ "spfCheck",
+ "stripComments",
+ "txtChunks",
+ ]) {
+ expect(names(root)).not.toContain(name);
+ }
+ expect(names(headers)).toContain("blankCheck");
+ expect(names(headers)).toContain("editDistance");
+ expect(names(headers)).toContain("parseHeaders");
+ expect(names(dmarc)).toContain("txtChunks");
+ });
+
+ it("keeps the network code out of the entry points that promise none", () => {
+ for (const module of [spf, dkim, domain, dmarc, headers]) {
+ expect(names(module)).not.toContain("gradeDomain");
expect(names(module)).not.toContain("resolveDomain");
expect(names(module)).not.toContain("dohResolver");
}
diff --git a/test/readme.test.ts b/test/readme.test.ts
index 8ea84ac..adc1803 100644
--- a/test/readme.test.ts
+++ b/test/readme.test.ts
@@ -5,14 +5,23 @@
* cheapest way to find out. Change an example here and in the README together.
*/
+import { createHash, createSign, generateKeyPairSync } from "node:crypto";
import { expect, it } from "vitest";
-import { gradeDomain } from "../src/index.ts";
-import { buildDmarcRecord, reviewDmarc, rolloutPlan } from "../src/dmarc/index.ts";
-import { analyzeHeaders } from "../src/headers/index.ts";
-import { staticResolver, verifySpf } from "../src/verify/index.ts";
+
+// Every import the README makes is from the root, so this is where a name
+// that quietly left the public barrel shows up.
+import {
+ analyzeHeaders,
+ buildDmarcRecord,
+ gradeRecords,
+ reviewDmarc,
+ rolloutPlan,
+ staticResolver,
+ verifyMessage,
+} from "../src/index.ts";
it("grade example", () => {
- const grade = gradeDomain({
+ const grade = gradeRecords({
domain: "acme.com",
txt: ["v=spf1 include:_spf.google.com ~all"],
dmarc: ["v=DMARC1; p=none; rua=mailto:dmarc@acme.com"],
@@ -20,6 +29,7 @@ it("grade example", () => {
mx: ["aspmx.l.google.com"],
});
expect(grade.verdict).toBe("spoofable");
+ expect(grade.letter).toBe("D");
expect(grade.dmarc.id).toBe("dmarc-monitor");
expect(grade.dmarc.headline).toBe("DMARC is monitoring only (p=none)");
expect(grade.recommendations[0]!.id).toBe("graduate-dmarc");
@@ -37,9 +47,8 @@ it("dmarc example", () => {
});
it("headers example", () => {
- const raw = `Received: from vps.hosting.example (vps.hosting.example [185.22.11.9])
- by mx.acme.example with ESMTP id 4b2c1f;
- Mon, 2 Jun 2025 03:41:02 +0000
+ const headers = `Received: from vps.hosting.example (185.22.11.9) by mx.acme.example;
+ Mon, 2 Jun 2025 03:41:02 +0000
Authentication-Results: mx.acme.example;
spf=pass smtp.mailfrom=billing@acme-invoices.example;
dkim=none;
@@ -47,13 +56,10 @@ Authentication-Results: mx.acme.example;
Return-Path:
From: Acme Finance
Reply-To: finance.acme@mail-secure.example
-To: bob@acme.example
Subject: Overdue invoice
-Date: Mon, 2 Jun 2025 03:41:00 +0000
-X-PHP-Originating-Script: 0:sendmail.php
+X-PHP-Originating-Script: 0:sendmail.php`;
-body`;
- const result = analyzeHeaders(raw);
+ const result = analyzeHeaders(headers);
expect(result.verdict).toBe("suspicious");
expect(result.spf.result).toBe("pass");
expect(result.spf.aligned).toBe(false);
@@ -62,15 +68,61 @@ body`;
]);
});
-it("verify example", async () => {
- const spf = await verifySpf({
- ip: "192.0.2.10",
- sender: "bob@example.com",
+/** Relaxed header canonicalization, which is what the signature covers. */
+const relax = (header: string) =>
+ header.replace(/^([^:]+):\s*/, (_, name: string) => `${name.toLowerCase()}:`);
+
+it("verify example: the header block, exactly as printed", async () => {
+ const rsa = generateKeyPairSync("rsa", { modulusLength: 2048 });
+
+ const from = "From: Billing ";
+ const to = "To: customer@acme.com";
+ const subject = "Subject: Invoice 42";
+ const body = "Please find the invoice attached.\r\n";
+
+ const bh = createHash("sha256").update(Buffer.from(body, "latin1")).digest("base64");
+ const value =
+ `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=mail; ` +
+ `h=from:to:subject; bh=${bh}; b=`;
+ const signed =
+ [from, to, subject].map((h) => `${relax(h)}\r\n`).join("") +
+ relax(`DKIM-Signature: ${value}`);
+ const signer = createSign("RSA-SHA256");
+ signer.update(Buffer.from(signed, "latin1"));
+ const b = signer.sign(rsa.privateKey).toString("base64");
+
+ const rawMessage =
+ `DKIM-Signature: ${value}${b}\r\n${from}\r\n${to}\r\n${subject}\r\n\r\n${body}`;
+
+ const result = await verifyMessage(rawMessage, {
+ ip: "203.0.113.50",
+ sender: "bounce@example.com",
+ helo: "mail.example.com",
+ mta: "mx.acme.com",
resolver: staticResolver({
- "example.com": { TXT: ["v=spf1 ip4:192.0.2.0/24 -all"] },
+ "example.com": { TXT: ["v=spf1 ip4:203.0.113.0/24 -all"] },
+ "_dmarc.example.com": { TXT: ["v=DMARC1; p=reject"] },
+ "mail._domainkey.example.com": {
+ TXT: [
+ `v=DKIM1; k=rsa; p=${rsa.publicKey
+ .export({ type: "spki", format: "der" })
+ .toString("base64")}`,
+ ],
+ },
}),
});
- expect(spf.result).toBe("pass");
- expect(spf.mechanism).toBe("+ip4:192.0.2.0/24");
- expect(spf.lookups).toBe(0);
+
+ expect(result.dmarc?.result).toBe("pass");
+ // What to do with this message, not the policy the domain publishes: it
+ // passed, so nothing.
+ expect(result.dmarc?.disposition).toBe("none");
+ expect(result.headers).toBe(
+ "Received-SPF: pass (mx.acme.com: domain of bounce@example.com designates\r\n" +
+ " 203.0.113.50 as permitted sender) client-ip=203.0.113.50;\r\n" +
+ ' envelope-from="bounce@example.com"; helo=mail.example.com\r\n' +
+ "Authentication-Results: mx.acme.com;\r\n" +
+ " spf=pass smtp.mailfrom=bounce@example.com;\r\n" +
+ " dkim=pass header.d=example.com header.s=mail;\r\n" +
+ " dmarc=pass header.from=example.com\r\n",
+ );
});
diff --git a/test/spec.test.ts b/test/spec.test.ts
index 472618b..7a904d3 100644
--- a/test/spec.test.ts
+++ b/test/spec.test.ts
@@ -24,7 +24,7 @@ import {
} from "../src/dmarc/record.ts";
import { dmarcGrade, reviewDmarc, rolloutPlan } from "../src/dmarc/review.ts";
import { aligns, coerceDomain, registrableDomain } from "../src/domain.ts";
-import { gradeDomain, type DomainRecords } from "../src/grade.ts";
+import { gradeRecords, type DomainRecords } from "../src/grade.ts";
import { analyzeHeaders } from "../src/headers/analyze.ts";
import { detectImpersonation, editDistance } from "../src/headers/impersonation.ts";
import {
@@ -59,7 +59,7 @@ const PROJECT: Record) => unknown> = {
aligns: (i) => ({ aligns: aligns(i["a"], i["b"]) }),
analyzeSpf: (i) => {
- const r = gradeDomain({ domain: "x.example", txt: i["txt"], dmarc: [] }).spf;
+ const r = gradeRecords({ domain: "x.example", txt: i["txt"], dmarc: [] }).spf;
return {
id: r.id,
status: r.status,
@@ -118,10 +118,11 @@ const PROJECT: Record) => unknown> = {
};
},
- gradeDomain: (i) => {
- const g = gradeDomain(i as DomainRecords);
+ gradeRecords: (i) => {
+ const g = gradeRecords(i as DomainRecords);
return {
verdict: g.verdict,
+ letter: g.letter,
spfId: g.spf.id,
dmarcId: g.dmarc.id,
dkimId: g.dkim.id,
diff --git a/test/verify-headers.test.ts b/test/verify-headers.test.ts
new file mode 100644
index 0000000..3549e17
--- /dev/null
+++ b/test/verify-headers.test.ts
@@ -0,0 +1,155 @@
+/**
+ * The headers `verifyMessage` hands back, which are the whole point of
+ * verifying on the way in: a receiver prepends them and every downstream
+ * filter reads them instead of redoing the work.
+ *
+ * The test that matters most here is the round trip. A header this library
+ * generates has to be one `analyzeHeaders` parses, folding included, or the
+ * two halves of the package disagree about the same message.
+ */
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { analyzeHeaders } from "../src/headers/index.ts";
+import { staticResolver, verifyMessage } from "../src/verify/index.ts";
+
+const from = "From: Billing ";
+const message = `${from}\r\nTo: customer@example.net\r\nSubject: Invoice 42\r\n\r\nbody\r\n`;
+
+const resolver = staticResolver({
+ "example.com": { TXT: ["v=spf1 ip4:192.0.2.0/24 -all"] },
+ "_dmarc.example.com": { TXT: ["v=DMARC1; p=reject"] },
+});
+
+const arrived = {
+ resolver,
+ ip: "192.0.2.7",
+ sender: "bounces@example.com",
+ helo: "mail.example.com",
+ mta: "mx.example.net",
+} as const;
+
+/** What a receiving parser does before reading a field: undo the folding. */
+const unfold = (headers: string) => headers.replace(/\r\n[ \t]/g, " ");
+
+describe("generated headers", () => {
+ it("stamps Received-SPF and Authentication-Results, ready to prepend", async () => {
+ const r = await verifyMessage(message, arrived);
+
+ expect(r.authResults).toBe(
+ "mx.example.net; spf=pass smtp.mailfrom=bounces@example.com; " +
+ "dmarc=pass header.from=example.com",
+ );
+ expect(r.headers).toBe(
+ "Received-SPF: pass (mx.example.net: domain of bounces@example.com designates\r\n" +
+ " 192.0.2.7 as permitted sender) client-ip=192.0.2.7;\r\n" +
+ ' envelope-from="bounces@example.com"; helo=mail.example.com\r\n' +
+ "Authentication-Results: mx.example.net;\r\n" +
+ " spf=pass smtp.mailfrom=bounces@example.com;\r\n" +
+ " dmarc=pass header.from=example.com\r\n",
+ );
+ });
+
+ it("prepends onto a message and reads back the same verdict", async () => {
+ const r = await verifyMessage(message, arrived);
+ const delivered = `${r.headers}${message}`;
+
+ const read = analyzeHeaders(delivered);
+ expect(read.spf.result).toBe("pass");
+ expect(read.spf.aligned).toBe(true);
+ expect(read.dmarc.result).toBe("pass");
+ expect(read.verdict).toBe("authentic");
+ });
+
+ it("folds every generated line inside the RFC 5322 limit", async () => {
+ const r = await verifyMessage(message, arrived);
+ for (const line of r.headers.split("\r\n")) {
+ expect(line.length).toBeLessThanOrEqual(78);
+ }
+ });
+
+ it("names the authserv-id mailgrade when no mta is given", async () => {
+ const r = await verifyMessage(message, { resolver });
+ expect(r.authResults.startsWith("mailgrade; ")).toBe(true);
+ });
+
+ it("omits Received-SPF when there was no IP to evaluate", async () => {
+ const r = await verifyMessage(message, { resolver, mta: "mx.example.net" });
+ expect(r.headers).not.toContain("Received-SPF");
+ expect(r.headers.startsWith("Authentication-Results:")).toBe(true);
+ });
+
+ it("reports a failure honestly in the Received-SPF comment", async () => {
+ const r = await verifyMessage(message, { ...arrived, ip: "203.0.113.9" });
+ expect(r.spf?.result).toBe("fail");
+ expect(unfold(r.headers)).toContain(
+ "Received-SPF: fail (mx.example.net: domain of bounces@example.com " +
+ "does not designate 203.0.113.9 as permitted sender)",
+ );
+ });
+});
+
+describe("hostile values", () => {
+ // Every value in these headers arrived off the wire, and a receiver
+ // prepends the block verbatim: a CR or LF that survives is a forged header,
+ // not a value.
+ it("refuses to let a CRLF in the envelope become a new header field", async () => {
+ const r = await verifyMessage(message, {
+ ...arrived,
+ sender: "bounces@example.com\r\nX-Evil: injected",
+ helo: "mail.example.com\nX-Also-Evil: yes",
+ });
+
+ // The CRLFs are flattened to spaces, so "X-Evil" survives only inside a
+ // value. What must not happen is a line of the block starting anything
+ // but a field this library generates or a folded continuation of one.
+ for (const line of r.headers.split("\r\n").filter((l) => l !== "")) {
+ expect(line).toMatch(/^(?:Received-SPF:|Authentication-Results:| )/);
+ }
+
+ // And a parser reading the prepended block sees exactly two fields.
+ const fieldNames = `${r.headers}\r\n`.match(/^[!-9;-~]+:/gm) ?? [];
+ expect(fieldNames).toEqual(["Received-SPF:", "Authentication-Results:"]);
+ });
+
+ it("keeps a quoted local part with a semicolon whole across the fold", async () => {
+ const r = await verifyMessage(message, {
+ ...arrived,
+ sender: '"a;b"@example.com',
+ });
+
+ // Escaped for the quoted-string, and unfolded back to one piece: the
+ // semicolon never became a segment boundary and no space crept inside.
+ expect(unfold(r.headers)).toContain('envelope-from="\\"a;b\\"@example.com"');
+ for (const line of r.headers.split("\r\n")) {
+ expect(line.length).toBeLessThanOrEqual(78);
+ }
+ });
+});
+
+describe("the default resolver", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ // The whole ergonomic claim of 2.0 is that this call needs no setup. If the
+ // default ever stops being wired up, every example in the README breaks and
+ // nothing else in the suite would notice, because everything else injects.
+ it("resolves over DoH when the caller names none", async () => {
+ const fetched: string[] = [];
+ vi.stubGlobal("fetch", (url: string) => {
+ fetched.push(url);
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ Status: 0, Answer: [] }),
+ });
+ });
+
+ const r = await verifyMessage(message, { ip: "192.0.2.7", sender: "a@b.example" });
+
+ expect(fetched.some((url) => url.startsWith("https://cloudflare-dns.com/dns-query"))).toBe(true);
+ expect(fetched.some((url) => url.includes("_dmarc.example.com"))).toBe(true);
+ expect(r.dmarc?.result).toBe("none");
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index cef0280..853db53 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,6 +7,7 @@
"allowImportingTsExtensions": true,
"moduleDetection": "force",
"verbatimModuleSyntax": true,
+ "resolveJsonModule": true,
"erasableSyntaxOnly": true,
"isolatedDeclarations": true,
"declaration": true,
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 72e01f5..cf5ed7f 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -3,6 +3,7 @@ import { defineConfig, type UserConfig } from "tsdown";
const config: UserConfig = defineConfig({
entry: [
"src/index.ts",
+ "src/cli.ts",
"src/spf.ts",
"src/dkim.ts",
"src/domain.ts",