add SMTPClient module: connection layer, message formatter, public API, tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 05:25:03 +01:00
parent 427f197bb3
commit 54ce92e280
9 changed files with 695 additions and 1 deletions
@@ -0,0 +1,110 @@
import Foundation
import Models
/// Builds RFC 5322 formatted email messages.
public enum MessageFormatter: Sendable {
/// Format an OutgoingMessage into a complete RFC 5322 message string with CRLF line endings.
public static func format(_ message: OutgoingMessage) -> String {
var headers: [(String, String)] = []
headers.append(("From", formatAddress(message.from)))
headers.append(("To", message.to.map(formatAddress).joined(separator: ", ")))
if !message.cc.isEmpty {
headers.append(("Cc", message.cc.map(formatAddress).joined(separator: ", ")))
}
// BCC intentionally omitted from headers per RFC 5322
headers.append(("Subject", message.subject))
headers.append(("Date", formatRFC2822Date(Date())))
headers.append(("Message-ID", "<\(message.messageId)>"))
if let inReplyTo = message.inReplyTo {
headers.append(("In-Reply-To", "<\(inReplyTo)>"))
}
if let references = message.references {
headers.append(("References", references))
}
headers.append(("MIME-Version", "1.0"))
headers.append(("Content-Type", "text/plain; charset=utf-8"))
headers.append(("Content-Transfer-Encoding", "quoted-printable"))
var result = ""
for (name, value) in headers {
result += "\(name): \(value)\r\n"
}
result += "\r\n"
result += quotedPrintableEncode(message.bodyText)
return result
}
/// Generate a unique Message-ID for the given domain.
public static func generateMessageId(domain: String) -> String {
"\(UUID().uuidString)@\(domain)"
}
/// Extract the domain part from an email address.
public static func domainFromEmail(_ email: String) -> String {
guard let atIndex = email.lastIndex(of: "@") else { return email }
return String(email[email.index(after: atIndex)...])
}
/// Format an EmailAddress as RFC 5322 address: `"Name" <addr>` or bare `addr`.
public static func formatAddress(_ addr: EmailAddress) -> String {
if let name = addr.name, !name.isEmpty {
return "\"\(name)\" <\(addr.address)>"
}
return addr.address
}
/// Format a Date as RFC 2822 date string.
public static func formatRFC2822Date(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
formatter.timeZone = TimeZone.current
return formatter.string(from: date)
}
/// Encode a string using quoted-printable encoding (RFC 2045).
public static func quotedPrintableEncode(_ text: String) -> String {
var result = ""
let data = Array(text.utf8)
var lineLength = 0
for byte in data {
let encoded: String
if byte == 0x0A {
// LF CRLF
encoded = "\r\n"
result += encoded
lineLength = 0
continue
} else if byte == 0x0D {
// CR skip, we handle LF CRLF above
continue
} else if byte == 0x09 || (byte >= 0x20 && byte <= 0x7E && byte != 0x3D) {
// Printable ASCII (except =) and tab: literal
encoded = String(UnicodeScalar(byte))
} else {
// Everything else: =XX hex encoding
encoded = String(format: "=%02X", byte)
}
// Soft line break if line would exceed 76 chars
if lineLength + encoded.count > 76 {
result += "=\r\n"
lineLength = 0
}
result += encoded
lineLength += encoded.count
}
return result
}
}
@@ -0,0 +1,80 @@
import Foundation
import Models
/// Public SMTP client for sending email messages.
public actor SMTPClient {
private let host: String
private let port: Int
private let security: SMTPSecurity
private let credentials: Credentials
public init(host: String, port: Int, security: SMTPSecurity, credentials: Credentials) {
self.host = host
self.port = port
self.security = security
self.credentials = credentials
}
/// Send an outgoing email message through a full SMTP session.
/// Performs: connect EHLO [STARTTLS + re-EHLO] AUTH MAIL FROM RCPT TO DATA QUIT
public func send(message: OutgoingMessage) async throws {
let connection = SMTPConnection(host: host, port: port, security: security)
let runner = SMTPCommandRunner(connection: connection)
let domain = MessageFormatter.domainFromEmail(message.from.address)
defer {
Task {
try? await runner.quit()
try? await connection.disconnect()
try? await connection.shutdown()
}
}
_ = try await connection.connect()
_ = try await runner.ehlo(hostname: domain)
if security == .starttls {
try await runner.startTLS()
_ = try await runner.ehlo(hostname: domain)
}
try await runner.authenticate(credentials: credentials)
try await runner.mailFrom(message.from.address)
// All recipients: to + cc + bcc
let allRecipients = message.to + message.cc + message.bcc
for recipient in allRecipients {
try await runner.rcptTo(recipient.address)
}
let formattedMessage = MessageFormatter.format(message)
try await runner.data(formattedMessage)
}
/// Test the SMTP connection by performing auth without sending.
/// Performs: connect EHLO [STARTTLS + re-EHLO] AUTH QUIT
public func testConnection() async throws {
let connection = SMTPConnection(host: host, port: port, security: security)
let runner = SMTPCommandRunner(connection: connection)
let domain = MessageFormatter.domainFromEmail(credentials.username)
defer {
Task {
try? await runner.quit()
try? await connection.disconnect()
try? await connection.shutdown()
}
}
_ = try await connection.connect()
_ = try await runner.ehlo(hostname: domain)
if security == .starttls {
try await runner.startTLS()
_ = try await runner.ehlo(hostname: domain)
}
try await runner.authenticate(credentials: credentials)
}
}
@@ -0,0 +1,112 @@
import Foundation
import Models
/// Orchestrates SMTP command sequences on top of SMTPConnection.
struct SMTPCommandRunner {
private let connection: SMTPConnection
init(connection: SMTPConnection) {
self.connection = connection
}
/// Send EHLO and parse capability lines from response.
func ehlo(hostname: String) async throws -> [String] {
let response = try await connection.sendCommand("EHLO \(hostname)")
guard response.isSuccess else {
throw SMTPError.unexpectedResponse(code: response.code, message: response.message)
}
return response.lines
}
/// Initiate STARTTLS upgrade.
func startTLS() async throws {
let response = try await connection.sendCommand("STARTTLS")
guard response.code == 220 else {
throw SMTPError.tlsUpgradeFailed
}
try await connection.upgradeToTLS()
}
/// Authenticate using AUTH PLAIN, falling back to AUTH LOGIN.
func authenticate(credentials: Credentials) async throws {
// Try AUTH PLAIN first
let plainToken = authPlainToken(
username: credentials.username,
password: credentials.password
)
let response = try await connection.sendCommand("AUTH PLAIN \(plainToken)")
if response.code == 235 {
return
}
// Fallback to AUTH LOGIN
let loginResponse = try await connection.sendCommand("AUTH LOGIN")
guard loginResponse.code == 334 else {
throw SMTPError.authenticationFailed(response.message)
}
let userResponse = try await connection.sendCommand(
Data(credentials.username.utf8).base64EncodedString()
)
guard userResponse.code == 334 else {
throw SMTPError.authenticationFailed(userResponse.message)
}
let passResponse = try await connection.sendCommand(
Data(credentials.password.utf8).base64EncodedString()
)
guard passResponse.code == 235 else {
throw SMTPError.authenticationFailed(passResponse.message)
}
}
func mailFrom(_ address: String) async throws {
let response = try await connection.sendCommand("MAIL FROM:<\(address)>")
guard response.isSuccess else {
throw SMTPError.sendFailed("MAIL FROM rejected: \(response.message)")
}
}
func rcptTo(_ address: String) async throws {
let response = try await connection.sendCommand("RCPT TO:<\(address)>")
guard response.isSuccess else {
throw SMTPError.recipientRejected(address)
}
}
/// Send the DATA command, then the message content, then the terminating dot.
func data(_ content: String) async throws {
let response = try await connection.sendCommand("DATA")
guard response.code == 354 else {
throw SMTPError.sendFailed("DATA rejected: \(response.message)")
}
// sendCommand appends \r\n, so sending "content\r\n." produces "content\r\n.\r\n"
// which is the correct DATA termination sequence
let payload = content + "\r\n."
let dataResponse = try await connection.sendCommand(payload)
guard dataResponse.isSuccess else {
throw SMTPError.sendFailed("DATA content rejected: \(dataResponse.message)")
}
}
func quit() async throws {
let response = try await connection.sendCommand("QUIT")
// 221 is the expected quit response, but don't throw on failure
_ = response
}
// MARK: - Private
private func authPlainToken(username: String, password: String) -> String {
// AUTH PLAIN format: \0username\0password (base64 encoded)
var token = Data()
token.append(0)
token.append(Data(username.utf8))
token.append(0)
token.append(Data(password.utf8))
return token.base64EncodedString()
}
}
@@ -0,0 +1,90 @@
import Foundation
import NIO
@preconcurrency import NIOSSL
import Models
actor SMTPConnection {
private let host: String
private let port: Int
private let security: SMTPSecurity
private let group: EventLoopGroup
private var channel: Channel?
private let responseHandler: SMTPResponseHandler
init(host: String, port: Int, security: SMTPSecurity) {
self.host = host
self.port = port
self.security = security
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.responseHandler = SMTPResponseHandler()
}
func connect() async throws -> SMTPResponse {
let handler = responseHandler
let hostname = host
let bootstrap: ClientBootstrap
if security == .ssl {
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
return channel.pipeline.addHandlers([sslHandler, handler])
}
} else {
// STARTTLS: start plain, upgrade later
bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
channel.pipeline.addHandler(handler)
}
}
do {
channel = try await bootstrap.connect(host: host, port: port).get()
} catch {
throw SMTPError.connectionFailed(error.localizedDescription)
}
return try await handler.waitForGreeting()
}
func sendCommand(_ command: String) async throws -> SMTPResponse {
guard let channel else { throw SMTPError.notConnected }
let handler = responseHandler
var buffer = channel.allocator.buffer(capacity: command.utf8.count + 2)
buffer.writeString(command + "\r\n")
channel.writeAndFlush(buffer, promise: nil)
return try await handler.waitForResponse()
}
func upgradeToTLS() async throws {
guard let channel else { throw SMTPError.notConnected }
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host)
do {
try await channel.pipeline.addHandler(sslHandler, position: .first).get()
} catch {
throw SMTPError.tlsUpgradeFailed
}
}
func sendRawBytes(_ data: Data) async throws {
guard let channel else { throw SMTPError.notConnected }
var buffer = channel.allocator.buffer(capacity: data.count)
buffer.writeBytes(data)
channel.writeAndFlush(buffer, promise: nil)
}
func disconnect() async throws {
try await channel?.close()
channel = nil
}
func shutdown() async throws {
try await group.shutdownGracefully()
}
}
@@ -0,0 +1,9 @@
public enum SMTPError: Error, Sendable {
case notConnected
case connectionFailed(String)
case authenticationFailed(String)
case recipientRejected(String)
case sendFailed(String)
case unexpectedResponse(code: Int, message: String)
case tlsUpgradeFailed
}
@@ -0,0 +1,14 @@
/// Represents a complete SMTP response (possibly multi-line).
/// SMTP responses consist of a 3-digit code and one or more text lines.
public struct SMTPResponse: Sendable {
public let code: Int
public let lines: [String]
public var message: String {
lines.joined(separator: "\n")
}
public var isSuccess: Bool {
code >= 200 && code < 400
}
}
@@ -0,0 +1,92 @@
import NIO
/// NIO handler that parses SMTP line-based responses.
/// SMTP response format: `<3-digit code><separator><text>\r\n`
/// Separator `-` means continuation, space means final line.
final class SMTPResponseHandler: ChannelInboundHandler, @unchecked Sendable {
typealias InboundIn = ByteBuffer
private var stringBuffer = ""
private var responseLines: [String] = []
private var responseCode: Int?
private var continuation: CheckedContinuation<SMTPResponse, Error>?
private var greetingContinuation: CheckedContinuation<SMTPResponse, Error>?
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
var buffer = unwrapInboundIn(data)
guard let received = buffer.readString(length: buffer.readableBytes) else { return }
stringBuffer.append(received)
while let lineEnd = stringBuffer.range(of: "\r\n") {
let line = String(stringBuffer[stringBuffer.startIndex..<lineEnd.lowerBound])
stringBuffer.removeSubrange(stringBuffer.startIndex..<lineEnd.upperBound)
processLine(line)
}
}
private func processLine(_ line: String) {
guard line.count >= 3,
let code = Int(line.prefix(3))
else { return }
// Extract text after the code + separator character
let text: String
if line.count > 4 {
text = String(line.dropFirst(4))
} else {
text = ""
}
responseLines.append(text)
if responseCode == nil {
responseCode = code
}
// Separator is at index 3: `-` for continuation, space for final
let isFinal: Bool
if line.count > 3 {
isFinal = line[line.index(line.startIndex, offsetBy: 3)] == " "
} else {
isFinal = true
}
if isFinal {
let response = SMTPResponse(code: code, lines: responseLines)
responseLines = []
responseCode = nil
if let cont = greetingContinuation {
greetingContinuation = nil
cont.resume(returning: response)
} else if let cont = continuation {
continuation = nil
cont.resume(returning: response)
}
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
if let cont = continuation {
continuation = nil
cont.resume(throwing: error)
}
if let cont = greetingContinuation {
greetingContinuation = nil
cont.resume(throwing: error)
}
context.close(promise: nil)
}
func waitForGreeting() async throws -> SMTPResponse {
try await withCheckedThrowingContinuation { cont in
greetingContinuation = cont
}
}
func waitForResponse() async throws -> SMTPResponse {
try await withCheckedThrowingContinuation { cont in
continuation = cont
}
}
}