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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user