add multipart/mixed formatting, base64 line-wrapped encoding to MessageFormatter
This commit is contained in:
@@ -70,6 +70,82 @@ public enum MessageFormatter: Sendable {
|
|||||||
return formatter.string(from: date)
|
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..<end])
|
||||||
|
if end < encoded.endIndex {
|
||||||
|
result += "\r\n"
|
||||||
|
}
|
||||||
|
index = end
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode a string using quoted-printable encoding (RFC 2045).
|
/// Encode a string using quoted-printable encoding (RFC 2045).
|
||||||
public static func quotedPrintableEncode(_ text: String) -> String {
|
public static func quotedPrintableEncode(_ text: String) -> String {
|
||||||
var result = ""
|
var result = ""
|
||||||
|
|||||||
@@ -173,4 +173,77 @@ struct MessageFormatterTests {
|
|||||||
#expect(dateString.contains(","))
|
#expect(dateString.contains(","))
|
||||||
#expect(dateString.count > 20)
|
#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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user