Added a lot of functionality. Bugfixes and changes
This commit is contained in:
368
oAI/Services/SMTPClient.swift
Normal file
368
oAI/Services/SMTPClient.swift
Normal file
@@ -0,0 +1,368 @@
|
||||
//
|
||||
// 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<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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user