From d7659eb12ef42ec19fe634f389f534fd57b4de13 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Fri, 6 Feb 2026 16:57:53 +0100 Subject: [PATCH 1/9] changes for beta2 --- README.md | 6 +- .../Encoder/Boundary/BoundaryGenerator.swift | 13 ++++ .../Boundary/DefaultBoundaryGenerator.swift | 17 +++++ Sources/FeatherMail/Encoder/MailEncoder.swift | 54 ++++++++++++++ .../FeatherMail/Encoder/RawMailEncoder.swift | 31 ++++---- .../RawMailEncoderTests.swift | 73 ++++++++++++++++--- 6 files changed, 168 insertions(+), 26 deletions(-) create mode 100644 Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift create mode 100644 Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift create mode 100644 Sources/FeatherMail/Encoder/MailEncoder.swift diff --git a/README.md b/README.md index 0096bae..61ab570 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ An abstract mail component for Feather CMS. -[![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138)]( - https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.1 +[![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)]( + https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.2 ) ## Features @@ -31,7 +31,7 @@ An abstract mail component for Feather CMS. Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-mail", exact: "1.0.0-beta.1"), +.package(url: "https://github.com/feather-framework/feather-mail", exact: "1.0.0-beta.2"), ``` Then add `FeatherMail` to your target dependencies: diff --git a/Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift b/Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift new file mode 100644 index 0000000..8d4657b --- /dev/null +++ b/Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift @@ -0,0 +1,13 @@ +// +// BoundaryGenerator.swift +// feather-mail +// +// Created by Binary Birds on 2026. 02. 06.. +// + +/// Generates MIME boundary strings without Foundation dependencies. +public protocol BoundaryGenerator: Sendable { + + /// Returns a unique MIME boundary string. + func generate() -> String +} diff --git a/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift b/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift new file mode 100644 index 0000000..7585e4b --- /dev/null +++ b/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift @@ -0,0 +1,17 @@ +// +// DefaultBoundaryGenerator.swift +// feather-mail +// +// Created by Binary Birds on 2026. 02. 06.. +// + +public struct DefaultBoundaryGenerator: BoundaryGenerator { + + /// Creates a default MIME boundary generator. + public init() {} + + /// Generates a unique MIME boundary without Foundation. + public func generate() -> String { + "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))" + } +} diff --git a/Sources/FeatherMail/Encoder/MailEncoder.swift b/Sources/FeatherMail/Encoder/MailEncoder.swift new file mode 100644 index 0000000..17061a6 --- /dev/null +++ b/Sources/FeatherMail/Encoder/MailEncoder.swift @@ -0,0 +1,54 @@ +// +// MailEncoder.swift +// feather-mail +// +// Created by Binary Birds on 2026. 02. 06. +// + +/// Encodes `Mail` values into raw MIME messages suitable for transport providers. +public protocol MailEncoder: Sendable { + + /// Encodes a mail into a raw MIME message string. + /// + /// - Parameters: + /// - mail: The mail to encode. The mail must be validated + /// before calling this method. + /// - dateHeader: RFC 2822-formatted date header value. + /// - messageID: Message identifier value, including angle brackets. + /// - boundary: Optional custom MIME boundary. If `nil`, the encoder + /// generates one when attachments are present. Provide a valid + /// MIME boundary string without surrounding quotes. + /// - Returns: A raw MIME string suitable for transport providers. + /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. + func encode( + mail: Mail, + dateHeader: String, + messageID: String, + boundary: String? + ) throws(MailError) -> String +} + +public extension MailEncoder { + + /// Encodes a mail into a raw MIME message string. + /// + /// - Parameters: + /// - mail: The mail to encode. The mail must be validated + /// before calling this method. + /// - dateHeader: RFC 2822-formatted date header value. + /// - messageID: Message identifier value, including angle brackets. + /// - Returns: A raw MIME string suitable for transport providers. + /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. + func encode( + mail: Mail, + dateHeader: String, + messageID: String + ) throws(MailError) -> String { + try encode( + mail: mail, + dateHeader: dateHeader, + messageID: messageID, + boundary: nil + ) + } +} diff --git a/Sources/FeatherMail/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift index 56dba96..99c7e43 100644 --- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -18,10 +18,16 @@ /// /// This type performs **no validation**. Callers are expected to validate /// the `Mail` instance before encoding. -public struct RawMailEncoder: Sendable { +public struct RawMailEncoder: MailEncoder { + + private let boundaryGenerator: BoundaryGenerator /// Creates a raw mail encoder. - public init() {} + public init( + boundaryGenerator: BoundaryGenerator = DefaultBoundaryGenerator() + ) { + self.boundaryGenerator = boundaryGenerator + } /// Encodes a mail into a raw MIME message string. /// @@ -30,12 +36,15 @@ public struct RawMailEncoder: Sendable { /// before calling this method. /// - dateHeader: RFC 2822-formatted date header value. /// - messageID: Message identifier value, including angle brackets. + /// - boundary: Optional custom MIME boundary. If `nil`, the encoder + /// generates one when attachments are present. /// - Returns: A raw MIME string suitable for transport providers. /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. public func encode( - _ mail: Mail, + mail: Mail, dateHeader: String, - messageID: String + messageID: String, + boundary: String? ) throws(MailError) -> String { var out = String() @@ -65,7 +74,10 @@ public struct RawMailEncoder: Sendable { out += "References: \(reference)\r\n" } - let boundary = mail.attachments.isEmpty ? nil : createBoundary() + let boundary = + mail.attachments.isEmpty + ? nil + : (boundary ?? boundaryGenerator.generate()) if let boundary { out += "Content-type: multipart/mixed; boundary=\"\(boundary)\"\r\n" @@ -103,6 +115,7 @@ public struct RawMailEncoder: Sendable { out += attachment.data.base64EncodedString() out += "\r\n" } + out += "--\(boundary)--\r\n" } out += "\r\n" @@ -113,14 +126,6 @@ public struct RawMailEncoder: Sendable { // MARK: - Helpers -private extension RawMailEncoder { - - /// Creates a unique MIME boundary without Foundation. - func createBoundary() -> String { - "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))" - } -} - // Foundation-free Base64 encoding for byte arrays. private extension Array where Element == UInt8 { /// Returns a Base64 string representation of the byte array. diff --git a/Tests/FeatherMailTests/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift index e67b134..8e54b12 100644 --- a/Tests/FeatherMailTests/RawMailEncoderTests.swift +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -5,7 +5,6 @@ // Created by Binary Birds on 2026. 01. 26.. // -import Foundation import Testing @testable import FeatherMail @@ -19,6 +18,7 @@ struct RawMailEncoderTests { private func makeMail( body: Body, cc: [Address] = [], + bcc: [Address] = [], replyTo: [Address] = [], reference: String? = nil, attachments: [FeatherMail.Attachment] = [] @@ -27,6 +27,7 @@ struct RawMailEncoderTests { from: .init("from@example.com"), to: [.init("to@example.com")], cc: cc, + bcc: bcc, replyTo: replyTo, subject: "Test Subject", body: body, @@ -40,7 +41,7 @@ struct RawMailEncoderTests { let mail = makeMail(body: .plainText("Hello")) let raw = try encoder.encode( - mail, + mail: mail, dateHeader: dateHeader, messageID: messageID ) @@ -60,7 +61,7 @@ struct RawMailEncoderTests { let mail = makeMail(body: .html("Hi")) let raw = try encoder.encode( - mail, + mail: mail, dateHeader: dateHeader, messageID: messageID ) @@ -78,7 +79,7 @@ struct RawMailEncoderTests { ) let raw = try encoder.encode( - mail, + mail: mail, dateHeader: dateHeader, messageID: messageID ) @@ -89,6 +90,23 @@ struct RawMailEncoderTests { #expect(raw.contains("References: \r\n")) } + @Test + func bccIsNotWrittenToHeaders() throws { + let mail = makeMail( + body: .plainText("Hello"), + bcc: [.init("hidden@example.com")] + ) + + let raw = try encoder.encode( + mail: mail, + dateHeader: dateHeader, + messageID: messageID + ) + + #expect(!raw.contains("Bcc:")) + #expect(!raw.contains("hidden@example.com")) + } + @Test func htmlWithAttachmentUsesMultipartAndHtmlBody() throws { let attachment = FeatherMail.Attachment( @@ -102,7 +120,7 @@ struct RawMailEncoderTests { ) let raw = try encoder.encode( - mail, + mail: mail, dateHeader: dateHeader, messageID: messageID ) @@ -129,7 +147,7 @@ struct RawMailEncoderTests { ) let raw = try encoder.encode( - mail, + mail: mail, dateHeader: dateHeader, messageID: messageID ) @@ -144,17 +162,52 @@ struct RawMailEncoderTests { #expect(raw.contains("SGVsbG8=")) let boundaryLinePrefix = "Content-type: multipart/mixed; boundary=\"" - guard let boundaryLineStart = raw.range(of: boundaryLinePrefix) else { + guard + let boundaryLine = raw.split(separator: "\r\n") + .first(where: { + $0.hasPrefix(boundaryLinePrefix) + }) + else { Issue.record("Missing boundary header") return } - let afterPrefix = raw[boundaryLineStart.upperBound...] - guard let endQuote = afterPrefix.firstIndex(of: "\"") else { + let boundarySuffix = boundaryLine.dropFirst(boundaryLinePrefix.count) + guard boundarySuffix.last == "\"" else { Issue.record("Missing boundary terminator") return } - let boundary = String(afterPrefix[.. Date: Fri, 6 Feb 2026 17:29:07 +0100 Subject: [PATCH 2/9] Update DefaultBoundaryGenerator.swift --- .../FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift b/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift index 7585e4b..8308797 100644 --- a/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift +++ b/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift @@ -5,6 +5,7 @@ // Created by Binary Birds on 2026. 02. 06.. // +/// Default MIME boundary generator implementation. public struct DefaultBoundaryGenerator: BoundaryGenerator { /// Creates a default MIME boundary generator. From fa29c6228a670145d36f624a186c2c9137762a00 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 11 Feb 2026 09:16:16 +0100 Subject: [PATCH 3/9] add MailHeaderProvider --- .../FeatherMail/Encoder/Base64+UInt8.swift | 54 +++++++++++++++++++ .../Encoder/MailHeaderProvider.swift | 36 +++++++++++++ .../FeatherMail/Encoder/RawMailEncoder.swift | 48 ----------------- 3 files changed, 90 insertions(+), 48 deletions(-) create mode 100644 Sources/FeatherMail/Encoder/Base64+UInt8.swift create mode 100644 Sources/FeatherMail/Encoder/MailHeaderProvider.swift diff --git a/Sources/FeatherMail/Encoder/Base64+UInt8.swift b/Sources/FeatherMail/Encoder/Base64+UInt8.swift new file mode 100644 index 0000000..ed4b489 --- /dev/null +++ b/Sources/FeatherMail/Encoder/Base64+UInt8.swift @@ -0,0 +1,54 @@ +// +// Base64+UInt8.swift +// feather-mail +// +// Created by Binary Birds on 2026. 02. 06.. +// + +// Foundation-free Base64 encoding for byte arrays. +public extension Array where Element == UInt8 { + /// Returns a Base64 string representation of the byte array. + func base64EncodedString() -> String { + guard !isEmpty else { + return "" + } + + let alphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + .utf8 + ) + var output: [UInt8] = [] + output.reserveCapacity(((count + 2) / 3) * 4) + + var index = 0 + while index < count { + let byte0 = self[index] + let byte1 = (index + 1 < count) ? self[index + 1] : 0 + let byte2 = (index + 2 < count) ? self[index + 2] : 0 + + let triple = + (UInt32(byte0) << 16) | (UInt32(byte1) << 8) | UInt32(byte2) + + output.append(alphabet[Int((triple >> 18) & 0x3F)]) + output.append(alphabet[Int((triple >> 12) & 0x3F)]) + + if index + 1 < count { + output.append(alphabet[Int((triple >> 6) & 0x3F)]) + } + else { + output.append(UInt8(ascii: "=")) + } + + if index + 2 < count { + output.append(alphabet[Int(triple & 0x3F)]) + } + else { + output.append(UInt8(ascii: "=")) + } + + index += 3 + } + + return String(decoding: output, as: UTF8.self) + } +} diff --git a/Sources/FeatherMail/Encoder/MailHeaderProvider.swift b/Sources/FeatherMail/Encoder/MailHeaderProvider.swift new file mode 100644 index 0000000..84a71ab --- /dev/null +++ b/Sources/FeatherMail/Encoder/MailHeaderProvider.swift @@ -0,0 +1,36 @@ +// +// MailHeaderProvider.swift +// feather-mail +// +// Created by Binary Birds on 2026. 02. 06.. +// + +/// Provides RFC 2822-compatible mail header values. +public protocol MailHeaderProvider: Sendable { + + /// Returns an RFC 2822-formatted date header value. + func dateHeader() -> String + + /// Returns a Message-ID header value, including angle brackets. + func messageID(for mail: Mail) -> String +} + +/// Default header provider that uses a caller-supplied date header. +public struct DefaultMailHeaderProvider: MailHeaderProvider { + + private let dateHeaderValue: String + + /// Creates a header provider with a preformatted date header value. + public init(dateHeader: String) { + self.dateHeaderValue = dateHeader + } + + public func dateHeader() -> String { + dateHeaderValue + } + + public func messageID(for mail: Mail) -> String { + let nonce = UInt64.random(in: UInt64.min...UInt64.max) + return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" + } +} diff --git a/Sources/FeatherMail/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift index 99c7e43..9af6737 100644 --- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -125,51 +125,3 @@ public struct RawMailEncoder: MailEncoder { } // MARK: - Helpers - -// Foundation-free Base64 encoding for byte arrays. -private extension Array where Element == UInt8 { - /// Returns a Base64 string representation of the byte array. - func base64EncodedString() -> String { - guard !isEmpty else { - return "" - } - - let alphabet = Array( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" - .utf8 - ) - var output: [UInt8] = [] - output.reserveCapacity(((count + 2) / 3) * 4) - - var index = 0 - while index < count { - let byte0 = self[index] - let byte1 = (index + 1 < count) ? self[index + 1] : 0 - let byte2 = (index + 2 < count) ? self[index + 2] : 0 - - let triple = - (UInt32(byte0) << 16) | (UInt32(byte1) << 8) | UInt32(byte2) - - output.append(alphabet[Int((triple >> 18) & 0x3F)]) - output.append(alphabet[Int((triple >> 12) & 0x3F)]) - - if index + 1 < count { - output.append(alphabet[Int((triple >> 6) & 0x3F)]) - } - else { - output.append(UInt8(ascii: "=")) - } - - if index + 2 < count { - output.append(alphabet[Int(triple & 0x3F)]) - } - else { - output.append(UInt8(ascii: "=")) - } - - index += 3 - } - - return String(decoding: output, as: UTF8.self) - } -} From 5b949c124fc432f74cbc1565cbf91521bb73c3ad Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 11 Feb 2026 09:18:25 +0100 Subject: [PATCH 4/9] Update MailHeaderProvider.swift --- Sources/FeatherMail/Encoder/MailHeaderProvider.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/FeatherMail/Encoder/MailHeaderProvider.swift b/Sources/FeatherMail/Encoder/MailHeaderProvider.swift index 84a71ab..aceea4c 100644 --- a/Sources/FeatherMail/Encoder/MailHeaderProvider.swift +++ b/Sources/FeatherMail/Encoder/MailHeaderProvider.swift @@ -25,10 +25,12 @@ public struct DefaultMailHeaderProvider: MailHeaderProvider { self.dateHeaderValue = dateHeader } + /// Returns the caller-supplied RFC 2822-formatted date header value. public func dateHeader() -> String { dateHeaderValue } + /// Returns a Message-ID header value derived from the mail sender. public func messageID(for mail: Mail) -> String { let nonce = UInt64.random(in: UInt64.min...UInt64.max) return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" From cf02d599c9226f937acc827dacc9c274e0c8d72a Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 11 Feb 2026 09:22:04 +0100 Subject: [PATCH 5/9] update workflows --- .github/workflows/testing.yml | 14 ++++++-------- README.md | 3 ++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 68dadfa..fe7fad0 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,6 +7,10 @@ on: jobs: + api_breakage: + name: Check API breakage + uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@main + swiftlang_checks: name: Swiftlang Checks uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main @@ -27,13 +31,7 @@ jobs: uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main with: local_swift_dependencies_check_enabled : true + run_tests_with_cache_enabled : true headers_check_enabled : false docc_warnings_check_enabled : true - - swiftlang_tests: - name: Swiftlang Tests - uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main - with: - enable_windows_checks : false - linux_build_command: "swift test --parallel --enable-code-coverage" - linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"6.0\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]" \ No newline at end of file + run_tests_swift_versions: '["6.1","6.2"]' \ No newline at end of file diff --git a/README.md b/README.md index 61ab570..5601bde 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ An abstract mail component for Feather CMS. ## Requirements ![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%2B-F05138) -![Platforms: macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138) +![Platforms: Linux, macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-Linux_%7C_macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138) - Swift 6.1+ - Platforms: + - Linux - macOS 15+ - iOS 18+ - tvOS 18+ From 5e2beabf035abad438f705e697191d78e9f56192 Mon Sep 17 00:00:00 2001 From: Tibor Bodecs Date: Wed, 11 Feb 2026 09:44:24 +0100 Subject: [PATCH 6/9] new encoder api --- Sources/FeatherMail/Encoder/MailEncoder.swift | 29 +--------------- .../FeatherMail/Encoder/RawMailEncoder.swift | 34 +++++++++++++------ .../RawMailEncoderTests.swift | 15 -------- 3 files changed, 25 insertions(+), 53 deletions(-) diff --git a/Sources/FeatherMail/Encoder/MailEncoder.swift b/Sources/FeatherMail/Encoder/MailEncoder.swift index 17061a6..6b5b4a8 100644 --- a/Sources/FeatherMail/Encoder/MailEncoder.swift +++ b/Sources/FeatherMail/Encoder/MailEncoder.swift @@ -21,34 +21,7 @@ public protocol MailEncoder: Sendable { /// - Returns: A raw MIME string suitable for transport providers. /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. func encode( - mail: Mail, - dateHeader: String, - messageID: String, - boundary: String? + mail: Mail ) throws(MailError) -> String } -public extension MailEncoder { - - /// Encodes a mail into a raw MIME message string. - /// - /// - Parameters: - /// - mail: The mail to encode. The mail must be validated - /// before calling this method. - /// - dateHeader: RFC 2822-formatted date header value. - /// - messageID: Message identifier value, including angle brackets. - /// - Returns: A raw MIME string suitable for transport providers. - /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. - func encode( - mail: Mail, - dateHeader: String, - messageID: String - ) throws(MailError) -> String { - try encode( - mail: mail, - dateHeader: dateHeader, - messageID: messageID, - boundary: nil - ) - } -} diff --git a/Sources/FeatherMail/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift index 9af6737..1ba6c12 100644 --- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -20,13 +20,30 @@ /// the `Mail` instance before encoding. public struct RawMailEncoder: MailEncoder { - private let boundaryGenerator: BoundaryGenerator +// private let boundaryGenerator: BoundaryGenerator + + var boundaryEncodingStrategy: (@Sendable (Mail) -> String) + var messageIDEncodingStrategy: (@Sendable (Mail) -> String) + var hederEncodingStrategy: (@Sendable (Mail) -> String) /// Creates a raw mail encoder. public init( - boundaryGenerator: BoundaryGenerator = DefaultBoundaryGenerator() + boundaryEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { _ in + "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))" + }, + messageIDEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { mail in + return "<\(mail.from.email)>" + }, + hederEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { mail in + let nonce = UInt64.random(in: UInt64.min...UInt64.max) + return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" + }, +// boundaryGenerator: BoundaryGenerator = DefaultBoundaryGenerator() ) { - self.boundaryGenerator = boundaryGenerator +// self.boundaryGenerator = boundaryGenerator + self.boundaryEncodingStrategy = boundaryEncodingStrategy + self.messageIDEncodingStrategy = messageIDEncodingStrategy + self.hederEncodingStrategy = hederEncodingStrategy } /// Encodes a mail into a raw MIME message string. @@ -41,10 +58,7 @@ public struct RawMailEncoder: MailEncoder { /// - Returns: A raw MIME string suitable for transport providers. /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. public func encode( - mail: Mail, - dateHeader: String, - messageID: String, - boundary: String? + mail: Mail ) throws(MailError) -> String { var out = String() @@ -66,8 +80,8 @@ public struct RawMailEncoder: MailEncoder { } out += "Subject: \(mail.subject)\r\n" - out += "Date: \(dateHeader)\r\n" - out += "Message-ID: \(messageID)\r\n" + out += "Date: \(hederEncodingStrategy(mail))\r\n" + out += "Message-ID: \(messageIDEncodingStrategy(mail))\r\n" if let reference = mail.reference { out += "In-Reply-To: \(reference)\r\n" @@ -77,7 +91,7 @@ public struct RawMailEncoder: MailEncoder { let boundary = mail.attachments.isEmpty ? nil - : (boundary ?? boundaryGenerator.generate()) + : (boundaryEncodingStrategy(mail)) if let boundary { out += "Content-type: multipart/mixed; boundary=\"\(boundary)\"\r\n" diff --git a/Tests/FeatherMailTests/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift index 8e54b12..165aac3 100644 --- a/Tests/FeatherMailTests/RawMailEncoderTests.swift +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -42,8 +42,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID ) #expect(raw.contains("From: from@example.com\r\n")) @@ -62,8 +60,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID ) #expect(raw.contains("Content-Type: text/html; charset=\"UTF-8\"\r\n")) @@ -80,8 +76,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID ) #expect(raw.contains("Cc: cc@example.com\r\n")) @@ -99,8 +93,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID ) #expect(!raw.contains("Bcc:")) @@ -121,8 +113,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID ) #expect(raw.contains("Content-type: multipart/mixed; boundary=\"")) @@ -148,8 +138,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID ) #expect(raw.contains("Content-type: multipart/mixed; boundary=\"")) @@ -197,9 +185,6 @@ struct RawMailEncoderTests { let raw = try encoder.encode( mail: mail, - dateHeader: dateHeader, - messageID: messageID, - boundary: customBoundary ) #expect( From cde961236c928e9a7fe8888724e3a67637462a90 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 11 Feb 2026 11:02:20 +0100 Subject: [PATCH 7/9] upgrade RawMailEncoder --- Makefile | 3 + .../Encoder/Boundary/BoundaryGenerator.swift | 13 - .../Boundary/DefaultBoundaryGenerator.swift | 18 -- Sources/FeatherMail/Encoder/MailEncoder.swift | 11 +- .../Encoder/MailHeaderProvider.swift | 38 --- .../FeatherMail/Encoder/RawMailEncoder.swift | 47 ++-- .../Models/Error/MailValidationError.swift | 3 + .../RawMailEncoderTests.swift | 229 +++++++++++++++++- 8 files changed, 256 insertions(+), 106 deletions(-) delete mode 100644 Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift delete mode 100644 Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift delete mode 100644 Sources/FeatherMail/Encoder/MailHeaderProvider.swift diff --git a/Makefile b/Makefile index 286df1c..9372543 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/he check: symlinks language deps lint headers docc-warnings package +breakage: + curl -s $(baseUrl)/check-api-breakage.sh | bash + package: curl -s $(baseUrl)/check-swift-package.sh | bash diff --git a/Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift b/Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift deleted file mode 100644 index 8d4657b..0000000 --- a/Sources/FeatherMail/Encoder/Boundary/BoundaryGenerator.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// BoundaryGenerator.swift -// feather-mail -// -// Created by Binary Birds on 2026. 02. 06.. -// - -/// Generates MIME boundary strings without Foundation dependencies. -public protocol BoundaryGenerator: Sendable { - - /// Returns a unique MIME boundary string. - func generate() -> String -} diff --git a/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift b/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift deleted file mode 100644 index 8308797..0000000 --- a/Sources/FeatherMail/Encoder/Boundary/DefaultBoundaryGenerator.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// DefaultBoundaryGenerator.swift -// feather-mail -// -// Created by Binary Birds on 2026. 02. 06.. -// - -/// Default MIME boundary generator implementation. -public struct DefaultBoundaryGenerator: BoundaryGenerator { - - /// Creates a default MIME boundary generator. - public init() {} - - /// Generates a unique MIME boundary without Foundation. - public func generate() -> String { - "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))" - } -} diff --git a/Sources/FeatherMail/Encoder/MailEncoder.swift b/Sources/FeatherMail/Encoder/MailEncoder.swift index 6b5b4a8..88a81db 100644 --- a/Sources/FeatherMail/Encoder/MailEncoder.swift +++ b/Sources/FeatherMail/Encoder/MailEncoder.swift @@ -10,18 +10,11 @@ public protocol MailEncoder: Sendable { /// Encodes a mail into a raw MIME message string. /// - /// - Parameters: - /// - mail: The mail to encode. The mail must be validated - /// before calling this method. - /// - dateHeader: RFC 2822-formatted date header value. - /// - messageID: Message identifier value, including angle brackets. - /// - boundary: Optional custom MIME boundary. If `nil`, the encoder - /// generates one when attachments are present. Provide a valid - /// MIME boundary string without surrounding quotes. + /// - Parameter mail: The mail to encode. The mail must be validated + /// before calling this method. /// - Returns: A raw MIME string suitable for transport providers. /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. func encode( mail: Mail ) throws(MailError) -> String } - diff --git a/Sources/FeatherMail/Encoder/MailHeaderProvider.swift b/Sources/FeatherMail/Encoder/MailHeaderProvider.swift deleted file mode 100644 index aceea4c..0000000 --- a/Sources/FeatherMail/Encoder/MailHeaderProvider.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// MailHeaderProvider.swift -// feather-mail -// -// Created by Binary Birds on 2026. 02. 06.. -// - -/// Provides RFC 2822-compatible mail header values. -public protocol MailHeaderProvider: Sendable { - - /// Returns an RFC 2822-formatted date header value. - func dateHeader() -> String - - /// Returns a Message-ID header value, including angle brackets. - func messageID(for mail: Mail) -> String -} - -/// Default header provider that uses a caller-supplied date header. -public struct DefaultMailHeaderProvider: MailHeaderProvider { - - private let dateHeaderValue: String - - /// Creates a header provider with a preformatted date header value. - public init(dateHeader: String) { - self.dateHeaderValue = dateHeader - } - - /// Returns the caller-supplied RFC 2822-formatted date header value. - public func dateHeader() -> String { - dateHeaderValue - } - - /// Returns a Message-ID header value derived from the mail sender. - public func messageID(for mail: Mail) -> String { - let nonce = UInt64.random(in: UInt64.min...UInt64.max) - return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" - } -} diff --git a/Sources/FeatherMail/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift index 1ba6c12..3b24529 100644 --- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -12,7 +12,7 @@ /// The encoding logic preserves the legacy behavior and output format. /// /// The encoder: -/// - builds standard email headers (From, To, Cc, Reply-To, Subject, Date) +/// - builds standard email headers (From, To, Cc, Reply-To, Subject, Date, Message-ID) /// - supports plain text and HTML bodies /// - supports attachments using `multipart/mixed` /// @@ -20,47 +20,50 @@ /// the `Mail` instance before encoding. public struct RawMailEncoder: MailEncoder { -// private let boundaryGenerator: BoundaryGenerator - var boundaryEncodingStrategy: (@Sendable (Mail) -> String) var messageIDEncodingStrategy: (@Sendable (Mail) -> String) - var hederEncodingStrategy: (@Sendable (Mail) -> String) + var headerDateString: String /// Creates a raw mail encoder. + /// + /// - Parameters: + /// - boundaryEncodingStrategy: Provides the MIME boundary string used + /// when attachments are present. + /// - messageIDEncodingStrategy: Provides the Message-ID header value, + /// including angle brackets. + /// - headerDateString: Provides the RFC 2822-formatted Date header + /// value, for example `Mon, 26 Jan 2026 12:34:56 +0000`. public init( - boundaryEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { _ in + boundaryEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { + _ in "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))" }, - messageIDEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { mail in - return "<\(mail.from.email)>" - }, - hederEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { mail in + messageIDEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { + mail in let nonce = UInt64.random(in: UInt64.min...UInt64.max) return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" }, -// boundaryGenerator: BoundaryGenerator = DefaultBoundaryGenerator() + headerDateString: String ) { -// self.boundaryGenerator = boundaryGenerator self.boundaryEncodingStrategy = boundaryEncodingStrategy self.messageIDEncodingStrategy = messageIDEncodingStrategy - self.hederEncodingStrategy = hederEncodingStrategy + self.headerDateString = headerDateString } /// Encodes a mail into a raw MIME message string. /// - /// - Parameters: - /// - mail: The mail to encode. The mail must be validated - /// before calling this method. - /// - dateHeader: RFC 2822-formatted date header value. - /// - messageID: Message identifier value, including angle brackets. - /// - boundary: Optional custom MIME boundary. If `nil`, the encoder - /// generates one when attachments are present. + /// - Parameter mail: The mail to encode. The mail must be validated + /// before calling this method. /// - Returns: A raw MIME string suitable for transport providers. - /// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed. + /// - Throws: `MailError.validation(.emptyHeaderDateString)` when the configured Date header value is empty. public func encode( mail: Mail ) throws(MailError) -> String { + guard !headerDateString.isEmpty else { + throw MailError.validation(.emptyHeaderDateString) + } + var out = String() out.reserveCapacity(4096) @@ -80,7 +83,7 @@ public struct RawMailEncoder: MailEncoder { } out += "Subject: \(mail.subject)\r\n" - out += "Date: \(hederEncodingStrategy(mail))\r\n" + out += "Date: \(headerDateString)\r\n" out += "Message-ID: \(messageIDEncodingStrategy(mail))\r\n" if let reference = mail.reference { @@ -137,5 +140,3 @@ public struct RawMailEncoder: MailEncoder { return out } } - -// MARK: - Helpers diff --git a/Sources/FeatherMail/Models/Error/MailValidationError.swift b/Sources/FeatherMail/Models/Error/MailValidationError.swift index 01c47dc..7b21e16 100644 --- a/Sources/FeatherMail/Models/Error/MailValidationError.swift +++ b/Sources/FeatherMail/Models/Error/MailValidationError.swift @@ -35,6 +35,9 @@ public enum MailValidationError: Error, Equatable { // MARK: - Encoding errors + /// The required Date header value is empty. + case emptyHeaderDateString + /// Failed to encode the mail into the required transport format. /// /// This can occur when generating raw MIME data for providers like SES. diff --git a/Tests/FeatherMailTests/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift index 165aac3..fdaabee 100644 --- a/Tests/FeatherMailTests/RawMailEncoderTests.swift +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -5,15 +5,37 @@ // Created by Binary Birds on 2026. 01. 26.. // +import Foundation import Testing @testable import FeatherMail @Suite struct RawMailEncoderTests { - private let encoder = RawMailEncoder() - private let dateHeader = "Mon, 26 Jan 2026 12:34:56 +0000" - private let messageID = "<12345@example.com>" + private func expectedDateHeader() -> String { + "Mon, 26 Jan 2026 12:34:56 +0000" + } + + private func expectedMessageID() -> String { + "<12345@example.com>" + } + + private func makeEncoder( + boundary: String? = nil, + messageID: String? = nil + ) -> RawMailEncoder { + let dateHeader = expectedDateHeader() + let messageID = messageID ?? expectedMessageID() + return RawMailEncoder( + boundaryEncodingStrategy: { _ in + boundary ?? "Boundary-Test" + }, + messageIDEncodingStrategy: { _ in + messageID + }, + headerDateString: dateHeader + ) + } private func makeMail( body: Body, @@ -39,6 +61,7 @@ struct RawMailEncoderTests { @Test func plainTextWithoutAttachmentsDoesNotUseMultipart() throws { let mail = makeMail(body: .plainText("Hello")) + let encoder = makeEncoder() let raw = try encoder.encode( mail: mail, @@ -47,8 +70,8 @@ struct RawMailEncoderTests { #expect(raw.contains("From: from@example.com\r\n")) #expect(raw.contains("To: to@example.com\r\n")) #expect(raw.contains("Subject: Test Subject\r\n")) - #expect(raw.contains("Date: \(dateHeader)\r\n")) - #expect(raw.contains("Message-ID: \(messageID)\r\n")) + #expect(raw.contains("Date: \(expectedDateHeader())\r\n")) + #expect(raw.contains("Message-ID: \(expectedMessageID())\r\n")) #expect(raw.contains("Content-Type: text/plain; charset=\"UTF-8\"\r\n")) #expect(!raw.contains("multipart/mixed")) #expect(!raw.contains("Content-Disposition: attachment")) @@ -57,12 +80,14 @@ struct RawMailEncoderTests { @Test func htmlBodyUsesHtmlContentType() throws { let mail = makeMail(body: .html("Hi")) + let encoder = makeEncoder() let raw = try encoder.encode( mail: mail, ) #expect(raw.contains("Content-Type: text/html; charset=\"UTF-8\"\r\n")) + #expect(!raw.contains("multipart/mixed")) } @Test @@ -73,6 +98,7 @@ struct RawMailEncoderTests { replyTo: [.init("reply@example.com")], reference: "" ) + let encoder = makeEncoder() let raw = try encoder.encode( mail: mail, @@ -90,6 +116,7 @@ struct RawMailEncoderTests { body: .plainText("Hello"), bcc: [.init("hidden@example.com")] ) + let encoder = makeEncoder() let raw = try encoder.encode( mail: mail, @@ -110,6 +137,7 @@ struct RawMailEncoderTests { body: .html("Hi"), attachments: [attachment] ) + let encoder = makeEncoder() let raw = try encoder.encode( mail: mail, @@ -135,6 +163,7 @@ struct RawMailEncoderTests { body: .plainText("Body"), attachments: [attachment] ) + let encoder = makeEncoder() let raw = try encoder.encode( mail: mail, @@ -148,6 +177,7 @@ struct RawMailEncoderTests { ) #expect(raw.contains("Content-Transfer-Encoding: base64")) #expect(raw.contains("SGVsbG8=")) + #expect(raw.contains("Mime-Version: 1.0\r\n\r\n")) let boundaryLinePrefix = "Content-type: multipart/mixed; boundary=\"" guard @@ -182,6 +212,7 @@ struct RawMailEncoderTests { attachments: [attachment] ) let customBoundary = "Custom-12345" + let encoder = makeEncoder(boundary: customBoundary) let raw = try encoder.encode( mail: mail, @@ -195,4 +226,192 @@ struct RawMailEncoderTests { #expect(raw.contains("--\(customBoundary)\r\n")) #expect(raw.contains("--\(customBoundary)--\r\n")) } + + @Test + func customMessageIDIsUsedWhenProvided() throws { + let mail = makeMail(body: .plainText("Hello")) + let customMessageID = "" + let encoder = makeEncoder(messageID: customMessageID) + + let raw = try encoder.encode( + mail: mail, + ) + + #expect(raw.contains("Message-ID: \(customMessageID)\r\n")) + } + + @Test + func makeEncoderHonorsCustomBoundaryAndMessageID() throws { + let attachment = FeatherMail.Attachment( + name: "file.txt", + contentType: "text/plain", + data: Array("Hello".utf8) + ) + let mail = makeMail( + body: .plainText("Body"), + attachments: [attachment] + ) + let customBoundary = "Boundary-From-MakeEncoder" + let customMessageID = "" + let encoder = makeEncoder( + boundary: customBoundary, + messageID: customMessageID + ) + + let raw = try encoder.encode( + mail: mail, + ) + + #expect( + raw.contains( + "Content-type: multipart/mixed; boundary=\"\(customBoundary)\"" + ) + ) + #expect(raw.contains("--\(customBoundary)\r\n")) + #expect(raw.contains("Message-ID: \(customMessageID)\r\n")) + } + + @Test + func boundaryStrategyIsNotInvokedWithoutAttachments() throws { + let encoder = RawMailEncoder( + boundaryEncodingStrategy: { _ in + Issue.record("Boundary strategy should not be invoked.") + return "Boundary-Strategy" + }, + messageIDEncodingStrategy: { _ in + expectedMessageID() + }, + headerDateString: expectedDateHeader() + ) + let mail = makeMail(body: .plainText("Hello")) + + let raw = try encoder.encode( + mail: mail, + ) + + #expect(!raw.contains("Content-type: multipart/mixed")) + } + + @Test + func boundaryStrategyIsInvokedWithAttachments() throws { + let encoder = RawMailEncoder( + boundaryEncodingStrategy: { _ in + return "Boundary-Strategy" + }, + messageIDEncodingStrategy: { _ in + expectedMessageID() + }, + headerDateString: expectedDateHeader() + ) + let attachment = FeatherMail.Attachment( + name: "file.txt", + contentType: "text/plain", + data: Array("Hello".utf8) + ) + let mail = makeMail( + body: .plainText("Body"), + attachments: [attachment] + ) + + let raw = try encoder.encode( + mail: mail, + ) + + #expect( + raw.contains( + "Content-type: multipart/mixed; boundary=\"Boundary-Strategy\"" + ) + ) + } + + @Test + func messageIDStrategyReceivesMail() throws { + let customMessageID = "" + let encoder = RawMailEncoder( + boundaryEncodingStrategy: { _ in + "Boundary-Strategy" + }, + messageIDEncodingStrategy: { mail in + "" + }, + headerDateString: expectedDateHeader() + ) + let mail = makeMail(body: .plainText("Hello")) + + let raw = try encoder.encode( + mail: mail, + ) + + #expect(raw.contains("Message-ID: \(customMessageID)\r\n")) + } + + @Test + func multipartBodyIncludesPlainTextPartBeforeAttachments() throws { + let attachment = FeatherMail.Attachment( + name: "file.txt", + contentType: "text/plain", + data: Array("Hello".utf8) + ) + let mail = makeMail( + body: .plainText("Body"), + attachments: [attachment] + ) + let encoder = makeEncoder() + + let raw = try encoder.encode( + mail: mail, + ) + + let bodyIndex = raw.range( + of: "Content-Type: text/plain; charset=\"UTF-8\"\r\n" + )? + .lowerBound + let attachmentIndex = raw.range( + of: "Content-Disposition: attachment; filename=\"file.txt\"" + )? + .lowerBound + #expect(bodyIndex != nil) + #expect(attachmentIndex != nil) + if let bodyIndex, let attachmentIndex { + #expect(bodyIndex < attachmentIndex) + } + } + + @Test + func htmlWithoutAttachmentsDoesNotEmitBoundaryMarkers() throws { + let mail = makeMail(body: .html("Hello")) + let encoder = makeEncoder() + + let raw = try encoder.encode( + mail: mail, + ) + + #expect(!raw.contains("--Boundary-Test")) + #expect(!raw.contains("Content-type: multipart/mixed")) + } + + @Test + func emptyHeaderDateStringThrowsValidationError() { + let encoder = RawMailEncoder( + boundaryEncodingStrategy: { _ in + "Boundary-Strategy" + }, + messageIDEncodingStrategy: { _ in + "" + }, + headerDateString: "" + ) + let mail = makeMail(body: .plainText("Hello")) + + do { + _ = try encoder.encode(mail: mail) + Issue.record("Expected an emptyHeaderDateString validation error.") + } + catch MailError.validation(let error) { + #expect(error == .emptyHeaderDateString) + } + catch { + Issue.record("Unexpected error type.") + } + } } From 5dfdf4e0e0ba63b4b86a62d87f36595c063ac50d Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 11 Feb 2026 11:49:55 +0100 Subject: [PATCH 8/9] add headerDateEncodingStrategy --- .../FeatherMail/Encoder/RawMailEncoder.swift | 16 +++++++-------- .../RawMailEncoderTests.swift | 20 ++++++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/Sources/FeatherMail/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift index 3b24529..d749128 100644 --- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -22,7 +22,7 @@ public struct RawMailEncoder: MailEncoder { var boundaryEncodingStrategy: (@Sendable (Mail) -> String) var messageIDEncodingStrategy: (@Sendable (Mail) -> String) - var headerDateString: String + var headerDateEncodingStrategy: (@Sendable () -> String) /// Creates a raw mail encoder. /// @@ -31,7 +31,7 @@ public struct RawMailEncoder: MailEncoder { /// when attachments are present. /// - messageIDEncodingStrategy: Provides the Message-ID header value, /// including angle brackets. - /// - headerDateString: Provides the RFC 2822-formatted Date header + /// - headerDateEncodingStrategy: Provides the RFC 2822-formatted Date header /// value, for example `Mon, 26 Jan 2026 12:34:56 +0000`. public init( boundaryEncodingStrategy: (@escaping @Sendable (Mail) -> String) = { @@ -43,11 +43,13 @@ public struct RawMailEncoder: MailEncoder { let nonce = UInt64.random(in: UInt64.min...UInt64.max) return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" }, - headerDateString: String + headerDateEncodingStrategy: (@escaping @Sendable () -> String) = { + return "" + } ) { self.boundaryEncodingStrategy = boundaryEncodingStrategy self.messageIDEncodingStrategy = messageIDEncodingStrategy - self.headerDateString = headerDateString + self.headerDateEncodingStrategy = headerDateEncodingStrategy } /// Encodes a mail into a raw MIME message string. @@ -60,13 +62,11 @@ public struct RawMailEncoder: MailEncoder { mail: Mail ) throws(MailError) -> String { - guard !headerDateString.isEmpty else { + guard !headerDateEncodingStrategy().isEmpty else { throw MailError.validation(.emptyHeaderDateString) } var out = String() - out.reserveCapacity(4096) - out += "From: \(mail.from.mime)\r\n" if !mail.to.isEmpty { @@ -83,7 +83,7 @@ public struct RawMailEncoder: MailEncoder { } out += "Subject: \(mail.subject)\r\n" - out += "Date: \(headerDateString)\r\n" + out += "Date: \(headerDateEncodingStrategy())\r\n" out += "Message-ID: \(messageIDEncodingStrategy(mail))\r\n" if let reference = mail.reference { diff --git a/Tests/FeatherMailTests/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift index fdaabee..e225dc1 100644 --- a/Tests/FeatherMailTests/RawMailEncoderTests.swift +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -33,7 +33,9 @@ struct RawMailEncoderTests { messageIDEncodingStrategy: { _ in messageID }, - headerDateString: dateHeader + headerDateEncodingStrategy: { + dateHeader + } ) } @@ -281,7 +283,9 @@ struct RawMailEncoderTests { messageIDEncodingStrategy: { _ in expectedMessageID() }, - headerDateString: expectedDateHeader() + headerDateEncodingStrategy: { + expectedDateHeader() + } ) let mail = makeMail(body: .plainText("Hello")) @@ -301,7 +305,9 @@ struct RawMailEncoderTests { messageIDEncodingStrategy: { _ in expectedMessageID() }, - headerDateString: expectedDateHeader() + headerDateEncodingStrategy: { + expectedDateHeader() + } ) let attachment = FeatherMail.Attachment( name: "file.txt", @@ -334,7 +340,9 @@ struct RawMailEncoderTests { messageIDEncodingStrategy: { mail in "" }, - headerDateString: expectedDateHeader() + headerDateEncodingStrategy: { + expectedDateHeader() + } ) let mail = makeMail(body: .plainText("Hello")) @@ -399,7 +407,9 @@ struct RawMailEncoderTests { messageIDEncodingStrategy: { _ in "" }, - headerDateString: "" + headerDateEncodingStrategy: { + "" + } ) let mail = makeMail(body: .plainText("Hello")) From cf92e11e1d33aafa8fbb0bbfa37dcecc5733afdf Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 11 Feb 2026 12:07:15 +0100 Subject: [PATCH 9/9] Update RawMailEncoder.swift --- Sources/FeatherMail/Encoder/RawMailEncoder.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/FeatherMail/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift index d749128..01674c5 100644 --- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -43,9 +43,7 @@ public struct RawMailEncoder: MailEncoder { let nonce = UInt64.random(in: UInt64.min...UInt64.max) return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>" }, - headerDateEncodingStrategy: (@escaping @Sendable () -> String) = { - return "" - } + headerDateEncodingStrategy: (@escaping @Sendable () -> String) ) { self.boundaryEncodingStrategy = boundaryEncodingStrategy self.messageIDEncodingStrategy = messageIDEncodingStrategy @@ -139,4 +137,5 @@ public struct RawMailEncoder: MailEncoder { return out } + }