961f87359a
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
6.0 KiB
Swift
202 lines
6.0 KiB
Swift
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)
|
|
}
|
|
|
|
/// Format a multipart/mixed message with attachments.
|
|
/// Throws `MessageFormatterError.attachmentTooLarge` if any file exceeds 25 MB.
|
|
public static func formatMultipart(
|
|
_ message: OutgoingMessage,
|
|
attachments: [(filename: String, mimeType: String, data: Data)]
|
|
) throws -> String {
|
|
let maxSize = 25 * 1024 * 1024 // 25 MB
|
|
for attachment in attachments {
|
|
if attachment.data.count > maxSize {
|
|
throw MessageFormatterError.attachmentTooLarge(
|
|
filename: attachment.filename,
|
|
size: attachment.data.count
|
|
)
|
|
}
|
|
}
|
|
|
|
let boundary = "=_MagnumOpus_\(UUID().uuidString)"
|
|
|
|
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: ", ")))
|
|
}
|
|
|
|
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", "multipart/mixed; boundary=\"\(boundary)\""))
|
|
|
|
var result = ""
|
|
for (name, value) in headers {
|
|
result += "\(name): \(value)\r\n"
|
|
}
|
|
result += "\r\n"
|
|
|
|
// Text body part
|
|
result += "--\(boundary)\r\n"
|
|
result += "Content-Type: text/plain; charset=utf-8\r\n"
|
|
result += "Content-Transfer-Encoding: quoted-printable\r\n"
|
|
result += "\r\n"
|
|
result += quotedPrintableEncode(message.bodyText)
|
|
result += "\r\n"
|
|
|
|
// Attachment parts
|
|
for attachment in attachments {
|
|
result += "--\(boundary)\r\n"
|
|
result += "Content-Type: \(attachment.mimeType); name=\"\(attachment.filename)\"\r\n"
|
|
result += "Content-Disposition: attachment; filename=\"\(attachment.filename)\"\r\n"
|
|
result += "Content-Transfer-Encoding: base64\r\n"
|
|
result += "\r\n"
|
|
result += base64Encode(attachment.data)
|
|
result += "\r\n"
|
|
}
|
|
|
|
// Closing boundary
|
|
result += "--\(boundary)--\r\n"
|
|
|
|
return result
|
|
}
|
|
|
|
/// Base64 encode data with line wrapping at 76 characters per RFC 2045.
|
|
public static func base64Encode(_ data: Data, lineLength: Int = 76) -> String {
|
|
let encoded = data.base64EncodedString()
|
|
var result = ""
|
|
var index = encoded.startIndex
|
|
while index < encoded.endIndex {
|
|
let end = encoded.index(index, offsetBy: lineLength, limitedBy: encoded.endIndex) ?? encoded.endIndex
|
|
result += String(encoded[index..<end])
|
|
if end < encoded.endIndex {
|
|
result += "\r\n"
|
|
}
|
|
index = end
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
public enum MessageFormatterError: Error {
|
|
case attachmentTooLarge(filename: String, size: Int)
|
|
}
|