290 lines
9.4 KiB
Swift
290 lines
9.4 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import SMTPClient
|
|
import Models
|
|
|
|
@Suite("MessageFormatter")
|
|
struct MessageFormatterTests {
|
|
|
|
// MARK: - Basic Message Formatting
|
|
|
|
@Test("formatted message contains all required headers")
|
|
func basicHeaders() {
|
|
let message = OutgoingMessage(
|
|
from: EmailAddress(name: "Alice", address: "alice@example.com"),
|
|
to: [EmailAddress(address: "bob@example.com")],
|
|
subject: "Hello",
|
|
bodyText: "Hi Bob",
|
|
messageId: "test-id@example.com"
|
|
)
|
|
|
|
let formatted = MessageFormatter.format(message)
|
|
|
|
#expect(formatted.contains("From: \"Alice\" <alice@example.com>"))
|
|
#expect(formatted.contains("To: bob@example.com"))
|
|
#expect(formatted.contains("Subject: Hello"))
|
|
#expect(formatted.contains("Message-ID: <test-id@example.com>"))
|
|
#expect(formatted.contains("MIME-Version: 1.0"))
|
|
#expect(formatted.contains("Content-Type: text/plain; charset=utf-8"))
|
|
#expect(formatted.contains("Content-Transfer-Encoding: quoted-printable"))
|
|
#expect(formatted.contains("Date: "))
|
|
}
|
|
|
|
// MARK: - Reply Headers
|
|
|
|
@Test("reply includes In-Reply-To and References headers")
|
|
func replyHeaders() {
|
|
let message = OutgoingMessage(
|
|
from: EmailAddress(address: "alice@example.com"),
|
|
to: [EmailAddress(address: "bob@example.com")],
|
|
subject: "Re: Hello",
|
|
bodyText: "Thanks!",
|
|
inReplyTo: "original-id@example.com",
|
|
references: "<original-id@example.com>",
|
|
messageId: "reply-id@example.com"
|
|
)
|
|
|
|
let formatted = MessageFormatter.format(message)
|
|
|
|
#expect(formatted.contains("In-Reply-To: <original-id@example.com>"))
|
|
#expect(formatted.contains("References: <original-id@example.com>"))
|
|
}
|
|
|
|
// MARK: - BCC Omitted
|
|
|
|
@Test("BCC recipients are not included in formatted output")
|
|
func bccOmitted() {
|
|
let message = OutgoingMessage(
|
|
from: EmailAddress(address: "alice@example.com"),
|
|
to: [EmailAddress(address: "bob@example.com")],
|
|
bcc: [EmailAddress(address: "secret@example.com")],
|
|
subject: "Test",
|
|
bodyText: "Body",
|
|
messageId: "test@example.com"
|
|
)
|
|
|
|
let formatted = MessageFormatter.format(message)
|
|
|
|
#expect(!formatted.contains("Bcc"))
|
|
#expect(!formatted.contains("secret@example.com"))
|
|
}
|
|
|
|
// MARK: - Multiple CC Recipients
|
|
|
|
@Test("multiple CC recipients formatted correctly")
|
|
func multipleCc() {
|
|
let message = OutgoingMessage(
|
|
from: EmailAddress(address: "alice@example.com"),
|
|
to: [EmailAddress(address: "bob@example.com")],
|
|
cc: [
|
|
EmailAddress(name: "Carol", address: "carol@example.com"),
|
|
EmailAddress(address: "dave@example.com"),
|
|
],
|
|
subject: "Test",
|
|
bodyText: "Body",
|
|
messageId: "test@example.com"
|
|
)
|
|
|
|
let formatted = MessageFormatter.format(message)
|
|
|
|
#expect(formatted.contains("Cc: \"Carol\" <carol@example.com>, dave@example.com"))
|
|
}
|
|
|
|
// MARK: - Quoted-Printable Encoding
|
|
|
|
@Test("non-ASCII characters are quoted-printable encoded")
|
|
func quotedPrintableNonAscii() {
|
|
let encoded = MessageFormatter.quotedPrintableEncode("Grüße")
|
|
|
|
// ü = C3 BC, ß = C3 9F, e is plain
|
|
#expect(encoded.contains("=C3=BC")) // ü
|
|
#expect(encoded.contains("=C3=9F")) // ß
|
|
#expect(encoded.contains("Gr"))
|
|
#expect(encoded.contains("e"))
|
|
}
|
|
|
|
@Test("equals sign is encoded in quoted-printable")
|
|
func quotedPrintableEquals() {
|
|
let encoded = MessageFormatter.quotedPrintableEncode("a=b")
|
|
#expect(encoded == "a=3Db")
|
|
}
|
|
|
|
@Test("plain ASCII text passes through unchanged")
|
|
func quotedPrintableAscii() {
|
|
let encoded = MessageFormatter.quotedPrintableEncode("Hello World")
|
|
#expect(encoded == "Hello World")
|
|
}
|
|
|
|
// MARK: - Domain Extraction
|
|
|
|
@Test("domain extracted from email address")
|
|
func domainExtraction() {
|
|
#expect(MessageFormatter.domainFromEmail("alice@example.com") == "example.com")
|
|
#expect(MessageFormatter.domainFromEmail("user@sub.domain.org") == "sub.domain.org")
|
|
}
|
|
|
|
@Test("domain extraction handles missing @ gracefully")
|
|
func domainExtractionNoAt() {
|
|
#expect(MessageFormatter.domainFromEmail("noatsign") == "noatsign")
|
|
}
|
|
|
|
// MARK: - Message-ID Generation
|
|
|
|
@Test("generated message ID contains domain")
|
|
func messageIdGeneration() {
|
|
let id = MessageFormatter.generateMessageId(domain: "example.com")
|
|
#expect(id.hasSuffix("@example.com"))
|
|
#expect(id.count > "@example.com".count)
|
|
}
|
|
|
|
@Test("generated message IDs are unique")
|
|
func messageIdUniqueness() {
|
|
let id1 = MessageFormatter.generateMessageId(domain: "example.com")
|
|
let id2 = MessageFormatter.generateMessageId(domain: "example.com")
|
|
#expect(id1 != id2)
|
|
}
|
|
|
|
// MARK: - Address Formatting
|
|
|
|
@Test("address with name formatted as quoted name angle-bracket")
|
|
func addressWithName() {
|
|
let addr = EmailAddress(name: "Alice Smith", address: "alice@example.com")
|
|
#expect(MessageFormatter.formatAddress(addr) == "\"Alice Smith\" <alice@example.com>")
|
|
}
|
|
|
|
@Test("address without name formatted as bare address")
|
|
func addressWithoutName() {
|
|
let addr = EmailAddress(address: "alice@example.com")
|
|
#expect(MessageFormatter.formatAddress(addr) == "alice@example.com")
|
|
}
|
|
|
|
@Test("address with empty name formatted as bare address")
|
|
func addressEmptyName() {
|
|
let addr = EmailAddress(name: "", address: "alice@example.com")
|
|
#expect(MessageFormatter.formatAddress(addr) == "alice@example.com")
|
|
}
|
|
|
|
// MARK: - RFC 2822 Date
|
|
|
|
@Test("RFC 2822 date format is valid")
|
|
func rfc2822DateFormat() {
|
|
let dateString = MessageFormatter.formatRFC2822Date(Date())
|
|
// Should match pattern like "Fri, 14 Mar 2026 10:30:00 +0100"
|
|
#expect(dateString.contains(","))
|
|
#expect(dateString.count > 20)
|
|
}
|
|
|
|
// MARK: - Multipart Formatting
|
|
|
|
@Test("format with attachments produces multipart/mixed")
|
|
func multipartWithAttachment() throws {
|
|
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 = try 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("formatMultipart throws for file exceeding 25 MB")
|
|
func attachmentSizeGuard() {
|
|
let message = OutgoingMessage(
|
|
from: EmailAddress(address: "alice@example.com"),
|
|
to: [EmailAddress(address: "bob@example.com")],
|
|
subject: "Big file",
|
|
bodyText: "See attached.",
|
|
messageId: "test@example.com"
|
|
)
|
|
// 26 MB attachment
|
|
let bigData = Data(repeating: 0xFF, count: 26 * 1024 * 1024)
|
|
let attachments: [(filename: String, mimeType: String, data: Data)] = [
|
|
(filename: "huge.bin", mimeType: "application/octet-stream", data: bigData),
|
|
]
|
|
|
|
#expect(throws: MessageFormatterError.self) {
|
|
try MessageFormatter.formatMultipart(message, attachments: attachments)
|
|
}
|
|
}
|
|
|
|
@Test func formatMultipartWithAttachments() throws {
|
|
let msg = OutgoingMessage(
|
|
from: EmailAddress(name: "Test", address: "test@example.com"),
|
|
to: [EmailAddress(name: "To", address: "to@example.com")],
|
|
subject: "With attachment",
|
|
bodyText: "Hello",
|
|
messageId: "test-123",
|
|
attachments: [
|
|
OutgoingAttachment(filename: "test.txt", mimeType: "text/plain", data: Data("file content".utf8))
|
|
]
|
|
)
|
|
let formatted = try MessageFormatter.formatMultipart(
|
|
msg,
|
|
attachments: msg.attachments.map { ($0.filename, $0.mimeType, $0.data) }
|
|
)
|
|
#expect(formatted.contains("multipart/mixed"))
|
|
#expect(formatted.contains("test.txt"))
|
|
#expect(formatted.contains("Hello"))
|
|
}
|
|
|
|
@Test("multiple attachments produce correct number of boundary sections")
|
|
func multipleAttachments() throws {
|
|
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 = try 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"))
|
|
}
|
|
}
|