Files
oai-swift/oAI/Services/SMTPClient.swift

387 lines
14 KiB
Swift

//
// SMTPClient.swift
// oAI
//
// Swift-native SMTP client for sending emails
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
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<Void, Error>) 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<Void, Error>) 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<Void, Error>) 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<Void, Error>) 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)
}
}