Files
oai-swift/oAI/Services/SMTPClient.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

385 lines
13 KiB
Swift

//
// SMTPClient.swift
// oAI
//
// Swift-native SMTP client for sending emails
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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: @unchecked Sendable {
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: @unchecked Sendable {
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: @unchecked Sendable {
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)
}
}