An RFC conformance audit compared every module line-by-line against its
governing specification (SPF: RFC 7208; DMARC: RFC 9989/9990; TLSRPT:
RFC 8460; MTA-STS: RFC 8461; SMTP/MX: RFC 5321/7505/2181; DNSSEC:
RFC 4033-4035; SOA: RFC 1035/2181; BIMI: draft-brand-indicators-14) and
found 81 discrepancies, most confirmed by executing the old code. This
release fixes all of them. Many fixes change validation verdicts —
records the specs call valid are no longer rejected, and records they
call invalid are no longer accepted — hence the major version.
This release also contains everything staged for 5.18.0, which
was never released.
Breaking changes
- MX STARTTLS/TLS testing is now opt-in: pass
--check-mx-tlson the CLI orcheck_mx_tls=Truetocheck_domains(),check_mx(), orget_mx_hosts(). The--skip-tlsflag andskip_tlsparameter are still accepted but do nothing, and passingskip_tlsemits aDeprecationWarning. As a result, MX host results no longer carry thetlsandstarttlskeys, and the CSVtls/starttlscolumns are empty, unless TLS testing is turned on check_dnssec()performs a real chain-of-trust check anchored at the parent zone's DS record instead of verifying a zone's DNSKEY against itself. A zone with no DS at its parent (including "island of security" zones) is insecure per RFC 4033 §4.3 and returnsFalse; a broken zone such as dnssec-failed.org returnsFalsethrough any resolver, where it previously returnedTruethrough non-validating resolvers. Bogus (SERVFAIL with DS present) is now warned about distinctly from unsigned- Unknown and extension tags/fields now parse with a warning instead of failing validation, as each spec requires: SPF unknown modifiers (RFC 7208 §6), DMARC unknown tags (RFC 9989 §4.7), TLSRPT extension fields (RFC 8460 §3), MTA-STS extension fields and policy keys (RFC 8461 §3.2), and BIMI unknown tags (draft §4.3). An unknown field is ignored only when it fits the spec's own extension grammar; a malformed extension name or value still fails the record or policy
- A TXT record unrelated to the record type being queried is now discarded instead of failing validation, per each spec's discard rule: TLSRPT (RFC 8460 §3.1), MTA-STS (RFC 8461 §3.1), BIMI (draft §7.2), and DMARC report authorization records (RFC 9990 §4). Each query also now returns the record carrying the version tag rather than whichever TXT record the resolver listed first. When a real record sits beside the unrelated one, the unrelated record is reported as a warning; when it is the only record present, the check reports that no record exists. DMARC authorization records are discarded silently, and a wildcard authorization record that is unrelated is still an error. Callers catching
UnrelatedTXTRecordFoundAtTLSRPTorUnrelatedTXTRecordFoundAtBIMIshould note that those queries now raiseSMTPTLSReportingRecordNotFoundandBIMIRecordNotFoundinstead - Records the old code wrongly accepted are now invalid: SPF records exceeding the 10-DNS-lookup limit through
a,ptr, or macro-valued terms (RFC 7208 §4.6.4 — the limit was previously only enforced for some mechanism types), SPFincludeof a domain with no SPF record (permerror per RFC 7208 §5.2), MTA-STS policies missing a required key (RFC 8461 §3.2 — the check was dead code), BIMI records missing the requiredl=tag (draft §4.3), lowercasev=dmarc1(RFC 9989 §4.8), and an SPF qualifier on a modifier or a value on theallmechanism (RFC 7208 §12) - MTA-STS and TLSRPT records are now matched case-sensitively, with no whitespace allowed around the version tag's
=, because RFC 8461 §3.1 and RFC 8460 §3 spell those literals with the case-sensitive%snotation of RFC 7405. Records such asV=STSv1; id=…,v = STSv1; id=…,v=STSv1; ID=…, andv=TLSRPTv1; RUA=…parsed before and are now syntax errors. DMARC is unaffected: RFC 9989 §4.8 makes its tag names case-insensitive and allows whitespace around=, which this release starts honoring - The DMARC tree walk now applies RFC 9989 §4.10.2 Organizational Domain selection (
psd=nwins; apsd=yrecord hands off to the record one label below it, warning when none is published there; otherwise the record with the fewest labels applies) instead of stopping at the closest parent record, which selected the opposite policy in the RFC's own worked example
Added
--check-mx-tlsCLI flag andcheck_mx_tlsAPI parameter (see breaking changes)get_mx_record_set()incheckdmarc.utils, returning MX hosts, warnings, null MX status, and the number of MX records in the answer (the newMXRecordSettype), parsed from dnspython rdata instead of text splitting. The record count is what separates "no MX records at all" from "MX records that produced no usable host"- Null MX (RFC 7505) handling: a lone
0 .record yields an explicit "does not accept mail" warning distinct from having no MX records (which now notes the RFC 5321 §5.1 implicit MX rule); a null MX coexisting with other MX records is flagged as an RFC 7505 §3 violation instead of producing an empty-hostname host entry; a root (.) target with a non-zero preference, such as10 ., is warned about as malformed instead of becoming an empty-hostname host entry - MX target sanity warnings: IP-address literals (RFC 5321 §5.1), hostnames failing RFC 5321 §2.3.5 label syntax, and targets that are CNAME aliases (RFC 2181 §10.3)
- DNS over HTTPS (DoH) and DNS over TLS (DoT) support through the existing
nameserversoption, matching parsedmarc (parsedmarc PR #886). Each entry picks its own transport: an IP address means plain DNS on port 53 exactly as before, anhttps://URL means DoH, andtls://ip[:port][#hostname]means DoT, where the port defaults to 853 and the optional#hostnamenames the server's TLS certificate identity, matching systemd-resolved's syntax. DoH queries go through a sharedhttpxclient that honors theHTTP_PROXY/HTTPS_PROXY/NO_PROXYandSSL_CERT_FILE/SSL_CERT_DIRenvironment variables, so checks can run on networks that block outbound DNS but provide an HTTP proxy. The DNSSEC, DNSKEY, and TLSA checks use the configured transports too. Thednspythonrequirement is nowdnspython[doh]>=2.7.0, andhttpx>=0.26.0is a new direct dependency for the shared DoH client
Changed
- Update the GitHub Actions used by the workflows to their latest major versions:
checkoutv7,setup-pythonv7,codecov-actionv7,upload-artifactv7,download-artifactv8,configure-pagesv6,upload-pages-artifactv5, anddeploy-pagesv5. Most now run on Node.js 24 (upload-pages-artifactandcodecov-actionare composite actions). The deprecatedcodecov/test-results-actionis replaced bycodecov/codecov-action@v7withreport_type: test_results, which is the same upload it performed before; no other workflow behavior changes - Renamed identifiers whose names misdescribed what they hold, keeping the old names as deprecated aliases where they were public API:
checkdmarc.dnssec.check_dnssec()replacestest_dnssec(), matching every other module'scheck_*()entry point;test_dnssec()remains as a deprecated alias that warnsget_mx_hosts()takesapproved_mx_hostnames, matchingcheck_mx()andcheck_domains(); the oldapproved_hostnameskeyword remains as a deprecated alias that warns- The CLI accepts
--nameserversas an alias for-n/--nameserver, and--approved-ns/--approved-mxas clearer aliases for--ns/--mx MTASTSQueryResult,MTASTSCheckResult,SMTPTLSReportingQueryResult, andSMTPTLSReportingResultreplace their plural forms, matching the singular BIMI result types; the plural names remain as aliasesMTA_STS_TAGSandSMTP_TLS_REPORTING_TAGSreplace the lowercasemta_sts_tagsandsmtp_rpt_tagsconstants, matchingBIMI_TAGS; the lowercase names remain as aliases
- The
MXHosttype now declares the fields MX host dicts actually carry (addresses,dnssec,tlsa,tls,starttlsas optional keys, withhostnameandpreferencerequired in a newMXRecordbase); the declaredip_addressesfield never existed in any produced data - Removed unused module-level copies of the DMARC grammar internals (
checkdmarc.dmarc.version_tag,tag_value, andSTART), which duplicated the private grammar class and shadow-collided with unrelated locals - Many internal variables renamed so a name no longer changes type or meaning mid-function (split results, parse results, pyleri grammar results, joined display strings); no behavior changes
Removed
- The
pyopenssldependency, as planned in 5.17.5. checkdmarc stopped importing pyOpenSSL in that release; the floor was kept for one release only so upgrades would also move any leftover pyOpenSSL to a version compatible withcryptography50
Fixed
- SPF: the 10-lookup and 2-void-lookup limits are enforced after every counted term rather than in some branches only;
redirectis ignored when anallmechanism is present (RFC 7208 §6.1); av=spf10-style sibling TXT record is discarded instead of hiding a valid record (§4.5); multipleallmechanisms are valid with first-match semantics (§4.6.2); terms afterallare no longer processed or counted; duplicate includes count their lookups like real evaluation; the term-value charset matches the §12 ABNF (so%{ir=}and!parse); exp-only macro lettersc/r/tare rejected outside exp text (§7.2); the uppercaseRtransformer is accepted;expis honored anywhere in the record and its name is matched case-insensitively afterall(§4.6.1);mxCIDR suffixes are stripped before the DNS query instead of being sent as part of the name; dual-CIDR lengths and leading-zero CIDRs are validated; emptya:/mx:/ptr:domain-specs are rejected; an MX host with no A/AAAA records is a warning rather than a void DNS lookup, because §4.6.4 counts void lookups per term query, so records that used to fail the 2-void limit for that reason now pass; and a domain-spec that is not a fully qualified domain name (such asexists:localhost) is warned about, since receivers may treat it as a no-match (§4.8) - DMARC: the grammar matches the §4.8 ABNF (any-length tag names, full value charset — mandatory percent-encoding like
%2Cin report URIs now parses, and barev=DMARC1is valid); non-mailto report URIs are kept with a warning instead of invalidating the record (§4.7) and are validated against the RFC 3986 URI structure (scheme, authority with a real IP-literal when bracketed, path, query, and at most one fragment);rua/rufvalues keep the case they were published with instead of being lowercased along with other tag values, since RFC 3986 makes only the scheme and host case-insensitive; invalidadkim/aspfvalues fall back torwith a warning;fo=0:1is reported as invalid (mutually exclusive), not "redundant", and a repeated value such asfo=1:1is invalid too; both fall back tofo=0with a warning; the record-detection filter tolerates ABNF-legal whitespace and case aroundv=; an unrelated TXT record no longer causes a false "authorization record not found"; an NXDOMAIN on the courtesy apex query no longer discards an already-found record; two citations to nonexistent RFC sections corrected - BIMI: the organizational-domain fallback keeps the caller's selector instead of reverting to
default(draft §7.2 step 6); the SVG-vs-certificate logotype hash check is tag-order-independent and hashes the raw bytes; SVG file size is measured in actual bytes instead ofsys.getsizeofof a decoded string; the raw SVG bytes are handed to the XML parser instead of a lossyerrors="ignore"decode, so malformed bytes fail validation rather than being silently dropped, and non-UTF-8 encodings declared in the XML declaration parse correctly; thepctwarning fires only forp=quarantineper §7.1 step 9; emptylps=parses to an empty list;l=/a=URIs are validated against thebimi-uriABNF (no raw spaces or unencoded commas, at most one#per RFC 3986); an IP-literal host is rejected ina=, whose prose requires an FQDN, but allowed inl=, which imports the URI grammar with no FQDN rule; unknown tags are ignored only when they fit the DKIM tag-value grammar the draft imports (RFC 6376 §3.2: no.or-in tag names, no control characters in values); the PEM-bytes certificate path reads the leaf certificate - MTA-STS: the policy fetch no longer follows redirects and requires HTTP 200 exactly (RFC 8461 §3.3); MX pattern matching is anchored with
*matching a single label, so*.example.comno longer matchesmail.example.com.evil.com(§4.1); a TXT record withoutidreturnsvalid: Falseinstead of crashing; duplicate fields are first-wins with a warning (§3.2); mixed LF/CRLF policies parse;mxvalues are validated against the["*."] DomainABNF with each label capped at 63 octets (RFC 1035 §2.3.4); record detection keys onv=STSv1;including the semicolon, while the grammar-legalv=STSv1 ;form is accepted with a warning that senders applying the §3.1 discard rule literally will ignore the record; a policy field value may contain colons, since §3.2 separates the name from the value at the first colon; the query raisesSPFRecordFoundWhereMTASTSRecordShouldBewhen an SPF record sits at the MTA-STS name;idis capped at 32 alphanumerics;max_ageaccepts only plain digits; a CR that is not part of a CRLF line ending (such as\r\r\n) is a policy syntax error instead of being silently stripped (§3.2); whitespace before a policy field name or between the name and the colon is a syntax error, since the §3.2 delimiter is":" *WSP - TLSRPT: whitespace around commas between
ruaURIs is accepted per the §3 ABNF; repeatedruafields merge their destinations, since §3 supports declaring more than one rua (other repeated tags still warn with first-wins); record detection keys onv=TLSRPTv1;per §3.1, while the grammar-legalv=TLSRPTv1 ;form is accepted with a warning that senders applying the discard rule literally will ignore the record; URI validation is anchored so garbage-prefixed schemes are rejected; a bracketed https authority must be a real IPv6 address (RFC 4291) and#may appear only once, as the RFC 3986 fragment delimiter; a mailto local part follows the RFC 5322 dot-atom rules (no leading, trailing, or doubled dots); the parser returns the matching TLSRPT record instead of whichever TXT record sorted first - SMTP: negative STARTTLS results are cached like positive ones; the port-465 fallback failure warning names the implicit-TLS probe instead of reading as a port 25 failure
- SOA:
check_soa()walks from the domain up through each ancestor to the base domain and reports the first SOA it finds, so a delegated child zone reports its own SOA and contact rather than the base domain's (RFC 2181 §7);soa_rname_to_email()handles RFC 1035 §5.1 escapes, including an escaped backslash before a real label separator, and quotes a decoded local part that is not a plain dot-atom (RFC 5322 §3.2.4) so the returned address is always syntactically valid; escapes in the domain labels are decoded too, with each label validated on its own before joining so an escaped dot (one DNS label holding a literal dot) is an error rather than silently moving the label boundary to a different mailbox domain - DNSSEC: caller-supplied caches are forwarded to
get_dnskey(); thednssecresult-key documentation states what the boolean actually means; a nameserver answering REFUSED, FORMERR, or the like is treated as a failed attempt and the next nameserver is tried, instead of the error being mistaken for a clean empty answer that means "no records". This applies to the chain check,get_dnskey(), andget_tlsa_records()alike: previously the first server's error ended the lookup, so a second nameserver holding the answer was never asked, andget_dnskey()cached the failure as though the zone were unsigned. A lookup that could not complete is no longer cached at all. In the chain check a SERVFAIL is still reported as bogus when the parent publishes a DS record, while a SERVFAIL on the DS query itself is reported as a check that could not complete. TLSA records whose signature does not verify are now reported as such rather than being logged as a query error; the record types consulted below a zone apex now include AAAA and TXT, so a name whose only signed record set is one of those is no longer reported as uncovered - An SPF
amechanism's CIDR suffix now applies to the returned addresses. A split result was reassigned over the same variable, so the length check inspected the hostname string:a:example.com/24silently lost its suffix, a two-character hostname had its second character used as one, anda/24failed to default to the current domain - The
DNS_CACHE_MAX_AGE_SECONDSenvironment variable now actually configures the DNS cache, which was wired to the DNSSEC constant, so the documented variable had no effect andDNSSEC_CACHE_MAX_AGE_SECONDSsilently controlled both caches - The
domainsfield of BIMI certificate metadata now stays a list when the checked domain does not match the certificate; building the error message rebound the list to a joined string. That message now separates the domains with commas rather than periods - MTA-STS policy
mxvalues are now validated against the whole value instead of any substring; an unanchored search accepted any value containing a single legal character, somx: not a hostname!passed as an MX entry, and the "Invalid mx value" error was nearly unreachable parse_mta_sts_record()'s docstring no longer claims tag values carry descriptions; MTA-STS tag values are plain strings andinclude_tag_descriptionscurrently adds none- An SPF
expmodifier with an empty value (exp=) now raisesSPFSyntaxErroras intended; the check compared the value to the integer 0, which never matches a string, so the empty modifier was silently accepted - A BIMI record with an empty
ltag no longer warns about DMARC policy requirements that only apply when a logo is published; the check compared the tag's dict to an empty string, which is always unequal. (A record with noltag at all is now rejected outright — see the breaking changes above — where it previously crashed with aKeyErroron this comparison) check_bimi()no longer discards the warnings raised while locating the record — an unrelated TXT record beside it, or a record published at the root of the domain — by overwriting them with the parser's warnings.check_mta_sts()already merged both sets- A certificate's wordMark attribute is now labeled
wordMarkin BIMI certificate metadata instead of its raw dotted OID string; a trailing comma made the label table's key a one-element tuple check_mta_sts()no longer passes its DNS timeout as the HTTP timeout for the policy download; the download usesDEFAULT_HTTP_TIMEOUT, as the BIMI check already didDMARCRecordNotFoundnow calls its parent constructor, so its message is carried explicitly rather than through a CPython quirk and itsdataattribute exists like other DMARC errors- SOA range and type errors now name the field that failed (
retry,expire, orminimum) instead of always namingrefresh - The BIMI check's DMARC policy warning stated the opposite of the requirement; it now reads "The DMARC policy (p tag) must be set to quarantine or reject"
- The certificate metadata field for the X.509 subjectAlternativeName extension was misspelled
serviceAlternativeName; code reading that key from BIMI certificate results should update - The
User-Agentheader sent on HTTP requests had doubled parentheses around the OS name - The example authorization record in the "does not indicate that it accepts DMARC reports" error contained a stray quote and a run of spaces from a misplaced line continuation
- SPF TXT length warnings now report sizes in bytes, matching what is measured and what RFC 7208 § 3.3 limits, instead of calling them characters
- Cleaned up user-facing messages across the package: corrected articles ("Found an SPF record…"), IPv4/IPv6 capitalization, double spaces, missing periods, inconsistent RFC citation punctuation, the garbled DMARC fo tag redundancy warning, and the parked-domain MX warning, which now reads "MX records found on a parked domain"
- Corrected inaccurate docstrings throughout: copy-paste artifacts ("MTA-HTS", "SIS-MTA", "Tne", the wrong TLSRPT record location), missing parameters and return keys, the stale claim that BIMI file content is not analyzed, and wrong exception cross-references
- Fixed documentation drift: the CLI usage block in the docs was missing
--retriesand is now generated from the real--helpoutput, the docs index pointed at a nonexistent CI workflow badge, and README/docs typos are corrected