From 0b9bbe12556dbebbdf776f75710e2f01ac140ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Sat, 14 Mar 2026 13:32:23 +0100 Subject: [PATCH] add multipart/mixed formatting, base64 line-wrapped encoding to MessageFormatter --- .../Sources/SMTPClient/MessageFormatter.swift | 76 +++++++++++++++++++ .../MessageFormatterTests.swift | 73 ++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift b/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift index 8ee740d..b4efee7 100644 --- a/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift +++ b/Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift @@ -70,6 +70,82 @@ public enum MessageFormatter: Sendable { return formatter.string(from: date) } + /// Format a multipart/mixed message with attachments. + public static func formatMultipart( + _ message: OutgoingMessage, + attachments: [(filename: String, mimeType: String, data: Data)] + ) -> String { + 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 = "" diff --git a/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift b/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift index 2352ed6..3663e45 100644 --- a/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift +++ b/Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift @@ -173,4 +173,77 @@ struct MessageFormatterTests { #expect(dateString.contains(",")) #expect(dateString.count > 20) } + + // MARK: - Multipart Formatting + + @Test("format with attachments produces multipart/mixed") + func multipartWithAttachment() { + let message = OutgoingMessage( + from: EmailAddress(address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + subject: "With attachment", + bodyText: "See attached.", + messageId: "test@example.com" + ) + let attachments: [(filename: String, mimeType: String, data: Data)] = [ + (filename: "test.pdf", mimeType: "application/pdf", data: Data("PDF content".utf8)), + ] + + let formatted = MessageFormatter.formatMultipart(message, attachments: attachments) + + #expect(formatted.contains("Content-Type: multipart/mixed; boundary=")) + #expect(formatted.contains("Content-Type: text/plain; charset=utf-8")) + #expect(formatted.contains("Content-Type: application/pdf; name=\"test.pdf\"")) + #expect(formatted.contains("Content-Disposition: attachment; filename=\"test.pdf\"")) + #expect(formatted.contains("Content-Transfer-Encoding: base64")) + #expect(formatted.contains("See attached.")) + } + + @Test("format without attachments produces single-part") + func singlePartNoAttachments() { + let message = OutgoingMessage( + from: EmailAddress(address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + subject: "Plain", + bodyText: "Just text.", + messageId: "test@example.com" + ) + + let formatted = MessageFormatter.format(message) + + #expect(formatted.contains("Content-Type: text/plain; charset=utf-8")) + #expect(!formatted.contains("multipart")) + } + + @Test("base64 encoding wraps at 76 characters") + func base64LineWrapping() { + let data = Data(repeating: 0xFF, count: 100) + let encoded = MessageFormatter.base64Encode(data) + let lines = encoded.components(separatedBy: "\r\n") + for line in lines where !line.isEmpty { + #expect(line.count <= 76) + } + } + + @Test("multiple attachments produce correct number of boundary sections") + func multipleAttachments() { + let message = OutgoingMessage( + from: EmailAddress(address: "alice@example.com"), + to: [EmailAddress(address: "bob@example.com")], + subject: "Multi", + bodyText: "Files.", + messageId: "test@example.com" + ) + let attachments: [(filename: String, mimeType: String, data: Data)] = [ + (filename: "a.pdf", mimeType: "application/pdf", data: Data("A".utf8)), + (filename: "b.jpg", mimeType: "image/jpeg", data: Data("B".utf8)), + ] + + let formatted = MessageFormatter.formatMultipart(message, attachments: attachments) + + #expect(formatted.contains("a.pdf")) + #expect(formatted.contains("b.jpg")) + #expect(formatted.contains("application/pdf")) + #expect(formatted.contains("image/jpeg")) + } }