Perfect - SMTP 简体中文
Perfect-SMTP is a from-scratch Swift 6 / SwiftNIO SMTP client. It is not a wrapper around libcurl or any other mail library — it drives the SMTP wire protocol itself, including its own STARTTLS state machine, connection pooling, DKIM signing, and MTA-STS policy enforcement.
It ships three delivery strategies (relay through an existing SMTP host, hand off to a local MTA like Postfix/sendmail, or resolve MX records and deliver directly), so you can pick the one that matches how you already operate mail, or let Perfect-SMTP be the terminal MTA itself.
6.0.0 is a complete rewrite of the libcurl-based Perfect-SMTP 5.x. It is not a drop-in upgrade; see Migrating from 5.x. The old code is preserved on the
legacybranch and the5.0.0tag.
This package is domain-agnostic by design — it has no Lasso-specific code
and no Lasso dependency. It is, however, a core dependency of
Perfect-Lasso — a Swift
reimplementation of the Lasso language, still in active development —
which uses it to implement its email_send tag.
- Hand-rolled SMTP client on SwiftNIO — its own STARTTLS upgrade sequence with byte-precise buffer discipline against injection/downgrade attacks, connection pooling with circuit breaking, and PIPELINING support.
- DKIM signing (RFC 6376) — RSA-SHA256 and Ed25519-SHA256 (RFC 8463), including dual-signing, with automatic oversigning of security-sensitive headers and a DMARC-alignment lint.
- Three delivery strategies —
RelayTransport(an ESP or existing SMTP relay),LocalMTATransport(hand off tosendmail/Postfix on the same host), andDirectMXTransport(resolve MX records and deliver directly, with its own retry queue and circuit breaker). - MTA-STS (RFC 8461) policy discovery, caching, and enforcement for direct-MX delivery, plus opportunistic STARTTLS by default.
- SASL authentication —
PLAIN,LOGIN, andXOAUTH2(required by Gmail/Workspace, increasingly required by Microsoft 365). - Deliverability headers —
List-Unsubscribe/List-Unsubscribe-Post(RFC 8058),Precedence,Auto-Submitted— the headers Gmail and Yahoo have required for bulk senders since February 2024. - Bulk/list-server ready — a bounded-concurrency batch
sendand anAsyncSequence-based streamingsendfor sending to millions of recipients without materializing them all in memory. - Structured delivery results — every send returns a per-recipient outcome (delivered, queued for retry, permanently failed, expired, ambiguous, or a transport-level failure) instead of a single pass/fail.
For anything beyond the basics below, see the full user guide.
- Swift 6.2 or later (
swift-tools-version: 6.2, Swift 6 language mode) - macOS 12 or later, or Linux (CI covers Ubuntu 24.04 with Swift 6.2 and 6.4)
iOS, tvOS and watchOS are not supported: this is a server-side package
(SwiftNIO sockets, and LocalMTATransport launches a local sendmail).
Dependencies are all Apple/SSWG packages: swift-nio, swift-nio-ssl,
swift-log and swift-crypto (the last for DKIM; any 4.x release, so it
resolves alongside other packages using swift-crypto 4).
Add the package to your Package.swift:
.package(url: "https://github.com/PerfectlySoft/Perfect-SMTP.git", from: "6.0.0")It has two products:
| Product | Contents |
|---|---|
PerfectSMTP |
Everything: the transports (RelayTransport, LocalMTATransport, DirectMXTransport), SMTPMailer, the connection pool, the DNS resolver and MTA-STS. Re-exports PerfectSMTPCore. |
PerfectSMTPCore |
Message model, MIME composer, header/body encoders and the DKIM signer, with no networking. Depend on it directly only if you compose or sign messages without sending them. |
.target(
name: "YourTarget",
dependencies: [
.product(name: "PerfectSMTP", package: "Perfect-SMTP"),
]
)Send one email through an existing SMTP relay (a corporate MTA or an ESP like SendGrid/Postmark/SES):
import NIOPosix
import PerfectSMTP
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let transport = RelayTransport(
config: RelayConfig(
host: "smtp.example.com",
port: 587,
tls: .startTLS,
auth: .plain(username: "postmaster@example.com", password: "secret")
),
group: group
)
let mailer = SMTPMailer(transport: transport)
var message = EmailMessage(from: EmailAddress(displayName: "Ops", address: "ops@example.com"))
message.to = [EmailAddress(address: "user@dest.com")]
message.subject = "Hello from Perfect-SMTP"
message.textBody = "Hi there!"
let results = try await mailer.send(message, envelopeFrom: .address("ops@example.com"))
for result in results {
print(result.recipient, result.outcome)
}
await transport.shutdown()
try await group.shutdownGracefully()MultiThreadedEventLoopGroup comes from SwiftNIO's NIOPosix, which
PerfectSMTP already depends on; if your build requires every imported module
to be a declared dependency, add
.package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0") and
.product(name: "NIOPosix", package: "swift-nio").
That's it for the basic case. For DKIM signing, direct-MX delivery, authentication options, bulk sending, and deliverability headers, see the user guide.
You get these without configuring anything:
- TLS certificates are verified. Every TLS connection (
.startTLS,.implicit, andDirectMXTransport's STARTTLS attempts) usesTLSConfiguration.makeClientConfiguration(): full chain verification plus a name check. A relay reached by name, a direct-MX host under an MTA-STS policy, and the MTA-STS policy host must present a DNS subjectAltName for that name: an IP-address SAN alone, which NIOSSL would accept for the dialed address, isn't enough. An IP-literal host is checked against its IP SAN, and opportunistic direct MX without a policy uses NIOSSL's check as is. Only a real IP literal is dialed without SNI; any other name that isn't a valid host name fails before connecting. A failed certificate is reported asSMTPError.tlsCertificateInvalid. .startTLSnever downgrades. If the server doesn't advertiseSTARTTLS, or answers it with an error such as454, the connection fails instead of continuing in plaintext. Bytes injected around the upgrade (the CVE-2026-41319 class) fail it with.starttlsInjection.- No AUTH in cleartext. A
RelayConfigwith credentials andtls: .nonethrowsRelayTransportError.authenticationRequiresTLSbefore dialing. SetallowsAuthenticationWithoutTLS: trueonly for a relay you trust, such as a loopback MTA or a test server. - Strict syntax wherever text reaches the wire.
- Addresses (MAIL FROM, RCPT TO, From/To/Cc/Reply-To/Sender) must be
plain
local@domainmailboxes: a dot-atom local part, and an LDH domain or an[address literal]. No quoted local parts,<,>, whitespace or control characters, so nothing can smuggle an ESMTP parameter or a second mailbox. Non-ASCII addresses are rejected (SMTPUTF8 isn't implemented); pass IDN domains in A-label form. - The EHLO name, TLS server name, DKIM
d=/s=andmessageIDDomainmust be LDH host names. - Extra-header names must be RFC 5322 field names, and the
routing-critical names (
Bcc,To,From...) are refused. - Header values, message IDs, content types and content IDs are refused if they contain CR, LF, other C0 or C1 controls, or U+2028/U+2029. Subjects and display names have those stripped and are RFC 2047-encoded.
- Headers are folded to 78 octets (refused if they can't be kept under
998). The DATA payload has its line endings normalized to CRLF and
is dot-stuffed. Bcc addresses only ever go into
RCPT TO.
- Addresses (MAIL FROM, RCPT TO, From/To/Cc/Reply-To/Sender) must be
plain
- Every network step has a timeout: TCP connect (30 s), each SMTP reply
including the greeting, EHLO, STARTTLS and the TLS handshake (300 s), the
final reply after DATA (600 s), each DNS query (5 s), the whole MTA-STS
policy fetch (60 s, and at most 64 KiB of body, enforced while reading),
and the
sendmailprocess (60 s). All are configurable. - No secrets in logs. The library logs only the DKIM alignment warning
and MTA-STS decisions (domains, hosts, errors), never message bodies or
credentials.
RelayConfig.Auth,SASLPlain,SASLLogin,XOAuth2andSigningKeyprint redacted descriptions, and their mirrors (dump, the debugger) hide the secret too. - Direct MX delivery:
- It only dials globally reachable unicast addresses. Every IANA
special-purpose IPv4 and IPv6 range is refused (loopback, private,
0.0.0.0/8, link-local, CGNAT, documentation, benchmarking, multicast, reserved), including IPv4 addresses embedded in mapped, NAT64 and 6to4 IPv6 addresses, unlessallowPrivateAddressesis set. - DNS answers must match the question, and only records for the asked name count. MX exchanges must be LDH host names, and a null MX anywhere in the set means "no mail".
- STARTTLS is opportunistic with no MTA-STS policy: a host that doesn't
offer it, answers
454, or fails the handshake or certificate is retried in plaintext. It is never retried in plaintext after detected injection or with its circuit breaker open, nor under.fixed(.startTLS)or an MTA-STSenforcepolicy. - Under an MTA-STS policy (
testingorenforce), the certificate must also carry a DNS subjectAltName for the MX host. - Unreachable hosts, timeouts, TLS failures and DNS timeouts or SERVFAIL
are temporary and queued for retry (RFC 5321 §4.5.4.1), as is an
MTA-STS
enforceviolation. NXDOMAIN, null MX and 5yz replies are final. Retries back off exponentially (5 minutes doubling to a 4-hour cap, with jitter) for up to 5 days. The retry queue is in memory only: pending retries are lost if the process exits.
- It only dials globally reachable unicast addresses. Every IANA
special-purpose IPv4 and IPv6 range is refused (loopback, private,
5.x wrapped libcurl through Perfect-CURL: you built an SMTPClient with a
URL and credentials, filled in an EMail, and called a blocking
send(completion:). 6.0.0 shares no types with it, so a port is a rewrite
of the call site, not a find-and-replace:
| 5.x | 6.0.0 |
|---|---|
SMTPClient(url: "smtp://host", username:, password:) |
RelayTransport(config: RelayConfig(host:port:tls:auth:), group:) |
smtps:// URL |
tls: .implicit (port 465); tls: .startTLS for port 587 |
Recipient(name:address:) |
EmailAddress(displayName:address:) |
EMail(client:) |
EmailMessage(from:), sent with SMTPMailer(transport:).send(_:envelopeFrom:) |
email.content / .html / .text |
message.htmlBody / message.textBody (set both for multipart/alternative) |
email.bcc |
the bcc: parameter of send (never written to a header) |
email.attachments (file paths) |
message.attachments (Attachment(filename:contentType:data:)) |
try email.send { code, header, body in } |
let results = try await mailer.send(...), one DeliveryResult per recipient |
What changes in behavior: sending is async; the envelope sender is
explicit (envelopeFrom: .address(...) or .null); STARTTLS is mandatory
when requested; credentials over a plaintext connection are refused (see
above); and the 5.x bugs that wrote a Bcc: header to every recipient and
emitted a mislabeled quoted-printable subject are gone. There is no libcurl
or OpenSSL system dependency any more, so the old libssl-dev/libcurl
install notes no longer apply.
The user guide explains why this is a rewrite rather than an update.
swift test
The whole suite is hermetic: no environment variables, no internet, no DNS
and no external mail server. Tests that need sockets (a real STARTTLS
handshake, a full DirectMX delivery, the DNS resolver, the MTA-STS
fetcher) run against in-process fake servers on 127.0.0.1. CI runs it on
Linux and macOS in debug and release with -warnings-as-errors, plus a
Linux job in a container started with --network none.
There is no live-integration tier. To check your own setup against a real
SMTP server, point a RelayTransport at a local
MailHog or
smtp4dev instance; see
Testing your integration.
Apache License 2.0 — see LICENSE.