add SMTPClient module: connection layer, message formatter, public API, tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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)")
|
||||
}
|
||||
|
||||
// sendCommand appends \r\n, so sending "content\r\n." produces "content\r\n.\r\n"
|
||||
// which is the correct DATA termination sequence
|
||||
let payload = content + "\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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user