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. -[![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 @@ -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+ @@ -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.") + } } }