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/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/README.md b/README.md
index 0096bae..5601bde 100644
--- a/README.md
+++ b/README.md
@@ -2,8 +2,8 @@
An abstract mail component for Feather CMS.
-[](
- https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.1
+[](
+ https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.2
)
## Features
@@ -16,10 +16,11 @@ An abstract mail component for Feather CMS.
## Requirements

-
+
- Swift 6.1+
- Platforms:
+ - Linux
- macOS 15+
- iOS 18+
- tvOS 18+
@@ -31,7 +32,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/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/MailEncoder.swift b/Sources/FeatherMail/Encoder/MailEncoder.swift
new file mode 100644
index 0000000..88a81db
--- /dev/null
+++ b/Sources/FeatherMail/Encoder/MailEncoder.swift
@@ -0,0 +1,20 @@
+//
+// 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.
+ ///
+ /// - 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/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift
index 56dba96..01674c5 100644
--- a/Sources/FeatherMail/Encoder/RawMailEncoder.swift
+++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift
@@ -12,35 +12,59 @@
/// 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`
///
/// This type performs **no validation**. Callers are expected to validate
/// the `Mail` instance before encoding.
-public struct RawMailEncoder: Sendable {
+public struct RawMailEncoder: MailEncoder {
+
+ var boundaryEncodingStrategy: (@Sendable (Mail) -> String)
+ var messageIDEncodingStrategy: (@Sendable (Mail) -> String)
+ var headerDateEncodingStrategy: (@Sendable () -> String)
/// Creates a raw mail encoder.
- public init() {}
+ ///
+ /// - Parameters:
+ /// - boundaryEncodingStrategy: Provides the MIME boundary string used
+ /// when attachments are present.
+ /// - messageIDEncodingStrategy: Provides the Message-ID header value,
+ /// including angle brackets.
+ /// - 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) = {
+ _ in
+ "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))"
+ },
+ messageIDEncodingStrategy: (@escaping @Sendable (Mail) -> String) = {
+ mail in
+ let nonce = UInt64.random(in: UInt64.min...UInt64.max)
+ return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>"
+ },
+ headerDateEncodingStrategy: (@escaping @Sendable () -> String)
+ ) {
+ self.boundaryEncodingStrategy = boundaryEncodingStrategy
+ self.messageIDEncodingStrategy = messageIDEncodingStrategy
+ self.headerDateEncodingStrategy = headerDateEncodingStrategy
+ }
/// 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.
+ /// - 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,
- dateHeader: String,
- messageID: String
+ mail: Mail
) throws(MailError) -> String {
- var out = String()
- out.reserveCapacity(4096)
+ guard !headerDateEncodingStrategy().isEmpty else {
+ throw MailError.validation(.emptyHeaderDateString)
+ }
+ var out = String()
out += "From: \(mail.from.mime)\r\n"
if !mail.to.isEmpty {
@@ -57,15 +81,18 @@ public struct RawMailEncoder: Sendable {
}
out += "Subject: \(mail.subject)\r\n"
- out += "Date: \(dateHeader)\r\n"
- out += "Message-ID: \(messageID)\r\n"
+ out += "Date: \(headerDateEncodingStrategy())\r\n"
+ out += "Message-ID: \(messageIDEncodingStrategy(mail))\r\n"
if let reference = mail.reference {
out += "In-Reply-To: \(reference)\r\n"
out += "References: \(reference)\r\n"
}
- let boundary = mail.attachments.isEmpty ? nil : createBoundary()
+ let boundary =
+ mail.attachments.isEmpty
+ ? nil
+ : (boundaryEncodingStrategy(mail))
if let boundary {
out += "Content-type: multipart/mixed; boundary=\"\(boundary)\"\r\n"
@@ -103,68 +130,12 @@ public struct RawMailEncoder: Sendable {
out += attachment.data.base64EncodedString()
out += "\r\n"
}
+ out += "--\(boundary)--\r\n"
}
out += "\r\n"
return out
}
-}
-
-// 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.
- 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/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 e67b134..e225dc1 100644
--- a/Tests/FeatherMailTests/RawMailEncoderTests.swift
+++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift
@@ -12,13 +12,37 @@ import Testing
@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
+ },
+ headerDateEncodingStrategy: {
+ dateHeader
+ }
+ )
+ }
private func makeMail(
body: Body,
cc: [Address] = [],
+ bcc: [Address] = [],
replyTo: [Address] = [],
reference: String? = nil,
attachments: [FeatherMail.Attachment] = []
@@ -27,6 +51,7 @@ struct RawMailEncoderTests {
from: .init("from@example.com"),
to: [.init("to@example.com")],
cc: cc,
+ bcc: bcc,
replyTo: replyTo,
subject: "Test Subject",
body: body,
@@ -38,18 +63,17 @@ struct RawMailEncoderTests {
@Test
func plainTextWithoutAttachmentsDoesNotUseMultipart() throws {
let mail = makeMail(body: .plainText("Hello"))
+ let encoder = makeEncoder()
let raw = try encoder.encode(
- mail,
- dateHeader: dateHeader,
- messageID: messageID
+ mail: mail,
)
#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"))
@@ -58,14 +82,14 @@ struct RawMailEncoderTests {
@Test
func htmlBodyUsesHtmlContentType() throws {
let mail = makeMail(body: .html("Hi"))
+ let encoder = makeEncoder()
let raw = try encoder.encode(
- mail,
- dateHeader: dateHeader,
- messageID: messageID
+ mail: mail,
)
#expect(raw.contains("Content-Type: text/html; charset=\"UTF-8\"\r\n"))
+ #expect(!raw.contains("multipart/mixed"))
}
@Test
@@ -76,11 +100,10 @@ struct RawMailEncoderTests {
replyTo: [.init("reply@example.com")],
reference: "["
)
+ let encoder = makeEncoder()
let raw = try encoder.encode(
- mail,
- dateHeader: dateHeader,
- messageID: messageID
+ mail: mail,
)
#expect(raw.contains("Cc: cc@example.com\r\n"))
@@ -89,6 +112,22 @@ struct RawMailEncoderTests {
#expect(raw.contains("References: ][\r\n"))
}
+ @Test
+ func bccIsNotWrittenToHeaders() throws {
+ let mail = makeMail(
+ body: .plainText("Hello"),
+ bcc: [.init("hidden@example.com")]
+ )
+ let encoder = makeEncoder()
+
+ let raw = try encoder.encode(
+ mail: mail,
+ )
+
+ #expect(!raw.contains("Bcc:"))
+ #expect(!raw.contains("hidden@example.com"))
+ }
+
@Test
func htmlWithAttachmentUsesMultipartAndHtmlBody() throws {
let attachment = FeatherMail.Attachment(
@@ -100,11 +139,10 @@ struct RawMailEncoderTests {
body: .html("Hi"),
attachments: [attachment]
)
+ let encoder = makeEncoder()
let raw = try encoder.encode(
- mail,
- dateHeader: dateHeader,
- messageID: messageID
+ mail: mail,
)
#expect(raw.contains("Content-type: multipart/mixed; boundary=\""))
@@ -127,11 +165,10 @@ struct RawMailEncoderTests {
body: .plainText("Body"),
attachments: [attachment]
)
+ let encoder = makeEncoder()
let raw = try encoder.encode(
- mail,
- dateHeader: dateHeader,
- messageID: messageID
+ mail: mail,
)
#expect(raw.contains("Content-type: multipart/mixed; boundary=\""))
@@ -142,19 +179,249 @@ 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 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[.."
+ },
+ headerDateEncodingStrategy: {
+ 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
+ ""
+ },
+ headerDateEncodingStrategy: {
+ ""
+ }
+ )
+ 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.")
+ }
}
}
]