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,110 @@
|
||||
import Foundation
|
||||
import Models
|
||||
|
||||
/// Builds RFC 5322 formatted email messages.
|
||||
public enum MessageFormatter: Sendable {
|
||||
|
||||
/// Format an OutgoingMessage into a complete RFC 5322 message string with CRLF line endings.
|
||||
public static func format(_ message: OutgoingMessage) -> String {
|
||||
var headers: [(String, String)] = []
|
||||
|
||||
headers.append(("From", formatAddress(message.from)))
|
||||
headers.append(("To", message.to.map(formatAddress).joined(separator: ", ")))
|
||||
|
||||
if !message.cc.isEmpty {
|
||||
headers.append(("Cc", message.cc.map(formatAddress).joined(separator: ", ")))
|
||||
}
|
||||
|
||||
// BCC intentionally omitted from headers per RFC 5322
|
||||
|
||||
headers.append(("Subject", message.subject))
|
||||
headers.append(("Date", formatRFC2822Date(Date())))
|
||||
headers.append(("Message-ID", "<\(message.messageId)>"))
|
||||
|
||||
if let inReplyTo = message.inReplyTo {
|
||||
headers.append(("In-Reply-To", "<\(inReplyTo)>"))
|
||||
}
|
||||
if let references = message.references {
|
||||
headers.append(("References", references))
|
||||
}
|
||||
|
||||
headers.append(("MIME-Version", "1.0"))
|
||||
headers.append(("Content-Type", "text/plain; charset=utf-8"))
|
||||
headers.append(("Content-Transfer-Encoding", "quoted-printable"))
|
||||
|
||||
var result = ""
|
||||
for (name, value) in headers {
|
||||
result += "\(name): \(value)\r\n"
|
||||
}
|
||||
result += "\r\n"
|
||||
result += quotedPrintableEncode(message.bodyText)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Generate a unique Message-ID for the given domain.
|
||||
public static func generateMessageId(domain: String) -> String {
|
||||
"\(UUID().uuidString)@\(domain)"
|
||||
}
|
||||
|
||||
/// Extract the domain part from an email address.
|
||||
public static func domainFromEmail(_ email: String) -> String {
|
||||
guard let atIndex = email.lastIndex(of: "@") else { return email }
|
||||
return String(email[email.index(after: atIndex)...])
|
||||
}
|
||||
|
||||
/// Format an EmailAddress as RFC 5322 address: `"Name" <addr>` or bare `addr`.
|
||||
public static func formatAddress(_ addr: EmailAddress) -> String {
|
||||
if let name = addr.name, !name.isEmpty {
|
||||
return "\"\(name)\" <\(addr.address)>"
|
||||
}
|
||||
return addr.address
|
||||
}
|
||||
|
||||
/// Format a Date as RFC 2822 date string.
|
||||
public static func formatRFC2822Date(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
|
||||
formatter.timeZone = TimeZone.current
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
/// Encode a string using quoted-printable encoding (RFC 2045).
|
||||
public static func quotedPrintableEncode(_ text: String) -> String {
|
||||
var result = ""
|
||||
let data = Array(text.utf8)
|
||||
var lineLength = 0
|
||||
|
||||
for byte in data {
|
||||
let encoded: String
|
||||
if byte == 0x0A {
|
||||
// LF → CRLF
|
||||
encoded = "\r\n"
|
||||
result += encoded
|
||||
lineLength = 0
|
||||
continue
|
||||
} else if byte == 0x0D {
|
||||
// CR — skip, we handle LF → CRLF above
|
||||
continue
|
||||
} else if byte == 0x09 || (byte >= 0x20 && byte <= 0x7E && byte != 0x3D) {
|
||||
// Printable ASCII (except =) and tab: literal
|
||||
encoded = String(UnicodeScalar(byte))
|
||||
} else {
|
||||
// Everything else: =XX hex encoding
|
||||
encoded = String(format: "=%02X", byte)
|
||||
}
|
||||
|
||||
// Soft line break if line would exceed 76 chars
|
||||
if lineLength + encoded.count > 76 {
|
||||
result += "=\r\n"
|
||||
lineLength = 0
|
||||
}
|
||||
|
||||
result += encoded
|
||||
lineLength += encoded.count
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user