Repository navigation
Perfect-SMTP 6.0.0: dependency ranges, CI, security hardening - #32
Merged
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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.Security fixes
InlineResource.contentID, the EHLO hostname,messageIDDomain, MIME boundaries and DKIM selector/domain; extra-header names must be RFC 5322 field names (Bcc:xno longer becomes a Bcc header).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 addNOTIFY=,ORCPT=orAUTH=parameters.dump()and Mirror; the SASL LOGIN password is sent once.Delivery behaviour
maxAge(5 days) is the real cutoff. MTA-STS enforce violations are temporary (451 4.7.5)..fixed(.startTLS)or under enforce.Breaking changes
SMTPEnvelope.mailFromand.recipientsare read-only (public private(set)), and the initializer validates both.CI and docs
linux-offlinejob that runs the tests underdocker run --network none.Verification
-warnings-as-errors; ASan clean; TSan shows only NIO continuation-handoff false positives.swift:6.2-nobleandswift:6.4-nobleon arm64 and amd64, with--network none, repeated runs; the exact dependency floors on macOS and Linux.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