Files
MagnumOpus/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift
T
2026-03-14 13:44:09 +01:00

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)
}