// // SMTPClient.swift // oAI // // Swift-native SMTP client for sending emails // import Foundation import Network import os class SMTPClient { private let log = Logger(subsystem: "com.oai.oAI", category: "smtp") private var connection: NWConnection? private var host: String private var port: UInt16 private var username: String private var password: String init(host: String, port: UInt16 = 587, username: String, password: String) { self.host = host self.port = port self.username = username self.password = password } // MARK: - Connection func connectWithTLS() async throws { let tlsOptions = NWProtocolTLS.Options() let tcpOptions = NWProtocolTCP.Options() let params = NWParameters(tls: tlsOptions, tcp: tcpOptions) connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params) let hostName = self.host try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in final class ResumeOnce { var resumed = false let lock = NSLock() } let resumeOnce = ResumeOnce() connection?.stateUpdateHandler = { [weak self] state in resumeOnce.lock.lock() defer { resumeOnce.lock.unlock() } guard !resumeOnce.resumed else { return } switch state { case .ready: resumeOnce.resumed = true self?.log.info("SMTP connected to \(hostName) with TLS") continuation.resume() case .failed(let error): resumeOnce.resumed = true self?.log.error("SMTP TLS connection failed: \(error.localizedDescription)") continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription)) default: break } } connection?.start(queue: .global()) } } func connect() async throws { let tcpOptions = NWProtocolTCP.Options() let params = NWParameters(tls: nil, tcp: tcpOptions) connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params) let hostName = self.host try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in final class ResumeOnce { var resumed = false let lock = NSLock() } let resumeOnce = ResumeOnce() connection?.stateUpdateHandler = { [weak self] state in resumeOnce.lock.lock() defer { resumeOnce.lock.unlock() } guard !resumeOnce.resumed else { return } switch state { case .ready: resumeOnce.resumed = true self?.log.info("SMTP connected to \(hostName)") continuation.resume() case .failed(let error): resumeOnce.resumed = true self?.log.error("SMTP connection failed: \(error.localizedDescription)") continuation.resume(throwing: EmailServiceError.connectionFailed(error.localizedDescription)) default: break } } connection?.start(queue: .global()) } } func disconnect() { _ = try? sendCommandSync("QUIT") connection?.cancel() connection = nil log.info("SMTP disconnected") } // MARK: - Send Email func sendEmail(from: String, to: [String], subject: String, body: String, htmlBody: String? = nil, inReplyTo: String? = nil) async throws -> String { // Port 465 uses direct TLS (implicit), port 587 uses STARTTLS (explicit) let usesDirectTLS = port == 465 if usesDirectTLS { // Direct TLS connection (port 465) try await connectWithTLS() } else { // Plain connection first (port 587) try await connect() } defer { disconnect() } // Read server greeting (220) _ = try await readResponse() // EHLO _ = try await sendCommand("EHLO \(host)") // STARTTLS only for port 587 if !usesDirectTLS { // Note: Network framework doesn't support mid-connection TLS upgrade // For STARTTLS, we skip the upgrade and continue with plain connection // This is insecure - recommend using port 465 instead log.warning("Port 587 with STARTTLS not fully supported, consider using port 465 (direct TLS)") } // AUTH LOGIN _ = try await sendCommand("AUTH LOGIN", expectCode: "334") // Send base64 encoded username let usernameB64 = Data(username.utf8).base64EncodedString() _ = try await sendCommand(usernameB64, expectCode: "334") // Send base64 encoded password let passwordB64 = Data(password.utf8).base64EncodedString() _ = try await sendCommand(passwordB64, expectCode: "235") // MAIL FROM _ = try await sendCommand("MAIL FROM:<\(from)>") // RCPT TO for recipient in to { _ = try await sendCommand("RCPT TO:<\(recipient)>") } // DATA _ = try await sendCommand("DATA", expectCode: "354") // Build email let messageId = "<\(UUID().uuidString)@\(host)>" let date = formatEmailDate(Date()) var email = "" email += "From: \(from)\r\n" email += "To: \(to.joined(separator: ", "))\r\n" email += "Subject: \(subject)\r\n" email += "Date: \(date)\r\n" email += "Message-ID: \(messageId)\r\n" if let inReplyTo = inReplyTo { email += "In-Reply-To: \(inReplyTo)\r\n" email += "References: \(inReplyTo)\r\n" } if let htmlBody = htmlBody { // Multipart alternative let boundary = "----=_Part_\(UUID().uuidString.prefix(16))" email += "MIME-Version: 1.0\r\n" email += "Content-Type: multipart/alternative; boundary=\"\(boundary)\"\r\n" email += "\r\n" email += "--\(boundary)\r\n" email += "Content-Type: text/plain; charset=utf-8\r\n" email += "\r\n" email += body email += "\r\n\r\n" email += "--\(boundary)\r\n" email += "Content-Type: text/html; charset=utf-8\r\n" email += "\r\n" email += htmlBody email += "\r\n\r\n" email += "--\(boundary)--\r\n" } else { // Plain text email += "Content-Type: text/plain; charset=utf-8\r\n" email += "\r\n" email += body email += "\r\n" } // End with CRLF.CRLF email += ".\r\n" // Send email data _ = try await sendCommand(email, expectCode: "250", raw: true) log.info("Email sent successfully: \(messageId)") return messageId } // MARK: - Low-level Protocol private func upgradToTLS() async throws { guard let connection = connection else { throw EmailServiceError.connectionFailed("Not connected") } // Create TLS options let tlsOptions = NWProtocolTLS.Options() // Create new parameters with TLS let params = NWParameters(tls: tlsOptions) // Cancel old connection connection.cancel() // Create new connection with TLS self.connection = NWConnection(host: NWEndpoint.Host(host), port: NWEndpoint.Port(integerLiteral: port), using: params) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in final class ResumeOnce { var resumed = false let lock = NSLock() } let resumeOnce = ResumeOnce() self.connection?.stateUpdateHandler = { [weak self] state in resumeOnce.lock.lock() defer { resumeOnce.lock.unlock() } guard !resumeOnce.resumed else { return } switch state { case .ready: resumeOnce.resumed = true self?.log.info("SMTP upgraded to TLS") continuation.resume() case .failed(let error): resumeOnce.resumed = true continuation.resume(throwing: error) default: break } } self.connection?.start(queue: .global()) } } private func sendCommand(_ command: String, expectCode: String = "250", raw: Bool = false) async throws -> String { guard let connection = connection else { throw EmailServiceError.connectionFailed("Not connected") } let fullCommand = raw ? command : "\(command)\r\n" let data = fullCommand.data(using: .utf8)! // Send command try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in connection.send(content: data, completion: .contentProcessed { error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume() } }) } // Read response let response = try await readResponse() if !response.hasPrefix(expectCode) { throw EmailServiceError.sendingFailed("Expected \(expectCode), got: \(response)") } return response } private func sendCommandSync(_ command: String) throws { guard let connection = connection else { return } let fullCommand = "\(command)\r\n" let data = fullCommand.data(using: .utf8)! connection.send(content: data, completion: .idempotent) } private func readResponse() async throws -> String { guard let connection = connection else { throw EmailServiceError.connectionFailed("Not connected") } var fullResponse = "" var isComplete = false // Keep reading until we get a complete SMTP response // Multi-line responses end with "XXX " (space), interim lines have "XXX-" (dash) while !isComplete { let data: Data = try await withCheckedThrowingContinuation { continuation in connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in if let error = error { continuation.resume(throwing: error) } else if let data = data { continuation.resume(returning: data) } else { continuation.resume(throwing: EmailServiceError.connectionFailed("No data received")) } } } guard let chunk = String(data: data, encoding: .utf8) else { throw EmailServiceError.connectionFailed("Invalid response encoding") } fullResponse += chunk // Check if we have a complete response // SMTP responses end with a line like "250 OK\r\n" (code + space + message) let lines = fullResponse.split(separator: "\r\n", omittingEmptySubsequences: false) for line in lines { if line.count >= 4 { let prefix = String(line.prefix(4)) // Check if it's a final line (code + space, not code + dash) // Format: "XXX " where X is a digit if prefix.count == 4, prefix[prefix.index(prefix.startIndex, offsetBy: 0)].isNumber, prefix[prefix.index(prefix.startIndex, offsetBy: 1)].isNumber, prefix[prefix.index(prefix.startIndex, offsetBy: 2)].isNumber, prefix[prefix.index(prefix.startIndex, offsetBy: 3)] == " " { isComplete = true break } } } // Safety: don't loop forever if fullResponse.count > 100000 { throw EmailServiceError.connectionFailed("Response too large") } } return fullResponse } // MARK: - Helpers private func formatEmailDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" formatter.locale = Locale(identifier: "en_US_POSIX") return formatter.string(from: date) } }