387 lines
14 KiB
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)
|
|
}
|
|
}
|