diff --git a/Packages/MagnumOpusCore/Package.swift b/Packages/MagnumOpusCore/Package.swift index 2f285d1..b0571db 100644 --- a/Packages/MagnumOpusCore/Package.swift +++ b/Packages/MagnumOpusCore/Package.swift @@ -12,9 +12,11 @@ let package = Package( .library(name: "Models", targets: ["Models"]), .library(name: "MailStore", targets: ["MailStore"]), .library(name: "IMAPClient", targets: ["IMAPClient"]), + .library(name: "SMTPClient", targets: ["SMTPClient"]), .library(name: "SyncEngine", targets: ["SyncEngine"]), ], dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), .package(url: "https://github.com/apple/swift-nio-imap.git", from: "0.1.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"), @@ -36,9 +38,17 @@ let package = Package( .product(name: "NIOSSL", package: "swift-nio-ssl"), ] ), + .target( + name: "SMTPClient", + dependencies: [ + "Models", + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + ] + ), .target( name: "SyncEngine", - dependencies: ["Models", "IMAPClient", "MailStore"] + dependencies: ["Models", "IMAPClient", "SMTPClient", "MailStore"] ), .testTarget(name: "ModelsTests", dependencies: ["Models"]), .testTarget(name: "MailStoreTests", dependencies: ["MailStore"]), @@ -48,6 +58,7 @@ let package = Package( "IMAPClient", ] ), + .testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient", "Models"]), .testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]), ] ) diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift new file mode 100644 index 0000000..8ee740d --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift @@ -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" ` 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 + } +} diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift new file mode 100644 index 0000000..99d6db9 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift @@ -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) + } +} diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift new file mode 100644 index 0000000..b6636c9 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift @@ -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() + } +} diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift new file mode 100644 index 0000000..d96ac96 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift @@ -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() + } +} diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift new file mode 100644 index 0000000..edb869a --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift @@ -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 +} diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponse.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponse.swift new file mode 100644 index 0000000..4032c1a --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponse.swift @@ -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 + } +} diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift new file mode 100644 index 0000000..c6b3317 --- /dev/null +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift @@ -0,0 +1,92 @@ +import NIO + +/// NIO handler that parses SMTP line-based responses. +/// SMTP response format: `<3-digit code>\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? + private var greetingContinuation: CheckedContinuation? + + 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..= 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 + } + } +} diff --git a/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift b/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift new file mode 100644 index 0000000..2352ed6 --- /dev/null +++ b/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift @@ -0,0 +1,176 @@ +import Testing +import Foundation +@testable import SMTPClient +import Models + +@Suite("MessageFormatter") +struct MessageFormatterTests { + + // MARK: - Basic Message Formatting + + @Test("formatted message contains all required headers") + func basicHeaders() { + let message = OutgoingMessage( + from: EmailAddress(name: "Alice", address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + subject: "Hello", + bodyText: "Hi Bob", + messageId: "test-id@example.com" + ) + + let formatted = MessageFormatter.format(message) + + #expect(formatted.contains("From: \"Alice\" ")) + #expect(formatted.contains("To: bob@example.com")) + #expect(formatted.contains("Subject: Hello")) + #expect(formatted.contains("Message-ID: ")) + #expect(formatted.contains("MIME-Version: 1.0")) + #expect(formatted.contains("Content-Type: text/plain; charset=utf-8")) + #expect(formatted.contains("Content-Transfer-Encoding: quoted-printable")) + #expect(formatted.contains("Date: ")) + } + + // MARK: - Reply Headers + + @Test("reply includes In-Reply-To and References headers") + func replyHeaders() { + let message = OutgoingMessage( + from: EmailAddress(address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + subject: "Re: Hello", + bodyText: "Thanks!", + inReplyTo: "original-id@example.com", + references: "", + messageId: "reply-id@example.com" + ) + + let formatted = MessageFormatter.format(message) + + #expect(formatted.contains("In-Reply-To: ")) + #expect(formatted.contains("References: ")) + } + + // MARK: - BCC Omitted + + @Test("BCC recipients are not included in formatted output") + func bccOmitted() { + let message = OutgoingMessage( + from: EmailAddress(address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + bcc: [EmailAddress(address: "secret@example.com")], + subject: "Test", + bodyText: "Body", + messageId: "test@example.com" + ) + + let formatted = MessageFormatter.format(message) + + #expect(!formatted.contains("Bcc")) + #expect(!formatted.contains("secret@example.com")) + } + + // MARK: - Multiple CC Recipients + + @Test("multiple CC recipients formatted correctly") + func multipleCc() { + let message = OutgoingMessage( + from: EmailAddress(address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + cc: [ + EmailAddress(name: "Carol", address: "carol@example.com"), + EmailAddress(address: "dave@example.com"), + ], + subject: "Test", + bodyText: "Body", + messageId: "test@example.com" + ) + + let formatted = MessageFormatter.format(message) + + #expect(formatted.contains("Cc: \"Carol\" , dave@example.com")) + } + + // MARK: - Quoted-Printable Encoding + + @Test("non-ASCII characters are quoted-printable encoded") + func quotedPrintableNonAscii() { + let encoded = MessageFormatter.quotedPrintableEncode("Grüße") + + // ü = C3 BC, ß = C3 9F, e is plain + #expect(encoded.contains("=C3=BC")) // ü + #expect(encoded.contains("=C3=9F")) // ß + #expect(encoded.contains("Gr")) + #expect(encoded.contains("e")) + } + + @Test("equals sign is encoded in quoted-printable") + func quotedPrintableEquals() { + let encoded = MessageFormatter.quotedPrintableEncode("a=b") + #expect(encoded == "a=3Db") + } + + @Test("plain ASCII text passes through unchanged") + func quotedPrintableAscii() { + let encoded = MessageFormatter.quotedPrintableEncode("Hello World") + #expect(encoded == "Hello World") + } + + // MARK: - Domain Extraction + + @Test("domain extracted from email address") + func domainExtraction() { + #expect(MessageFormatter.domainFromEmail("alice@example.com") == "example.com") + #expect(MessageFormatter.domainFromEmail("user@sub.domain.org") == "sub.domain.org") + } + + @Test("domain extraction handles missing @ gracefully") + func domainExtractionNoAt() { + #expect(MessageFormatter.domainFromEmail("noatsign") == "noatsign") + } + + // MARK: - Message-ID Generation + + @Test("generated message ID contains domain") + func messageIdGeneration() { + let id = MessageFormatter.generateMessageId(domain: "example.com") + #expect(id.hasSuffix("@example.com")) + #expect(id.count > "@example.com".count) + } + + @Test("generated message IDs are unique") + func messageIdUniqueness() { + let id1 = MessageFormatter.generateMessageId(domain: "example.com") + let id2 = MessageFormatter.generateMessageId(domain: "example.com") + #expect(id1 != id2) + } + + // MARK: - Address Formatting + + @Test("address with name formatted as quoted name angle-bracket") + func addressWithName() { + let addr = EmailAddress(name: "Alice Smith", address: "alice@example.com") + #expect(MessageFormatter.formatAddress(addr) == "\"Alice Smith\" ") + } + + @Test("address without name formatted as bare address") + func addressWithoutName() { + let addr = EmailAddress(address: "alice@example.com") + #expect(MessageFormatter.formatAddress(addr) == "alice@example.com") + } + + @Test("address with empty name formatted as bare address") + func addressEmptyName() { + let addr = EmailAddress(name: "", address: "alice@example.com") + #expect(MessageFormatter.formatAddress(addr) == "alice@example.com") + } + + // MARK: - RFC 2822 Date + + @Test("RFC 2822 date format is valid") + func rfc2822DateFormat() { + let dateString = MessageFormatter.formatRFC2822Date(Date()) + // Should match pattern like "Fri, 14 Mar 2026 10:30:00 +0100" + #expect(dateString.contains(",")) + #expect(dateString.count > 20) + } +}