Files
MagnumOpus/Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift
2026-03-14 05:25:55 +01:00

118 lines
3.6 KiB
Swift

import Foundation
import Models
/// Orchestrates SMTP command sequences on top of SMTPConnection.
struct SMTPCommandRunner {
private let connection: SMTPConnection
init(connection: SMTPConnection) {
self.connection = connection
}
/// Send EHLO and parse capability lines from response.
func ehlo(hostname: String) async throws -> [String] {
let response = try await connection.sendCommand("EHLO \(hostname)")
guard response.isSuccess else {
throw SMTPError.unexpectedResponse(code: response.code, message: response.message)
}
return response.lines
}
/// Initiate STARTTLS upgrade.
func startTLS() async throws {
let response = try await connection.sendCommand("STARTTLS")
guard response.code == 220 else {
throw SMTPError.tlsUpgradeFailed
}
try await connection.upgradeToTLS()
}
/// Authenticate using AUTH PLAIN, falling back to AUTH LOGIN.
func authenticate(credentials: Credentials) async throws {
// Try AUTH PLAIN first
let plainToken = authPlainToken(
username: credentials.username,
password: credentials.password
)
let response = try await connection.sendCommand("AUTH PLAIN \(plainToken)")
if response.code == 235 {
return
}
// Fallback to AUTH LOGIN
let loginResponse = try await connection.sendCommand("AUTH LOGIN")
guard loginResponse.code == 334 else {
throw SMTPError.authenticationFailed(response.message)
}
let userResponse = try await connection.sendCommand(
Data(credentials.username.utf8).base64EncodedString()
)
guard userResponse.code == 334 else {
throw SMTPError.authenticationFailed(userResponse.message)
}
let passResponse = try await connection.sendCommand(
Data(credentials.password.utf8).base64EncodedString()
)
guard passResponse.code == 235 else {
throw SMTPError.authenticationFailed(passResponse.message)
}
}
func mailFrom(_ address: String) async throws {
let response = try await connection.sendCommand("MAIL FROM:<\(address)>")
guard response.isSuccess else {
throw SMTPError.sendFailed("MAIL FROM rejected: \(response.message)")
}
}
func rcptTo(_ address: String) async throws {
let response = try await connection.sendCommand("RCPT TO:<\(address)>")
guard response.isSuccess else {
throw SMTPError.recipientRejected(address)
}
}
/// Send the DATA command, then the message content, then the terminating dot.
func data(_ content: String) async throws {
let response = try await connection.sendCommand("DATA")
guard response.code == 354 else {
throw SMTPError.sendFailed("DATA rejected: \(response.message)")
}
// Dot-stuff: lines starting with "." must be prefixed with an extra "."
// to prevent premature DATA termination (RFC 5321 §4.5.2)
let stuffed = content
.replacingOccurrences(of: "\r\n.", with: "\r\n..")
// sendCommand appends \r\n, so sending "content\r\n." produces "content\r\n.\r\n"
// which is the correct DATA termination sequence
let payload = stuffed + "\r\n."
let dataResponse = try await connection.sendCommand(payload)
guard dataResponse.isSuccess else {
throw SMTPError.sendFailed("DATA content rejected: \(dataResponse.message)")
}
}
func quit() async throws {
let response = try await connection.sendCommand("QUIT")
// 221 is the expected quit response, but don't throw on failure
_ = response
}
// MARK: - Private
private func authPlainToken(username: String, password: String) -> String {
// AUTH PLAIN format: \0username\0password (base64 encoded)
var token = Data()
token.append(0)
token.append(Data(username.utf8))
token.append(0)
token.append(Data(password.utf8))
return token.base64EncodedString()
}
}