first commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# dmarc-checker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements DMARC (RFC 7489) policy evaluation on top of an upstream
|
||||
SPF result and DKIM verification. Looks up the `_dmarc.<from-domain>`
|
||||
TXT record through an injectable resolver, parses its policy and
|
||||
alignment tags, then determines whether at least one of SPF or DKIM
|
||||
passes **and** aligns with the From header's organisational domain.
|
||||
|
||||
## Features
|
||||
|
||||
- `parseDmarcRecord` returns a strongly-typed `DmarcRecord` with all
|
||||
standard tags (`p`, `sp`, `adkim`, `aspf`, `pct`, `rua`, `ruf`).
|
||||
- `checkDmarc` accepts pre-computed SPF and DKIM outcomes so callers
|
||||
can compose authentication pipelines without re-running expensive
|
||||
DNS or crypto work.
|
||||
- Both alignment modes — strict (exact domain match) and relaxed
|
||||
(organisational-domain match) — are supported.
|
||||
- DNS resolver is async and injectable; the package itself never
|
||||
calls into `dns.*` directly.
|
||||
- Records `email_processing.dmarc.result_total` attributed by
|
||||
`result` for every outcome.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkDmarc } from "@suaveplan/email-processing/dmarc";
|
||||
|
||||
const out = await checkDmarc(fromDomain, spfResult, dkimResult, async (d) => {
|
||||
const recs = await dns.resolveTxt(d);
|
||||
return recs.map((p) => p.join(""));
|
||||
});
|
||||
if (out.result === "fail" && out.policy === "reject") {
|
||||
rejectMessage();
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { checkDmarc, parseDmarcRecord } from "@suaveplan/email-processing/dmarc";
|
||||
|
||||
const recs = await dnsResolver(`_dmarc.${domain}`);
|
||||
const dmarcText = recs.find((r) => r.startsWith("v=DMARC1")) ?? "";
|
||||
const parsed = parseDmarcRecord(dmarcText);
|
||||
const aggregateUri = parsed?.reportUri;
|
||||
if (aggregateUri) {
|
||||
await scheduleReport({ domain, uri: aggregateUri });
|
||||
}
|
||||
const result = await checkDmarc(domain, spf, dkim, dnsResolver);
|
||||
metrics.recordDmarc(domain, result.result, result.policy);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `DmarcPolicy` — `"none" | "quarantine" | "reject"`.
|
||||
- `DmarcAlignment` — `"strict" | "relaxed"`.
|
||||
- `DmarcResult` — `"pass" | "fail" | "none"`.
|
||||
- `DmarcRecord` — full parsed record.
|
||||
- `DmarcCheckResult` — `{ result, policy?, spfAligned, dkimAligned, record?, error? }`.
|
||||
- `parseDmarcRecord(txt)` — strict parser, returns `null` on invalid
|
||||
input.
|
||||
- `checkDmarc(fromDomain, spf, dkim, resolver)` — async orchestrator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The DMARC TXT record lookup uses the bare `_dmarc.<domain>` form per
|
||||
RFC 7489 §6.6.3 (organisational-domain fallback is left to a higher
|
||||
layer — callers who want PSL-aware fallback should chain a second
|
||||
`checkDmarc` against the parent domain when the first returns
|
||||
`result: "none"` with `error: "No DMARC record found"`). The
|
||||
`reportDmarc` helper centralises OTel emission so every return path
|
||||
records the outcome exactly once.
|
||||
Reference in New Issue
Block a user