118 lines
3.6 KiB
Swift
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()
|
|
}
|
|
}
|