@sdxc/email-auth
v2026.11.3
Published
Parse and evaluate a domain's email-authentication DNS policies: DMARC, SPF, MTA-STS, TLS-RPT, BIMI and DKIM
Readme
@sdxc/email-auth
Parse and evaluate a domain's email-authentication DNS policies: DMARC, SPF, MTA-STS, TLS-RPT, BIMI and DKIM.
Every parser is pure and returns a Result; every lookup goes through DNS over HTTPS, so the
same checks run in a Cloudflare Worker, a server or a test. DMARC follows RFC 9989, with the
np, psd and t tags and the DNS Tree Walk in place of the public suffix list.
Installation
npm add @sdxc/email-authResults come from @sdxc/result and lookups
from @sdxc/doh; both install alongside it.
Usage
Discover a domain's DMARC policy
import { discoverDmarc } from "@sdxc/email-auth";
import { isSuccess } from "@sdxc/result";
let discovery = await discoverDmarc("news.example.com");
if (isSuccess(discovery)) {
discovery.data.policy; // "quarantine": the record's sp=, since news.example.com has none
discovery.data.policyDomain; // "example.com"
discovery.data.organizationalDomain; // "example.com"
discovery.data.record?.testing; // t=y
}policy is null when DMARC does not apply. A resolver failure is the Failure, never "no
policy".
Count SPF lookups, or evaluate a sender
import { analyzeSpf, checkHost } from "@sdxc/email-auth";
let audit = await analyzeSpf("example.com");
// audit.data.lookups: DNS-querying terms across every include; over 10 is a permerror
// audit.data.problems: [{ code: "too-many-lookups", lookups: 13 }, …]
let evaluation = await checkHost({
ip: "192.0.2.10",
domain: "example.com",
sender: "[email protected]",
});
evaluation.result; // "pass" | "fail" | "softfail" | "neutral" | "none" | "temperror" | "permerror"
evaluation.mechanism; // "mx"Read MTA-STS and TLS-RPT
import { discoverMtaSts, discoverTlsRpt, fetchMtaStsPolicy } from "@sdxc/email-auth";
let record = await discoverMtaSts("example.com"); // { status: "found", record: { id } }
let policy = await fetchMtaStsPolicy("example.com"); // { mode: "enforce", mx: […], maxAge }
let reporting = await discoverTlsRpt("example.com"); // { status: "found", record: { rua } }The policy file is fetched like a sending MTA does: HTTPS, no redirects, a 200 text/plain
body under 64 KiB, through @sdxc/outbound.
Check whether a domain qualifies for BIMI
import { bimiDmarcShortfalls, discoverBimi, discoverDmarc } from "@sdxc/email-auth";
let bimi = await discoverBimi("example.com"); // { status: "found", record: { location, evidence } }
let dmarc = await discoverDmarc("example.com");
// bimiDmarcShortfalls(dmarc.data): [] when DMARC enforces, else ["policy-none"], ["testing"], …BIMI follows draft-brand-indicators-for-message-identification-14. The logo URL is validated as HTTPS and SVG, and never fetched.
Parse records and reports you already have
import {
parseDkimRecord,
parseDmarcRecord,
parseDmarcReport,
parseSpfRecord,
} from "@sdxc/email-auth";
parseDmarcRecord("v=DMARC1; p=reject; rua=mailto:[email protected]");
parseSpfRecord("v=spf1 mx include:_spf.example.net -all");
parseDkimRecord("v=DKIM1; k=rsa; p=MIGfMA0GCSq…");
parseDmarcReport(xml); // an RFC 9990 aggregate report, already decompressedAudit a whole domain
import { checkEmailAuth } from "@sdxc/email-auth";
let report = await checkEmailAuth("example.com", { dkimSelectors: ["google"] });
report.ok; // false when any policy is missing, invalid or unreadable
report.results; // [{ policy: "dmarc", verdict: "pass", summary: "p=reject", problems: [] }, …]Command line
bunx @sdxc/email-auth check example.comWith @sdxc/kit installed, sdxc pkg email-auth … runs the same command line, using the version your project installs.
check grades DMARC, SPF, MTA-STS (with its policy file and MX coverage), TLS-RPT, BIMI and
DKIM for each --selector. It exits 0 when every policy holds, 1 when one is missing, invalid
or unreadable, and 2 on a usage error.
sdxc-email-auth check example.com --selector google --selector s1
sdxc-email-auth check example.com --skip bimi,tls-rpt --jsonsdxc-email-auth check --help lists every flag.
API
DMARC
parseDmarcRecord(text): a record, or a failure whenv=DMARC1is not first. Invalid values fall back to their defaults and are listed inissues, as receivers do.appliedDmarcPolicy(record, scope?): the policy for the domain, an existing subdomain or a missing one, withnp→sp→pfallback andnonefor a reporting-only record.discoverDmarc(domain, options?): policy discovery: the record, where it was published, and the policy a receiver applies.dmarcTreeWalk(domain),walkDmarcTree(domain, options?): the names a Tree Walk queries, and the walk itself.selectOrganizationalDomain(domain, found),findOrganizationalDomain(domain, options?): the Organizational Domain from a walk's records, or by running one.isAligned(authorDomain, identifier, mode, options?): strict or relaxed identifier alignment.isDmarcRecord(text): whether a TXT record starts withv=DMARC1.parseDmarcReport(xml): an aggregate report, RFC 9990 or RFC 7489 shaped.
SPF
parseSpfRecord(text),isSpfRecord(text): the record's mechanisms and modifiers.checkHost(input, options?): RFC 7208check_host(), with the 10-lookup limit, the void-lookup limit (maxVoidLookups, default 2), macros andexpexplanations.analyzeSpf(domain, options?): the record, its lookup count across includes and redirects, and its problems.
BIMI
parseBimiRecord(text),isBimiRecord(text): the logo (l=), evidence (a=), local-part selectors (lps=) and avatar preference (avp=);declinedfor an explicitl=; a=;.discoverBimi(domain, options?): the record at<selector>._bimi.<domain>, then at the Organizational Domain; passorganizationalDomainto skip the Tree Walk.bimiDmarcShortfalls(discovery): why a DMARC discovery falls short of BIMI's requirements.
MTA-STS, TLS-RPT and DKIM
parseMtaStsRecord(text),parseMtaStsPolicy(text),discoverMtaSts(domain, options?),fetchMtaStsPolicy(domain, options?),mtaStsPolicyUrl(domain),matchesMxPattern(host, patterns).parseTlsRptRecord(text),discoverTlsRpt(domain, options?).parseDkimRecord(text),lookupDkimKey(domain, selector, options?): DKIM1 key records (RFC 6376). DKIM2 (draft-ietf-dkim-dkim2-spec) is being tracked and not parsed yet.checkEmailAuth(domain, options?): every policy above gradedpass,warn,failorerror, withdkimSelectors,bimiSelectorandskip;EMAIL_AUTH_POLICIESlists them.RecordParseError: every parser's failure, with theprotocolit was read as.PolicyFetchError: a policy file fetch that failed, with the step inreason.
Every lookup takes { resolver, signal, timeoutMs }, passed to @sdxc/doh.
Versioning
Releases are dated rather than semantic. A version is the UTC date it was published,
written YYYY.M.D, so 2026.9.4 is the release from 4 September 2026. At most one
release goes out per day.
Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.
Depend on one exact date, and move it when you are ready to take the change:
{
"dependencies": {
"@sdxc/email-auth": "2026.9.4"
}
}A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.
License
MIT
