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" ` 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.. 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) }