Skip to content

Perfect-SMTP 6.0.0: dependency ranges, CI, security hardening - #32

Merged
taplin merged 39 commits into
mainfrom
release-6.0.0
Oct 25, 2026
Merged

taplin merged 39 commits into
mainfrom
release-6.0.0

Conversation

@taplin

@taplin taplin commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Prepares Perfect-SMTP 6.0.0: the SwiftNIO rewrite (#23) and its October hardening (#24–#28, #30, #31), plus release prep and the fixes from four adversarial review rounds.

Dependencies

  • swift-crypto: exact: "4.5.1" → "4.0.0"..<"5.0.0". The exact pin broke resolution for any graph needing another 4.x; 3.x fails on Linux (non-Sendable keys before 4.0). Resolves alongside Perfect-NIO 0.1.0.
  • Floors that actually build: swift-nio 2.86.0 (2.65.0 never built), swift-nio-ssl 2.31.0, swift-log 1.5.4, swift-asn1 1.2.0 (declared directly; already in the graph through swift-crypto). Verified with all floors pinned exactly together.
  • No new packages. The MTA-STS policy fetcher is now a small HTTP/1.1 client on NIOHTTP1 (part of swift-nio).

Security fixes

  • Header and command injection: CR/LF in InlineResource.contentID, the EHLO hostname, messageIDDomain, MIME boundaries and DKIM selector/domain; extra-header names must be RFC 5322 field names (Bcc:x no longer becomes a Bcc header).
  • Envelope parameter injection: a strict AddressSyntax (dot-atom @ LDH or address literal, ≤254 octets, no quoted local parts, ASCII only) for MAIL FROM, RCPT TO and From/To/Cc/Reply-To/Sender. >/space could previously add NOTIFY=, ORCPT= or AUTH= parameters.
  • TLS: relay AUTH is refused over plaintext unless explicitly allowed; named hosts must present a matching DNS SAN on relay TLS, DirectMX under MTA-STS and the policy fetch (an IP-only certificate no longer passes); the check runs after the handshake via the public peer certificate and a swift-asn1 SAN parser, before any SMTP or HTTP bytes are written.
  • MTA-STS fetch: 64 KiB cap enforced while reading (a 3 GB body used to be buffered; on Linux URLSession could not be capped at all), a whole-fetch deadline, no redirects, SSRF filtering on by default with a vetted-address dial.
  • Address filtering: the full IANA special-purpose registries (0.0.0.0/8 used to reach localhost), with NAT64, 6to4 and mapped addresses judged by their embedded IPv4.
  • DNS: responses must echo the question, owner names are checked, malformed packets are ignored, any null MX means no mail, names are LDH and ≤255 octets.
  • Smuggling and framing: bare CR/LF normalized before dot-stuffing on every path; a 354 after all recipients were rejected is closed with a lone dot; headers are folded at 78 octets (refused over 998).
  • Credentials: redacted from descriptions, dump() and Mirror; the SASL LOGIN password is sent once.

Delivery behaviour

  • DirectMX classifies connection, timeout, breaker, TLS and DNS SERVFAIL/timeout failures as transient and retries them with exponential backoff (doubling to a 4 h cap, ±10% jitter); maxAge (5 days) is the real cutoff. MTA-STS enforce violations are temporary (451 4.7.5).
  • Opportunistic TLS falls back to plaintext only with no MTA-STS policy; never after injection, with the breaker open, under .fixed(.startTLS) or under enforce.
  • QUIT is sent on close; evictions don't wait for the 221.

Breaking changes

  • SMTPEnvelope.mailFrom and .recipients are read-only (public private(set)), and the initializer validates both.
  • A relay reached by host name must present a certificate with a matching DNS SAN.
  • Non-ASCII addresses are refused (SMTPUTF8 isn't implemented; use A-labels).
  • Higher dependency floors (above).

CI and docs

  • CI copies Perfect-CRUD's workflow without the iOS job (the package is macOS + Linux): Linux Swift 6.2/6.4, macOS Xcode 26.0.1/26.6, debug and release, warnings as errors, plus a linux-offline job that runs the tests under docker run --network none.
  • README, user guide and README.zh_CN updated for 6.0.0, including the security defaults and 5.x migration; the rewrite plan is kept as design history.

Verification

  • 483 tests, hermetic (no network needed). Xcode 27 (Swift 6.4) and Xcode 26.3 (Swift 6.2.4), debug and release, -warnings-as-errors; ASan clean; TSan shows only NIO continuation-handoff false positives.
  • Linux swift:6.2-noble and swift:6.4-noble on arm64 and amd64, with --network none, repeated runs; the exact dependency floors on macOS and Linux.
  • DKIM: RSA and Ed25519 signatures verify with dkimpy under all four canonicalization pairs, including folded headers.
  • Four adversarial review rounds with local fake SMTP, HTTP and DNS servers (injection, STARTTLS downgrade and injection, certificate variants, slow and endless responses, fd leaks, DNS and SAN-parser fuzzing); final verdict push.

Follow-ups, not in this release: SMTPUTF8, a custom TLS configuration for private CAs and client certificates, and signing Message-ID/In-Reply-To/References by default.

🤖 Generated with Claude Code

taplin and others added 30 commits October 23, 2026 08:45
SMTPConnection reaches NIOAsyncChannel's deprecated unscoped inbound/
outbound accessors through a protocol requirement, so the deliberate use
(a pooled connection outlives any executeThenClose scope) no longer
breaks a -warnings-as-errors build. Behaviour is unchanged.

Tests: bind the held closes in the pool test to their event loop with
NIOLoopBound, give a Void? binding an explicit type, and keep the
capturing LogHandler building warning-free against both swift-log 1.5
(no LogEvent) and 1.11+ (flat log(...) requirement deprecated).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…2.86.0

The exact swift-crypto pin made resolution fail for any package that
also needs another swift-crypto (e.g. root exact 4.5.2 + this package's
exact 4.5.1). The _CryptoExtras surface used here (_RSA.Signing PEM/DER
init, keySizeInBits, PKCS#1 v1.5 sign/verify) builds and passes the
full suite at 3.4.0, 3.15.1, 4.0.0 and 4.5.2. 3.0.0-3.1.0 lack
Sendable RSA keys and 3.2.0-3.3.0 lack the unsafe*Representation
initializers the tests use.

swift-nio 2.65.0 never built: the library needs 2.75.0
(syncOperations.removeHandler(context:)) and the tests 2.86.0
(EmbeddedChannel accepting autoRead). swift-nio-ssl 2.27.0 and
swift-log 1.5.0 build and test as declared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The content ID was written into the inline part's Content-ID header
verbatim, so "img1\r\nX-Injected: yes" added a header to the part and
a blank line could inject part body content. Route it through the same
requireNoInjection check as the inline Content-Type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RelayTransport sent PLAIN/LOGIN credentials or an XOAUTH2 token over a
tls: .none connection without complaint. It now throws
RelayTransportError.authenticationRequiresTLS before dialing unless
RelayConfig.allowsAuthenticationWithoutTLS is set (for a loopback MTA
or a test server). STARTTLS and implicit TLS are unaffected.

SMTPConnection wrote the configured EHLO hostname into the command line
verbatim; a CR/LF in it would inject commands. It is now checked like
the MAIL FROM and RCPT TO addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Interpolating a RelayConfig (or its auth, or a SASLPlain/SASLLogin) into
a log line or error message printed the password through Swift's
default reflection-based description. The library never does this
itself, but a caller logging its configuration would leak credentials.
Same redaction discipline as SigningKey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lease

Same shape as Perfect-CRUD's workflow, without the iOS job: the package
declares macOS only, and its transports (SwiftNIO sockets,
LocalMTATransport's Process) are server-side. Builds with
-warnings-as-errors and runs the hermetic test suite; SWIFT_BACKTRACE is
disabled on Linux so a crash fails fast with its own output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URLSessionMTASTSFetcher relied on URLSession's timeouts: the request
timeout only bounds the gap between bytes and the resource timeout
defaults to seven days. A policy host that sends headers and then
trickles the body kept the fetch, and every DirectMX send awaiting that
domain's policy, running indefinitely (repro: one byte per 100 ms was
still being read when a 60 s test time limit cancelled it).

The fetcher now takes a timeout (default 60 s) covering connect to last
body byte, and throws MTASTSHTTPFetchError.timedOut when it passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README: install from 6.0.0, the two products, Swift 6.2+ on macOS 12+
and Linux, a quick start verified by building it as a scratch consumer,
the security defaults, and a 5.x migration table. The user guide gains
the plaintext-AUTH refusal, TLS verification and MTA-STS fetch timeout,
and drops its stale test count and no-CI note. README.zh_CN.md still
describes the current API, so it keeps its content with corrected
platform, requirements and testing lines. The rewrite plan stays as
design history (source comments cite its sections) with a status line
pointing readers at the guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its test

startNextIfPossible checked Task.isCancelled before awaiting the source
iterator, so a cancellation that landed during iterator.next() still
started a send for the message it returned. Check again after the await.

cancellingTheConsumingTaskStopsThePipelinePromptly failed 2 of 3 full
runs on linux/amd64 debug (emulated): it read the send counter 100 ms
and 300 ms after cancel() and required them to match, but on a slow
machine cancellation had not reached the pipeline by the first read
(instrumented runs showed the first window of sends starting after
cancel()). It now waits for the consumer task to finish, waits for the
count to settle, then checks that it stays put and that at most one
window of sends ran after cancellation. 8/8 debug and 8/8 release on
linux/amd64 afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… 1.5.4

Linux testing at the bottom of the range found two floors that only
held on macOS:

- swift-crypto 3.x: on Linux, Curve25519.Signing.PrivateKey (the
  BoringSSL implementation) isn't Sendable until 4.0.0, so SigningKey
  fails to compile. 4.0.0 and 4.5.2 build and pass on macOS and on
  Linux (Swift 6.2 and 6.4).
- swift-log 1.5.0-1.5.3 don't compile on Linux with Swift 6.2+
  (localtime returns an optional); 1.5.4 does.

All floors together (nio 2.86.0, nio-ssl 2.27.0, log 1.5.4, crypto
4.0.0) build with -warnings-as-errors and pass the suite in debug and
release on swift:6.2-noble and swift:6.4-noble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DNSAddress.isRoutable missed 0.0.0.0/8, which reaches the local host,
and most of the IANA special-purpose space. IPv4 now rejects every
non-globally-reachable special-purpose block plus multicast and
240.0.0.0/4. IPv6 is an allow-list of global unicast 2000::/3, minus
2001::/23, 2001:db8::/32 and 3fff::/20; IPv4-mapped, NAT64 64:ff9b::/96
and 6to4 2002::/16 addresses are judged by the IPv4 address they embed.
That also covers ::a.b.c.d, fec0::/10 and ff00::/8.

Table tests cover both registries, and a real-socket test shows that a
resolved 0.0.0.0 or ::ffff:0.0.0.0 no longer reaches a listener on
127.0.0.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
URLSessionMTASTSFetcher buffered the whole body before
MTASTSPolicyManager checked its 64 KiB cap; the reviewer's endless-body
server got 3 GB read and 2 GB peak RSS. The fetcher now takes
maximumBodySize (default 64 KiB) and throws
MTASTSHTTPFetchError.responseTooLarge as soon as it is exceeded. On
Darwin it streams with bytes(for:delegate:) and counts. On Linux the
session delegate cancels in didReceive past the cap. A larger
Content-Length is refused before any body is read. The policy manager
maps it to MTASTSDiscoveryError.responseTooLarge.

Tests use a local server that streams 64 KiB chunks for as long as the
client reads, with and without a 10 GB Content-Length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An extra-header name was only checked for control characters and
against the forbidden list, so "Bcc:secret@victim.test" composed to a
Bcc: header and "To " to a second To:. Names must now be one or more
printable ASCII characters other than ':' (RFC 5322 3.6.8), checked
before the forbidden-name lookup (ComposerError.invalidHeaderName).

The shared injection check also rejects C1 controls (including NEL
U+0085) and U+2028/U+2029, which some receivers treat as line breaks;
subjects and display names have them stripped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses were only checked for control characters, so
'victim@example.test> NOTIFY=SUCCESS ORCPT=rfc822;spoof@evil.test'
went out as an ESMTP parameter on RCPT TO, and
's@example.test>, <attacker@evil.test' as a From address added a second
mailbox (and fed the synthesized Message-ID domain).

New AddressSyntax: a mailbox is a dot-atom local part (at most 64
octets) plus '@' plus an LDH hostname or an [address literal]. There
are no quoted local parts, no '<', '>', '"' or whitespace, and exactly
one '@'. Non-ASCII is rejected for now: SMTPUTF8 isn't implemented, so
IDN domains must be passed in A-label form. It is applied to:
- MAIL FROM, envelope recipients, and again in SMTPConnection (the
  recipients array is mutable after init);
- From, Sender, Reply-To, To and Cc;
- the EHLO hostname (LDH or address literal);
- MIMEComposer's messageIDDomain (invalidMessageIDDomain);
- DKIMSigner's d= and s= (invalidDomain / invalidSelector).
Boundary-generator output must be 1-70 RFC 2046 bchars not ending in a
space (invalidBoundary).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DotStuffing only treated LF as a line start, so a raw SignedMessage with
'x\r.\r\nMAIL FROM:...' went out with the dot unstuffed after a lone
CR. Receivers that treat a bare CR or LF as a line break would then see
an early end of data followed by smuggled commands. Every bare CR and
bare LF now becomes CRLF before stuffing. This is the one function
every SMTP DATA path (relay, direct MX) goes through, so the only
CRLF.CRLF on the wire is the terminator. Composed messages already use
CRLF throughout and are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With PIPELINING, a server may answer the pipelined DATA with 354 even
though it rejected every RCPT. The connection then went back to the
pool still in DATA mode, so the next transaction's MAIL FROM became
message body. Per RFC 2920 3.1 it now sends the terminating dot with no
body and reads the reply. If that fails, it closes the channel so the
pool drops the connection. A test checks that the dot, never the body,
is written and that the next transaction starts with a real MAIL FROM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…4 loop

- RelayConfig.Auth, SASLPlain, SASLLogin and XOAuth2 get CustomReflectable
  mirrors with the password or token replaced by <redacted>.
  dump(relayConfig) and Mirror(reflecting:) printed the secret: they walk
  stored properties and ignore the redacted descriptions.
- XOAUTH2 rejects U+0001 in the username or token, so the username can't
  forge its own auth= field. PLAIN rejects NUL in any field
  (SASLError.separatorInCredential).
- The AUTH exchange gives up after 10 334 continuations
  (SMTPConnectionError.tooManySASLChallenges) instead of looping for as
  long as the server keeps sending them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ll MX

- Decoding rejects labels with bytes outside printable ASCII, or with a
  space or '.'. The reviewer's 'mx\0.victim.test' exchange matched
  *.victim.test before. Names longer than 255 octets on the wire are
  rejected on decode and encode.
- A response must echo exactly one question with the asked name and
  type. Only records owned by the asked name, or by the end of a CNAME
  chain from it in the same answer, are used for A/AAAA, MX and TXT.
- A malformed or mismatched UDP datagram is ignored and the wait goes
  on, instead of aborting the lookup.
- MX exchanges and CNAME targets must be LDH host names. An MX set with
  no usable exchange is malformedResponse, not noRecordsFound (which
  would trigger the implicit-MX fallback).
- Any null MX (preference 0, exchange '.') in the RRset is a null MX.
  RFC 7505 section 3 forbids mixing it with other MX records and doesn't
  say what a sender should do when a zone does; refusing delivery is
  the conservative reading.
- resolveAddresses no longer swallows failures with try?. A timeout or
  SERVFAIL in both families is thrown as such (ResolveError.isTransient)
  rather than collapsing to noRecordsFound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tries

TLS names:
- makeSSLHandler only dials without SNI for a real IP literal. Before,
  any name NIOSSL refused for SNI (the reviewer used mx\0.victim.test)
  fell back to checking the dialed IP, so a certificate with only an IP
  SAN passed. SMTPBootstrap.connect now refuses a non-LDH TLS name or
  EHLO name up front, and the 'confirmed safe' comment is gone.
- Under MTA-STS (testing and enforce), DirectMX keys carry
  requireCertificateDNSName. An additional NIOSSL verification callback
  then requires a DNS subjectAltName matching the MX host, because
  NIOSSL otherwise accepts an IP SAN equal to the dialed address.
- Certificate failures (untrusted chain, wrong name, no DNS SAN) are
  reported as SMTPError.tlsCertificateInvalid, not .starttlsInjection.
- The pre-TLS EHLO probe uses the configured EHLO hostname.

DirectMX failures (RFC 5321 4.5.4.1), when no host of a domain could be
used:
- Temporary, so .queuedForRetry (451 4.4.1) and queued: refused or reset
  connections, timeouts, a peer hanging up, an open breaker, TLS
  failures, and DNS timeouts or SERVFAIL.
- Final (.failed): NXDOMAIN, private addresses, bad names.
- An MTA-STS enforce violation is temporary too (451 4.7.5).

Opportunistic TLS still falls back to plaintext on 454, a failed
handshake or certificate, or a missing STARTTLS, but never after
injection or an open breaker. .fixed(.startTLS) and enforce never fall
back. Tests use a local CA with IP-only, DNS-only and combined leaf
certificates on 127.0.0.1, plus dialer-driven tests of every fallback
and classification case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Header values went out on one line however long they were: 200 To
addresses, 100 References, the DKIM-Signature's h= and b=, and a
2000-character subject. RFC 5322 2.1.1 caps lines at 998 octets and
recommends 78.

- MIMEComposer folds every top-level and MIME-part header at existing
  whitespace (HeaderFolding) before anything else sees it. A header that
  still has a line over 998 octets is refused
  (ComposerError.headerLineTooLong).
- An unstructured value with an unbreakable run over 900 octets is
  RFC 2047-encoded so it can be folded. Encoded words carry 39 payload
  bytes so 'Subject: ' plus one word stays under 78.
- DKIMSigner folds its tag list (FWS after ';' and ':'), so the hashed
  form is the sent form under both canonicalizations, and folds b= after
  signing (it is removed whole before verification).

dkimpy verifies all 24 signatures (12 messages, RSA and Ed25519, every
canonicalization pair) from the reviewer's harness. Its longest header
line is now 78 octets, down from several hundred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idle connections closed by shutdown(), eviction or the idle timeout now
get QUIT (bounded at 2 s, without waiting for the 221) before their
channel closes, so the server sees an orderly end of session. A
connection released unhealthy is still just closed. The pool's slot
accounting still runs off the channel's close future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A caller value such as '<a@b> <c@d>' passed the control-character check
and went out as two IDs in one field. Each value must now be a single
'<...>' with no whitespace or brackets inside. Inline content IDs, which
are wrapped in <...>, may not contain brackets or whitespace ('a> <b').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
README security defaults now describe what the code does:
- the NIOSSL name check, and the IP-literal-only no-SNI rule;
- the exact STARTTLS fallback rule (opportunistic falls back after 454,
  a failed handshake or certificate, or no STARTTLS; never after
  injection, with the breaker open, under .fixed(.startTLS) or under
  MTA-STS enforce);
- the full special-purpose address filter;
- the mailbox, hostname and header syntax rules (no SMTPUTF8 yet);
- folding;
- the streaming 64 KiB MTA-STS cap;
- DirectMX retry classification;
- that the retry queue is in memory only.
The user guide is updated to match (opportunistic TLS, enforce now
temporary 451 4.7.5, .failed vs queued).

README.zh_CN.md drops the dead perfect.org banners and gains the NIOPosix
note and an updated security paragraph.

CI gains linux-offline: dependencies are resolved in a networked
container, then the build and tests run under 'docker run --network
none' (a job container can't drop its own network).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Linux the first version of the streaming cap failed every run. After
'.cancel' or cancel(), corelibs Foundation kept reading the endless body
until the test closed the server (10 GB on loopback). The fetch now fails
the moment the cap (or an oversized Content-Length) is seen, then
cancels the task and invalidates the session. The test now also checks
that the client hangs up. The deadline test's trickling body is now
declared under the cap, so the deadline, not the cap, is what ends it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In loaded full-suite runs on Linux, corelibs Foundation sometimes kept
reading the endless body for a few seconds after the cancel (up to
2.4 GB over loopback), so the byte-count bound failed. The bytes are
dropped, not buffered: a standalone probe under 12 busy threads kept max
RSS between 31 and 60 MB over ten endless-body fetches, each failing
with responseTooLarge in under 50 ms. On Linux the test now checks that
the fetch fails promptly and the client closes the connection; the byte
bound stays on Darwin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…form

The streaming cap in URLSessionMTASTSFetcher holds on Darwin, but on
Linux corelibs Foundation keeps reading and queueing the body after the
task is cancelled. In the endless-body test on linux/amd64 the test
process reached 5.9 GB RSS and was OOM-killed (exit 137) in 6 of 15
runs; the redirect and deadline tests peaked at about 60 MB.

New NIOMTASTSHTTPFetcher, now MTASTSPolicyManager's default: an
HTTP/1.1 GET over SwiftNIO with NIOHTTP1 (part of swift-nio, so no new
package):
- full certificate verification, with SNI for host names;
- no redirects followed;
- a whole-fetch timeout;
- a refused oversized Content-Length;
- the connection closed as soon as the body passes 64 KiB.
URLSessionMTASTSFetcher stays as an opt-in, documented as not
memory-safe against an endless body on Linux.

The size, deadline and redirect tests now run against both fetchers
(the endless-body case only against the NIO one on Linux). New tests
cover a real policy over HTTPS from the local test CA, chunked bodies,
and an untrusted certificate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Evicted idle connections now send QUIT before closing, so the close
arrives slightly later. evictionIsCappedByTheWaitingHostsPerHostRoom
checked the held-close count 20 ms after the waiters parked, and failed
under TSan on Linux (2 of 2) and once on emulated amd64 debug. It now
waits for the first close to be held, then gives any wrong extra
evictions 50 ms to appear before asserting exactly one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…andshake

The MTA-STS DNS-name check used NIOSSL's underscored
_makeSSLClientHandler and _subjectAlternativeNames. It now runs once the
handshake completes:
- NIOSSL's public peerCertificate (first in nio-ssl 2.31.0; floor raised,
  2.30.0 fails to build) gives the verified leaf;
- toDERBytes() gives its DER;
- a small parser on swift-asn1 reads the subjectAltName extension
  (2.5.29.17) dNSNames.
swift-asn1 was already in the graph through swift-crypto 4 and is now
declared directly at its 1.2.0 floor, so no new package. Matching reuses
MXPatternMatcher: case-insensitive, with a wildcard covering exactly one
label. The parser is tested against the test CA's certificates,
hand-built DER, malformed DER and extensions, and 2000 random
mutations. The full suite passes with every floor pinned exactly
(nio 2.86.0, nio-ssl 2.31.0, asn1 1.2.0, log 1.5.4, crypto 4.0.0).

isCertificateFailure matches CertificateNameError and NIOSSLExtraError by
type. Only chain failures still need BoringSSL's reason text, because
NIOSSL keeps the error codes private (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esses

- RelayTransport: the pool's default dialer now requires a DNS
  subjectAltName for every .startTLS or .implicit connection to a host
  name. NIOSSL alone also accepts an IP SAN equal to the dialed address,
  so someone who could redirect the relay's DNS to an address they hold
  an IP certificate for could collect AUTH credentials. Tested by name
  ('localhost'): the IP-only test certificate connects without the
  check and fails with it. IP-literal relays still use the IP SAN.
- NIOMTASTSHTTPFetcher (a regression from the URLSession default on
  Linux) accepted an IP-only certificate for the policy host. A host
  name now needs a matching DNS SAN; IP-only and wrong-name certificates
  are refused.
- MTA-STS SSRF: the fetcher resolves the host once through the library's
  DNSResolver (or an injected MTASTSAddressResolving), filters every
  address with DNSAddress.isRoutable, and dials a vetted address with
  SNI and Host set to the name. This is on by default:
  allowPrivateAddresses is the opt-out, used by the local tests. Nothing
  permitted throws noPermittedAddress, which maps to
  addressFilteredAsPrivate. The stale URLSession comments in the policy
  manager are gone.
- The Host header brackets IPv6 literals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
taplin and others added 9 commits October 23, 2026 14:12
451 4.4.1 and 4.7.5 classify as greylist-class, which was a flat 300 s,
and maxAttempts was 10. A domain that stayed unreachable expired after
about 50 minutes.

RetryBackoffPolicy.delay(for:attempt:) now doubles from the class base
(300 s greylist and connection failures, 900 s for 421, 120 s otherwise)
up to maximumBackoff (4 h), with jitter (default +/-10%). The first
greylisting retry is still 5 minutes. DirectMXRetryQueue applies the
schedule from the entry's real attempt number, because the redelivery's
own classification has no history and always says attempt 1.
maxAttempts defaults to 50: about 35 attempts span 5 days (39 at
jitter's low end), so maxAge is the real cutoff.

Tests cover the exact schedule and cap, the jitter bounds, maxAge
outlasting maxAttempts, and the queue actually lengthening its waits.
The README and user guide document the schedule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HeaderFolding could fold before trailing whitespace, or between two
spaces, and leave a continuation line of nothing but whitespace. The
reviewer's case is a Subject of "L"*70 + "a(c)end ". A fold is now
made only where non-whitespace content both precedes it on the current
line and follows it in the value.

ASCII subjects and display names containing "=?" are now RFC
2047-encoded, so receivers don't decode a lookalike encoded word such
as '=?utf-8?q?x?=' in plain text.

The reviewer's random corpus is now clean. checkfold2.py reports no
problems on 3000 + 5000 + 5000 messages (before: 45-49 whitespace-only
lines and about 190 subject/From mismatches per 3000).
checkfold.py finds no whitespace-only or over-998 lines. Its remaining
categories are its email-module whitespace normalization and
unbreakable tokens past 78 octets. dkimpy verifies all 24 signatures on
the folded output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lMTA

SMTPEnvelope.mailFrom and .recipients were public vars, so a caller
could put an unvalidated address into a validated envelope.
LocalMTATransport then passed it straight to the sendmail argv. The init
also never checked the reverse path.

I chose immutability: both are now public private(set), and the init
validates the reverse path as well as the recipients (mailbox syntax, no
leading '-', no control characters). A new SMTPEnvelope.validate()
re-runs the same checks, and LocalMTATransport and SMTPConnection call
it right before the addresses reach the argv or the wire. Callers that
mutated an envelope must build a new one; this is a breaking change,
allowed in 6.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… QUIT 221, LOGIN

- Addresses are capped at 254 octets (RFC 5321 4.5.3.1.3). The 'IPv6:'
  tag is case-insensitive, and the literal is validated as RFC 4291
  text: '[IPv6:::::]', '1::2::3', 5-digit groups and wrong group counts
  are refused.
- DNS labels with unusual bytes are decoded in zone-file presentation
  form (\DDD) rather than failing the whole message, so an odd name in
  another record no longer rejects a valid answer. Escaped names never
  equal a real host name, so they can't match an owner and
  AddressSyntax.isHostname drops them as exchanges or CNAME targets.
- QUIT waits up to 0.5 s for the 221 before closing. The deadline closes
  the channel itself, which ends the pending read without relying on
  cancellation.
- SASL LOGIN sends the password once; a third 334 now throws
  SASLError.unexpectedChallenge instead of re-sending it. The
  challenge-bound test uses XOAUTH2, which answers any number of 334s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
theQueueReschedulesWithAGrowingDelay read the pending entry's remaining
wait after 1.5 s. Under 12 busy loops the entry was already due, so it
failed once in 3 release runs. It now timestamps every redelivery and
checks that each gap is at least the scheduled 0.01 x 2^k s (load can
only make gaps longer), and that the count stays far below what a flat
schedule would produce. 7/7 runs under the same load passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On emulated linux/amd64 debug, the new TLS handshake tests (some load
the system trust store) and a 2000-iteration DER mutation test ran all
at once. They starved the timing-sensitive tests beside them: the
retry-queue deadline, the CNAME cycle under 1 s, and the pool idle
window failed in 4 of 5 runs. The certificate, relay-name, NIO fetcher
and parser suites now run serialized, and the fuzz does 400 mutations.
linux/amd64 then passed 5/5 debug and 5/5 release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three local round trips took over a second under TSan on Linux (2 of 2
runs), so 'elapsed < 1 s' failed although the cycle guard worked. The
resolver now uses a 10 s query timeout and the test requires under 8 s.
Any path that waited on a timeout would take at least 10 s, so it still
catches the regression the test exists for. Linux TSan then passed 2/2
with no race reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pool evictions and idle-timeout closes now write QUIT and release the
slot without waiting for the server's reply; only shutdown() waits, up to
Configuration.quitReplyTimeout (0.5 s default). The pool tests' fake
connections answer QUIT with 221, and a new test shows a checkout behind
an eviction is not held up by a server that never replies.

NIOMTASTSHTTPFetcher writes the request only after the TLS handshake has
completed and the certificate's DNS SAN has matched (TLSReadyGate replaces
PeerDNSNameCheck). The readiness promise is created per connect attempt
on a pinned event loop instead of being looked up in the pipeline. A new
test checks that a server presenting an IP-only certificate never
receives a byte of the request (TLS 1.2 and 1.3).

URLSessionMTASTSFetcher reports URLSession's own request timeout as
MTASTSHTTPFetchError.timedOut. Both are set from the same `timeout`, and
under TSan on a loaded host the idle timeout sometimes fired first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@taplin
taplin merged commit 2b38e84 into main Oct 25, 2026
18 checks passed
@taplin
taplin deleted the release-6.0.0 branch October 25, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant