@propgate/dns › The evaluators
The evaluators
Six evaluators exist (delegation, SPF, DKIM, DMARC, MX, CAA) and
runChecks is what a caller actually reaches for. It takes a DomainProfile
(what a domain is for, stated as data) and runs whichever checks that
profile asks for against one EvaluationContext.
import { runChecks, sendingOnly } from "@propgate/dns";
const profile = sendingOnly({
dkimSelectors: ["resend"],
spfInclude: "_spf.resend.com",
});
const result = await runChecks({
domain: "customer.example",
profile,
resolver: { target: { address: "8.8.8.8", port: 53 } },
});
console.log(result.verdict, result.checks.map((check) => check.kind));Two decisions live in runChecks that no single evaluator could make on its
own:
- Checks run concurrently, each against its own context. They share nothing and none is ordered relative to another, so the wall clock is the slowest check rather than the sum of all six.
- A skipped check is not a passing check. A profile that never asks about
DKIM produces no DKIM outcome at all, rather than a green one.
sendingOnly,fullMail, andwebOnlyare the three profile constructors that ship with the package; aDomainProfileis otherwise plain data you can construct and store yourself.
The verdict on the result is the worst of its parts, and indeterminate
ranks above warn: one check that could not run makes the whole answer
uncertain, but a definite failure you did observe is more actionable than
uncertainty about the rest.
The six checks
Rendered from REQUIREMENT_TYPES, the same record the API reference reads, so
this list cannot say something the resolver cannot actually check.
delegation
Every nameserver in the delegation answers authoritatively and agrees. Catches lame delegations and stale NS records, which look like intermittent outages to everyone else.
The non-obvious part: every nameserver in the delegation is queried individually, because a lame delegation is a fact about one server rather than about the zone: resolvers that happen to pick it get SERVFAIL while everyone else is fine.
import { createEvaluationContext, evaluateDelegation } from "@propgate/dns";
const context = createEvaluationContext({
target: { address: "8.8.8.8", port: 53 },
});
// Every nameserver in the delegation is asked the same question. No single
// server can report on another, so this is the one check that queries more
// than one target.
const result = await evaluateDelegation(context, { domain: "customer.example" });
console.log(result.verdict);spf
The SPF record authorises your sending infrastructure and is within the RFC limits.
The non-obvious part: include: is expanded recursively the way a receiving MTA does it, with RFC 7208's ten-lookup and two-void-lookup ceilings counted across the whole expanded tree rather than per record.
import { createEvaluationContext, evaluateSpf } from "@propgate/dns";
const context = createEvaluationContext({
target: { address: "8.8.8.8", port: 53 },
});
// include: is expanded recursively, the way a receiving MTA does it. RFC
// 7208 §4.6.4's ten-lookup and two-void-lookup ceilings are counted across
// the whole expanded tree, not per record — a domain can cross either limit
// without anyone having touched its own SPF record.
const result = await evaluateSpf(context, {
domain: "customer.example",
include: "_spf.resend.com",
});
console.log(result.verdict, result.findings.map((finding) => finding.code));dkim
A selector publishes a valid, usable key. The one requirement type that may appear more than once, because DKIM answers a question per selector rather than per domain.
The non-obvious part: the key is parsed rather than pattern-matched, and DNS names fold case while base64 does not. A published key differing only in letter case from the one you issued is a different key, not a formatting quirk.
import { createEvaluationContext, evaluateDkim } from "@propgate/dns";
const context = createEvaluationContext({
target: { address: "8.8.8.8", port: 53 },
});
// The key is parsed, not pattern-matched against the published record. DNS
// names fold case, so DKIM._domainkey and dkim._domainkey are the same
// query — but the base64 key value does not: expectedPublicKey is compared
// byte-exact, so a key differing only in letter case is reported as a
// different key rather than treated as a match.
const result = await evaluateDkim(context, {
domain: "customer.example",
selector: "resend",
expectedPublicKey: "MIGfMA0GCSqGSIb3DQEBAQUA...",
});
console.log(result.verdict);dmarc
A valid DMARC record is discoverable at the right name. A p=none policy is a warning, not a failure — there is deliberately no way to require a minimum policy, because the evaluator cannot assert one and a requirement nobody can evaluate is a promise this API would not keep.
The non-obvious part: a record is valid at the exact name or, failing that, at the organizational domain. An external rua= destination has to publish its own authorisation record back, or the reports it is sent are silently discarded.
import { createEvaluationContext, evaluateDmarc } from "@propgate/dns";
const context = createEvaluationContext({
target: { address: "8.8.8.8", port: 53 },
});
// Discovery tries the exact name first and only falls back to the
// organizational domain when nothing is published there. checkExternalReports
// (on by default) then confirms any rua= pointed at another organization has
// authorised receiving reports for this one — almost nothing else checks
// this, so unauthorised reports are addressed and silently discarded.
const result = await evaluateDmarc(context, { domain: "mail.customer.example" });
console.log(result.verdict);mx
Mail is deliverable, or correctly declared undeliverable. Whether a null MX is right depends entirely on intent, which no amount of looking at DNS reveals.
The non-obvious part: expectsMail is tri-state (true, false, or omitted) because a null MX is correct on a sending-only domain and a total failure on one that receives mail, and no amount of looking at DNS tells you which.
import { createEvaluationContext, evaluateMx } from "@propgate/dns";
const context = createEvaluationContext({
target: { address: "8.8.8.8", port: 53 },
});
// expectsMail is tri-state: true, false, or omitted. A null MX is the
// correct, deliberate answer on a sending-only domain and a total failure on
// one that receives mail — no amount of looking at DNS distinguishes them,
// so the caller has to say which one this is.
const result = await evaluateMx(context, {
domain: "customer.example",
expectsMail: false,
});
console.log(result.verdict);caa
The CAA tree authorises a named certificate authority. Rejected without an issuer: the evaluator has nothing to compare against, so the requirement could never be reported on.
The non-obvious part: the climb goes to the top-level domain, never to the organizational domain (the Public Suffix List plays no part), and stops at the first CAA RRset it finds. A parent's policy is replaced by a nearer one, never merged with it.
import { createEvaluationContext, evaluateCaa } from "@propgate/dns";
const context = createEvaluationContext({
target: { address: "8.8.8.8", port: 53 },
});
// The search climbs the DNS tree from the name up to, but not including, the
// root (RFC 8659 §3) — never to the organizational domain, and the Public
// Suffix List plays no part. The nearest ancestor with a CAA RRset wins
// outright; policies are never merged up the tree.
const result = await evaluateCaa(context, {
domain: "mail.customer.example",
issuer: "letsencrypt.org",
wildcard: true,
});
console.log(result.verdict);Next
Recipes puts this together into three complete files: check a domain and switch on the verdict, query a specific resolver on a non-standard port, and read the lookups behind a finding well enough to explain it to a customer.