New features
- DNS over HTTPS (DoH) and DNS over TLS (DoT) are now supported through the existing
nameserversoption (#880). No new configuration option is involved: each entry in the comma-separated list now picks its own transport, and the forms can be mixed in one list. An IP address means plain DNS over UDP and TCP port 53, exactly as before; anhttps://URL means DoH; andtls://ip[:port][#hostname]means DoT, where the port defaults to 853 and the optional#hostnamesupplies the TLS certificate identity (SNI) to verify the server against, matching systemd-resolved's syntax. DoH queries go through a per-processhttpxclient with its environment trust left at the default, so they honor the standardHTTP_PROXY,HTTPS_PROXY, andNO_PROXYvariables — the motivating case, a corporate network that blocks outbound DNS but offers an HTTP proxy, where the proxy also resolves the DoH server's own hostname, so no access to UDP port 53 is needed at all — as well asSSL_CERT_FILE/SSL_CERT_DIR, for a private CA in front of a TLS-inspecting proxy. dnspython's own DoH support cannot do this, because it builds its HTTP client with a custom transport, which is precisely the condition under which httpx ignores proxy environment variables. DoT connections are made directly to TCP port 853 and do not use a proxy. Anameserverslist of plain IP addresses — including the default — behaves exactly as it did before, and the pre-flight DNS check at startup now exercises whichever transports are configured, so a bad DoH or DoT entry is reported before any mailbox is opened. Thednspythonrequirement is nowdnspython[doh]>=2.7.0.
Bug fixes
- Failure report MIME parts are now decoded according to their
Content-Transfer-Encoding(#882).parse_report_email()read every part's payload without asking the standard library to decode it, so any transfer encoding was left in place. Two things went wrong as a result. A quoted-printabletext/rfc822-headerssample part kept its RFC 2045 §6.7 soft line breaks — a=followed by a line ending, with none of the RFC 5322 folding whitespace a header parser needs — which split long headers mid-value; the sample'sFromheader became unparseable, and since the report itself carried noReported-Domainfield, the whole failure report was discarded withTypeError: 'NoneType' object is not subscriptable. Separately, a quoted-printablemessage/feedback-reportpart parsed "successfully" with silently corrupted field values, such as anauthentication_resultsofmx.example.com; dmarc=3Dfail. Decoding is applied only to themessage/feedback-reportpart and to the sample part; all other branches (attachments,application/tlsrpt+*,text/plain) still receive the raw payload, since they decode base64 and sniff zip/gzip magic themselves. Parts declaring no transfer encoding, or a 7bit/8bit one, are likewise left alone: there is nothing to undo, and running such a part through the standard library's decoder would round-trip its already-correct text throughraw-unicode-escapebytes and replace every non-ASCII character with U+FFFD. Note that a composite part carrying a real transfer encoding is illegal per RFC 2045 §6.4, but reporters send them anyway, and the standard library nests the still-encoded text as a child message rather than decoding it — so those parts are decoded by hand. That nested parse needs one repair first: a soft line break splits a long field onto a continuation line with no colon and no leading whitespace, so the parser treats the remainder as a message body and re-serializing inserts a blank line the encoded text never had, which would otherwise truncate the value at its first split. Any unexpected failure in the new decoding step falls back to the previous behavior, so no report that parsed before can start failing because of it. - Feedback report field values no longer keep the carriage return of a CRLF line ending. A
message/feedback-reportpart is a MIME body part, so its lines end with CRLF per RFC 5322 §2.1, but the field regex matched the value with.+underre.MULTILINE, where$matches before the LF and not before the CR — so every value ended in a stray\r. The bug was masked because the base64 branch above decoded throughstr(bytes)and then stripped the resulting literal\rescape sequences; now that parts are properly transfer-decoded, real CRLF reaches the regex, and the regex excludes the line ending explicitly. - A failure report with no
Reported-Domainfield whose sample has no usableFromheader is now rejected with a clear message instead of crashing withUnexpected error: 'NoneType' object is not subscriptable.reported_domainis a required string in theFailureReporttype and is read directly by the Elasticsearch and OpenSearch outputs, so it cannot be defaulted toNone; such a report is unusable and is reported as invalid, naming both the missing field and the unparseableFromheader. As part of this,InvalidFailureReportraised insideparse_failure_report()now propagates unchanged instead of being caught by the function's catch-all handler and re-wrapped, so pre-existing messages such as "Failure sample is not a valid email" are no longer prefixed withUnexpected error:.