From bfdbdb6056b0a076483710e078f8cdb23fcd1311 Mon Sep 17 00:00:00 2001
From: Ignace Maes <10243652+IgnaceMaes@users.noreply.github.com>
Date: Tue, 1 Sep 2026 20:35:54 +0200
Subject: [PATCH] feat!: one import, a letter grade, and a CLI
Rework the public interface around the calls people actually make. The
headline calls (gradeDomain, verifyMessage, analyzeHeaders) are on the root
barrel, resolver is optional everywhere and defaults to DNS over HTTPS, and
DomainGrade carries a letter from "A+" to "F" with the rubric in the spec
corpus.
Adds a bin: npx mailgrade prints the grade, with --json for scripts
and --strict for CI. verifyMessage takes mta and returns a folded,
CRLF-terminated Received-SPF and Authentication-Results block, sanitised
against header injection.
BREAKING CHANGE: the root barrel is a curated list rather than export *, the
spec corpus is no longer published in the tarball, checkDomain is now
gradeDomain and the old gradeDomain is gradeRecords.
Co-Authored-By: Claude Opus 5 (1M context)
---
.changeset/wide-moons-shave.md | 57 +++++++
CHANGELOG.md | 12 +-
README.md | 231 ++++++++++++++---------------
package.json | 19 ++-
scripts/smoke.mjs | 6 +-
spec/README.md | 2 +-
spec/grade.json | 99 ++++++++++++-
src/cli.ts | 20 +++
src/cli/run.ts | 262 +++++++++++++++++++++++++++++++++
src/dmarc/analyze.ts | 11 ++
src/doh-resolver.ts | 132 +++++++++++++++++
src/doh.ts | 153 +++++--------------
src/grade.ts | 83 +++++++++--
src/index.ts | 115 +++++++++++++--
src/verify/dkim.ts | 9 +-
src/verify/dmarc.ts | 9 +-
src/verify/index.ts | 260 +++++++++++++++++++++++++++++---
src/verify/spf.ts | 6 +-
test/cli.test.ts | 182 +++++++++++++++++++++++
test/doh.test.ts | 8 +-
test/exports.test.ts | 86 ++++++-----
test/readme.test.ts | 94 +++++++++---
test/spec.test.ts | 9 +-
test/verify-headers.test.ts | 155 +++++++++++++++++++
tsconfig.json | 1 +
tsdown.config.ts | 1 +
26 files changed, 1647 insertions(+), 375 deletions(-)
create mode 100644 .changeset/wide-moons-shave.md
create mode 100644 src/cli.ts
create mode 100644 src/cli/run.ts
create mode 100644 src/doh-resolver.ts
create mode 100644 test/cli.test.ts
create mode 100644 test/verify-headers.test.ts
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",