From 5c8e19fa10be3157ab52b0f99c58042cf7ff3e7c Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 15 Jan 2026 14:58:59 +0100 Subject: [PATCH 01/26] final changes for beta1? --- .github/workflows/testing.yml | 2 +- .swiftformatignore | 1 - LICENSE | 2 +- Makefile | 2 +- Package.swift | 13 ++--- Package@swift-6.0.swift | 47 ------------------ Package@swift-6.1.swift | 13 ++--- README.md | 7 ++- Sources/FeatherMail/Mail.swift | 2 +- Sources/FeatherMail/MailError.swift | 2 +- Sources/FeatherMail/MailProtocol.swift | 2 +- .../FeatherMailTests}/Assets/feather.png | Bin ...Tests.swift => FeatherMailTestSuite.swift} | 26 +++++----- .../Mocks/MockMailStruct.swift | 4 +- .../Mocks/MockMailTestUtil.swift | 14 +++--- .../tests/Dockerfile | 0 16 files changed, 37 insertions(+), 100 deletions(-) delete mode 100644 Package@swift-6.0.swift rename {Sources/FeatherMailTesting => Tests/FeatherMailTests}/Assets/feather.png (100%) rename Tests/FeatherMailTests/{FeatherMailTests.swift => FeatherMailTestSuite.swift} (68%) rename Sources/FeatherMailTesting/MailStruct.swift => Tests/FeatherMailTests/Mocks/MockMailStruct.swift (91%) rename Sources/FeatherMailTesting/MailTestSuite.swift => Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift (92%) rename Docker/Dockerfile.testing => docker/tests/Dockerfile (100%) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index cd98d5f..0f42d1e 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,4 +36,4 @@ jobs: 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\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]" \ No newline at end of file + 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 diff --git a/.swiftformatignore b/.swiftformatignore index 7e9965a..9a98663 100644 --- a/.swiftformatignore +++ b/.swiftformatignore @@ -1,3 +1,2 @@ Package.swift -Package@swift-6.0.swift Package@swift-6.1.swift \ No newline at end of file diff --git a/LICENSE b/LICENSE index c9749d8..d48549a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ MIT License Copyright (c) 2018-2022 Tibor Bödecs -Copyright (c) 2022-2026 Binary Birds Ltd. +Copyright (c) 2022-2026 Binary Birds Kft. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/Makefile b/Makefile index d607264..11bd428 100644 --- a/Makefile +++ b/Makefile @@ -38,5 +38,5 @@ test: swift test --parallel docker-test: - docker build -t tests . -f ./Docker/Dockerfile.testing && docker run --rm tests + docker build -t tests . -f ./docker/tests/Dockerfile && docker run --rm tests diff --git a/Package.swift b/Package.swift index 3ff9cd3..44d405d 100644 --- a/Package.swift +++ b/Package.swift @@ -15,10 +15,9 @@ let package = Package( products: [ .library(name: "FeatherMail", targets: ["FeatherMail"]), - .library(name: "FeatherMailTesting", targets: ["FeatherMailTesting"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-log", from: "1.8.0"), + .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], targets: [ .target( @@ -27,8 +26,8 @@ let package = Package( .product(name: "Logging", package: "swift-log"), ] ), - .target( - name: "FeatherMailTesting", + .testTarget( + name: "FeatherMailTests", dependencies: [ .target(name: "FeatherMail"), ], @@ -36,11 +35,5 @@ let package = Package( .copy("Assets/feather.png") ] ), - .testTarget( - name: "FeatherMailTests", - dependencies: [ - .target(name: "FeatherMailTesting"), - ] - ), ] ) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift deleted file mode 100644 index 28fb663..0000000 --- a/Package@swift-6.0.swift +++ /dev/null @@ -1,47 +0,0 @@ -// swift-tools-version:6.0 -import PackageDescription - -let package = Package( - name: "feather-mail", - - platforms: [ - .macOS(.v13), - .iOS(.v16), - .tvOS(.v16), - .watchOS(.v9), - .visionOS(.v1), - ], - - products: [ - .library(name: "FeatherMail", targets: ["FeatherMail"]), - .library(name: "FeatherMailTesting", targets: ["FeatherMailTesting"]), - ], - - dependencies: [ - .package(url: "https://github.com/apple/swift-log", from: "1.8.0") - ], - - targets: [ - .target( - name: "FeatherMail", - dependencies: [ - .product(name: "Logging", package: "swift-log") - ] - ), - .target( - name: "FeatherMailTesting", - dependencies: [ - .target(name: "FeatherMail") - ], - resources: [ - .copy("Assets/feather.png") - ] - ), - .testTarget( - name: "FeatherMailTests", - dependencies: [ - .target(name: "FeatherMailTesting") - ] - ), - ] -) diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index f7148c8..950fb12 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -14,11 +14,10 @@ let package = Package( products: [ .library(name: "FeatherMail", targets: ["FeatherMail"]), - .library(name: "FeatherMailTesting", targets: ["FeatherMailTesting"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-log", from: "1.8.0") + .package(url: "https://github.com/apple/swift-log", from: "1.6.0") ], targets: [ @@ -28,8 +27,8 @@ let package = Package( .product(name: "Logging", package: "swift-log") ] ), - .target( - name: "FeatherMailTesting", + .testTarget( + name: "FeatherMailTests", dependencies: [ .target(name: "FeatherMail") ], @@ -37,11 +36,5 @@ let package = Package( .copy("Assets/feather.png") ] ), - .testTarget( - name: "FeatherMailTests", - dependencies: [ - .target(name: "FeatherMailTesting") - ] - ), ] ) diff --git a/README.md b/README.md index e13eec7..5767008 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Use at your own risk. To add a dependency on the package, declare it in your `Package.swift`: ```swift -.package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "0.4.0")), +.package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "1.0.0-beta.1")), ``` and to your application target, add `FeatherMail` to your dependencies: @@ -31,7 +31,7 @@ import PackageDescription let package = Package( name: "my-application", dependencies: [ - .package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "0.6.0")), + .package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "1.0.0-beta.1")), ], targets: [ .target(name: "MyApplication", dependencies: [ @@ -46,5 +46,4 @@ let package = Package( ### Documentation -The official API reference for this project is available at: -[https://feather-framework.github.io/feather-mail/documentation/](https://feather-framework.github.io/feather-mail/documentation/) +For more information, see the official [API documentation](https://feather-framework.github.io/feather-mail/documentation/) for this package. diff --git a/Sources/FeatherMail/Mail.swift b/Sources/FeatherMail/Mail.swift index f3631f2..6b3ff65 100644 --- a/Sources/FeatherMail/Mail.swift +++ b/Sources/FeatherMail/Mail.swift @@ -2,7 +2,7 @@ // Mail.swift // feather-mail // -// Created by Tibor Bodecs on 2023. 01. 16.. +// Created by Tibor Bödecs on 2023. 01. 16.. // import Foundation diff --git a/Sources/FeatherMail/MailError.swift b/Sources/FeatherMail/MailError.swift index 04966af..0e7aa06 100644 --- a/Sources/FeatherMail/MailError.swift +++ b/Sources/FeatherMail/MailError.swift @@ -2,7 +2,7 @@ // MailError.swift // feather-mail // -// Created by Tibor Bodecs on 2023. 01. 16.. +// Created by Tibor Bödecs on 2023. 01. 16.. // /// Errors that can occur when initializing or sending a `Mail` object. diff --git a/Sources/FeatherMail/MailProtocol.swift b/Sources/FeatherMail/MailProtocol.swift index ef329eb..31c001c 100644 --- a/Sources/FeatherMail/MailProtocol.swift +++ b/Sources/FeatherMail/MailProtocol.swift @@ -2,7 +2,7 @@ // MailProtocol.swift // feather-mail // -// Created by Tibor Bodecs on 2023. 01. 16.. +// Created by Tibor Bödecs on 2023. 01. 16.. // /// A protocol that defines the requirements for a mail delivery service. diff --git a/Sources/FeatherMailTesting/Assets/feather.png b/Tests/FeatherMailTests/Assets/feather.png similarity index 100% rename from Sources/FeatherMailTesting/Assets/feather.png rename to Tests/FeatherMailTests/Assets/feather.png diff --git a/Tests/FeatherMailTests/FeatherMailTests.swift b/Tests/FeatherMailTests/FeatherMailTestSuite.swift similarity index 68% rename from Tests/FeatherMailTests/FeatherMailTests.swift rename to Tests/FeatherMailTests/FeatherMailTestSuite.swift index ef95807..4864eeb 100644 --- a/Tests/FeatherMailTests/FeatherMailTests.swift +++ b/Tests/FeatherMailTests/FeatherMailTestSuite.swift @@ -1,21 +1,21 @@ // -// FeatherMailTests.swift +// FeatherMailTestSuite.swift // feather-mail // -// Created by Tibor Bodecs on 2023. 01. 16.. +// Created by Tibor Bödecs on 2023. 01. 16.. // import Testing -import FeatherMail -@testable import FeatherMailTesting +@testable import FeatherMail -final class FeatherMailTests { +@Suite +class FeatherMailTestSuite { @Test func testNormal() async throws { - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) + let mail = MockMailStruct() + let mailTestSuite = MockMailTestUtil(mail) try await mailTestSuite.testAll(from: "from@from.from", to: "to@to.to") } @@ -23,8 +23,8 @@ final class FeatherMailTests { @Test func testFromError() async throws { - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) + let mail = MockMailStruct() + let mailTestSuite = MockMailTestUtil(mail) do { try await mailTestSuite.testAll( @@ -40,8 +40,8 @@ final class FeatherMailTests { @Test func testToError() async throws { - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) + let mail = MockMailStruct() + let mailTestSuite = MockMailTestUtil(mail) do { try await mailTestSuite.testAll( @@ -57,8 +57,8 @@ final class FeatherMailTests { @Test func testSubjectError() async throws { - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) + let mail = MockMailStruct() + let mailTestSuite = MockMailTestUtil(mail) do { try await mailTestSuite.testAll( diff --git a/Sources/FeatherMailTesting/MailStruct.swift b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift similarity index 91% rename from Sources/FeatherMailTesting/MailStruct.swift rename to Tests/FeatherMailTests/Mocks/MockMailStruct.swift index 5a2f838..3fb52d0 100644 --- a/Sources/FeatherMailTesting/MailStruct.swift +++ b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift @@ -1,5 +1,5 @@ // -// MailStruct.swift +// MockMailStruct.swift // feather-mail // // Created by Binary Birds on 2026. 01. 06.. @@ -8,7 +8,7 @@ import FeatherMail /// A concrete implementation of `MailProtocol` used primarily for testing or as a no-op placeholder. -public struct MailStruct: MailProtocol { +public struct MockMailStruct: MailProtocol { /// Simulates the sending of an email. /// diff --git a/Sources/FeatherMailTesting/MailTestSuite.swift b/Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift similarity index 92% rename from Sources/FeatherMailTesting/MailTestSuite.swift rename to Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift index 6119303..b9320f0 100644 --- a/Sources/FeatherMailTesting/MailTestSuite.swift +++ b/Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift @@ -1,22 +1,22 @@ // -// MailTestSuite.swift +// MockMailTestUtil.swift // feather-mail // -// Created by Tibor Bodecs on 2024. 04. 09.. +// Created by Tibor Bödecs on 2024. 04. 09.. // import Foundation import FeatherMail /// A thread-safe utility designed to run a battery of tests against a `MailProtocol` implementation. -public struct MailTestSuite: Sendable { +public struct MockMailTestUtil: Sendable { /// The mail service instance being tested. - let mail: MailStruct + let mail: MockMailStruct /// Initializes the test suite with a specific mail service. - /// - Parameter mail: The `MailStruct` instance to use for sending emails during tests. - public init(_ mail: MailStruct) { + /// - Parameter mail: The `MockMailStruct` instance to use for sending emails during tests. + public init(_ mail: MockMailStruct) { self.mail = mail } @@ -50,7 +50,7 @@ public struct MailTestSuite: Sendable { } } -public extension MailTestSuite { +public extension MockMailTestUtil { /// Attempts to locate the test attachment file within the module bundle. /// - Returns: A `URL` pointing to the 'feather.png' resource, or nil if not found. diff --git a/Docker/Dockerfile.testing b/docker/tests/Dockerfile similarity index 100% rename from Docker/Dockerfile.testing rename to docker/tests/Dockerfile From 960dffcda45e15cfea8fe7ca2106550f8638b252 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 15 Jan 2026 15:03:21 +0100 Subject: [PATCH 02/26] Update testing.yml --- .github/workflows/testing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0f42d1e..ebfae0b 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -36,4 +36,4 @@ jobs: 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 + 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 From f8788b476059c7c1d5e6033f55cd40ef739cc1e1 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 15 Jan 2026 16:59:19 +0100 Subject: [PATCH 03/26] Update FeatherMailTestSuite.swift --- Tests/FeatherMailTests/FeatherMailTestSuite.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/FeatherMailTests/FeatherMailTestSuite.swift b/Tests/FeatherMailTests/FeatherMailTestSuite.swift index 4864eeb..d48be93 100644 --- a/Tests/FeatherMailTests/FeatherMailTestSuite.swift +++ b/Tests/FeatherMailTests/FeatherMailTestSuite.swift @@ -9,7 +9,7 @@ import Testing @testable import FeatherMail @Suite -class FeatherMailTestSuite { +struct FeatherMailTestSuite { @Test func testNormal() async throws { From a050ffdf6f0344668f8e4048fb6289d89b6b25a4 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 15 Jan 2026 17:08:57 +0100 Subject: [PATCH 04/26] Delete Package@swift-6.1.swift --- Package@swift-6.1.swift | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 Package@swift-6.1.swift diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift deleted file mode 100644 index 950fb12..0000000 --- a/Package@swift-6.1.swift +++ /dev/null @@ -1,40 +0,0 @@ -// swift-tools-version:6.1 -import PackageDescription - -let package = Package( - name: "feather-mail", - - platforms: [ - .macOS(.v13), - .iOS(.v16), - .tvOS(.v16), - .watchOS(.v9), - .visionOS(.v1), - ], - - products: [ - .library(name: "FeatherMail", targets: ["FeatherMail"]), - ], - - dependencies: [ - .package(url: "https://github.com/apple/swift-log", from: "1.6.0") - ], - - targets: [ - .target( - name: "FeatherMail", - dependencies: [ - .product(name: "Logging", package: "swift-log") - ] - ), - .testTarget( - name: "FeatherMailTests", - dependencies: [ - .target(name: "FeatherMail") - ], - resources: [ - .copy("Assets/feather.png") - ] - ), - ] -) From d14956e452ecd8ac771b143789790b7aad75eec5 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 15 Jan 2026 17:33:58 +0100 Subject: [PATCH 05/26] Update Package.swift --- Package.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 44d405d..cda01bb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,7 +1,7 @@ -// swift-tools-version:6.2 +// swift-tools-version:6.1 import PackageDescription -let defaultSwiftSettings: [SwiftSetting] = [ +var defaultSwiftSettings: [SwiftSetting] = [ .swiftLanguageMode(.v6), .enableExperimentalFeature( "AvailabilityMacro=FeatherMailAvailability:macOS 13, iOS 16, watchOS 9, tvOS 16, visionOS 1" @@ -10,6 +10,13 @@ let defaultSwiftSettings: [SwiftSetting] = [ .enableExperimentalFeature("Lifetimes"), ] +#if compiler(>=6.2) +defaultSwiftSettings.append( + // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0461-async-function-isolation.md + .enableUpcomingFeature("NonisolatedNonsendingByDefault") +) +#endif + let package = Package( name: "feather-mail", From 64195e2fd4d1916d6b52e32e9d4f09efca66aafe Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 15 Jan 2026 17:49:54 +0100 Subject: [PATCH 06/26] Update .swiftformatignore --- .swiftformatignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.swiftformatignore b/.swiftformatignore index 9a98663..c73cf0c 100644 --- a/.swiftformatignore +++ b/.swiftformatignore @@ -1,2 +1 @@ -Package.swift -Package@swift-6.1.swift \ No newline at end of file +Package.swift \ No newline at end of file From b9448f7ded806d5e3267d5261633a709d74106af Mon Sep 17 00:00:00 2001 From: GErP83 Date: Fri, 16 Jan 2026 10:42:56 +0100 Subject: [PATCH 07/26] separate validation logic --- Sources/FeatherMail/Mail.swift | 141 ----------------- Sources/FeatherMail/MailError.swift | 23 --- Sources/FeatherMail/MailProtocol.swift | 18 --- Sources/FeatherMail/Models/Address.swift | 52 +++++++ Sources/FeatherMail/Models/Attachment.swift | 37 +++++ Sources/FeatherMail/Models/Body.swift | 16 ++ Sources/FeatherMail/Models/Mail.swift | 74 +++++++++ Sources/FeatherMail/Models/MailError.swift | 43 ++++++ Sources/FeatherMail/Models/MailProtocol.swift | 17 ++ .../Validator/DefaultMailValidator.swift | 101 ++++++++++++ .../Validator/MailValidating.swift | 20 +++ .../FeatherMailTestSuite.swift | 132 +++++++++++----- .../Mocks/MockMailStruct.swift | 26 ++-- .../Mocks/MockMailTestUtil.swift | 145 ------------------ 14 files changed, 470 insertions(+), 375 deletions(-) delete mode 100644 Sources/FeatherMail/Mail.swift delete mode 100644 Sources/FeatherMail/MailError.swift delete mode 100644 Sources/FeatherMail/MailProtocol.swift create mode 100644 Sources/FeatherMail/Models/Address.swift create mode 100644 Sources/FeatherMail/Models/Attachment.swift create mode 100644 Sources/FeatherMail/Models/Body.swift create mode 100644 Sources/FeatherMail/Models/Mail.swift create mode 100644 Sources/FeatherMail/Models/MailError.swift create mode 100644 Sources/FeatherMail/Models/MailProtocol.swift create mode 100644 Sources/FeatherMail/Validator/DefaultMailValidator.swift create mode 100644 Sources/FeatherMail/Validator/MailValidating.swift delete mode 100644 Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift diff --git a/Sources/FeatherMail/Mail.swift b/Sources/FeatherMail/Mail.swift deleted file mode 100644 index 6b3ff65..0000000 --- a/Sources/FeatherMail/Mail.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// Mail.swift -// feather-mail -// -// Created by Tibor Bödecs on 2023. 01. 16.. -// - -import Foundation - -/// A thread-safe representation of an email message. -public struct Mail: Sendable { - - /// Represents an email identity consisting of an email string and an optional display name. - public struct Address: Sendable { - /// The raw email address (e.g., "user@example.com"). - public let email: String - /// An optional display name (e.g., "John Doe"). - public let name: String? - - /// Creates a new email address. - /// - Parameters: - /// - email: The email address string. - /// - name: An optional display name. - public init( - _ email: String, - name: String? = nil - ) { - self.email = email - self.name = name - } - - /// Internal validation helper to ensure the email is not just whitespace. - var isValid: Bool { - !email.trimmingCharacters(in: .whitespaces).isEmpty - } - } - - /// Represents a file attached to the email. - public struct Attachment: Sendable { - /// The filename, including extension (e.g., "invoice.pdf"). - public let name: String - /// The MIME type of the content (e.g., "application/pdf"). - public let contentType: String - /// The raw binary data of the file. - public let data: Data - - /// attachment init - public init( - name: String, - contentType: String, - data: Data - ) { - self.name = name - self.contentType = contentType - self.data = data - } - } - - /// Defines the format of the email content. - public enum Body: Sendable { - /// Standard unformatted text. - case plainText(String) - /// HTML formatted content for rich-text emails. - case html(String) - } - - /// The originating sender address. - public let from: Address - /// The list of primary recipients. - public let to: [Address] - /// The list of carbon copy recipients. - public let cc: [Address] - /// The list of blind carbon copy recipients. - public let bcc: [Address] - /// The list of addresses where replies should be directed. - public let replyTo: [Address] - /// The summary line of the email. - public let subject: String - /// The actual content of the email (Text or HTML). - public let body: Body - /// An optional identifier used for email threading and references. - public let reference: String? - /// A list of files attached to the email. - public let attachments: [Attachment] - - /// Initializes and validates a Mail object. - /// - /// The initializer automatically filters out empty or whitespace-only email addresses - /// from all recipient lists (to, cc, bcc, and replyTo). - /// - /// - Parameters: - /// - from: The sender's address. Must not be empty. - /// - to: An array of primary recipients. - /// - cc: An array of carbon copy recipients. Defaults to empty. - /// - bcc: An array of blind carbon copy recipients. Defaults to empty. - /// - replyTo: An array of addresses for replies. Defaults to empty. - /// - subject: The email subject. Must not be empty to avoid spam filtering. - /// - body: The message body content. - /// - reference: An optional string for message headers (threading). - /// - attachments: An array of file attachments. Defaults to empty. - /// - /// - Throws: `MailError.invalidSender` if sender is empty, - /// `MailError.invalidSubject` if subject is empty, - /// or `MailError.invalidRecipient` if no recipients exist. - public init( - from: Address, - to: [Address], - cc: [Address] = [], - bcc: [Address] = [], - replyTo: [Address] = [], - subject: String, - body: Body, - reference: String? = nil, - attachments: [Attachment] = [] - ) throws(MailError) { - - guard !from.email.trimmingCharacters(in: .whitespaces).isEmpty else { - throw MailError.invalidSender - } - - guard !subject.trimmingCharacters(in: .whitespaces).isEmpty else { - throw MailError.invalidSubject - } - - self.to = to.filter(\.isValid) - self.cc = cc.filter(\.isValid) - self.bcc = bcc.filter(\.isValid) - self.replyTo = replyTo.filter(\.isValid) - - guard !self.to.isEmpty || !self.cc.isEmpty || !self.bcc.isEmpty else { - throw MailError.invalidRecipient - } - - self.from = from - self.subject = subject - self.body = body - self.reference = reference - self.attachments = attachments - } - -} diff --git a/Sources/FeatherMail/MailError.swift b/Sources/FeatherMail/MailError.swift deleted file mode 100644 index 0e7aa06..0000000 --- a/Sources/FeatherMail/MailError.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// MailError.swift -// feather-mail -// -// Created by Tibor Bödecs on 2023. 01. 16.. -// - -/// Errors that can occur when initializing or sending a `Mail` object. -public enum MailError: Error, Equatable { - - /// The 'from' address is missing or contains an invalid email string. - case invalidSender - - /// The subject line is empty or consists only of whitespace. - /// Note: Empty subjects are frequently flagged as high-risk spam by providers like Gmail and Outlook. - case invalidSubject - - /// No valid recipients were found in the 'to', 'cc', or 'bcc' fields. - case invalidRecipient - - /// An underlying error occurred. - case unknown -} diff --git a/Sources/FeatherMail/MailProtocol.swift b/Sources/FeatherMail/MailProtocol.swift deleted file mode 100644 index 31c001c..0000000 --- a/Sources/FeatherMail/MailProtocol.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// MailProtocol.swift -// feather-mail -// -// Created by Tibor Bödecs on 2023. 01. 16.. -// - -/// A protocol that defines the requirements for a mail delivery service. -/// -/// Conforming types are responsible for taking a validated `Mail` object and -/// transmitting it via a specific transport mechanism (e.g., SMTP, SES). -public protocol MailProtocol: Sendable { - - /// Asynchronously sends the specified email. - /// - Parameter email: The `Mail` object to be sent. Must be pre-validated by its initializer. - /// - Throws: `Error` if the delivery fails due to network or provider issues. - func send(_ email: Mail) async throws(MailError) -} diff --git a/Sources/FeatherMail/Models/Address.swift b/Sources/FeatherMail/Models/Address.swift new file mode 100644 index 0000000..a9b51e5 --- /dev/null +++ b/Sources/FeatherMail/Models/Address.swift @@ -0,0 +1,52 @@ +// +// Address.swift +// feather-mail +// +// Created by Tibor Bödecs on 2023. 01. 16.. +// + +/// Represents an email identity consisting of an email string and an optional display name. +public struct Address: Sendable { + + /// The raw email address string (e.g. "user@example.com"). + public let email: String + + /// An optional human-readable display name (e.g. "John Doe"). + public let name: String? + + /// Creates a new email address. + /// - Parameters: + /// - email: The email address string. + /// - name: An optional display name. + public init( + _ email: String, + name: String? = nil + ) { + self.email = email + self.name = name + } + + /// Indicates whether the address contains a non-empty email value. + /// + /// This is a lightweight sanity check and does not perform full + /// RFC-compliant email validation. + var isValid: Bool { + !email.trimmingCharacters(in: .whitespaces).isEmpty + } + + /// Returns a MIME-compatible string representation of the address. + /// + /// If a display name is present, the format will be: + /// `Name ` + /// + /// Otherwise, only the email address is returned. + /// + /// This representation is suitable for use in email headers. + public var mime: String { + if let name { + return "\(name) <\(email)>" + } + return email + } + +} diff --git a/Sources/FeatherMail/Models/Attachment.swift b/Sources/FeatherMail/Models/Attachment.swift new file mode 100644 index 0000000..3828dae --- /dev/null +++ b/Sources/FeatherMail/Models/Attachment.swift @@ -0,0 +1,37 @@ +// +// Attachment.swift +// feather-mail +// +// Created by Tibor Bödecs on 2023. 01. 16.. +// + +import Foundation + +/// Represents a file attachment included with an email message. +public struct Attachment: Sendable { + + /// The file name, including extension (e.g. "invoice.pdf"). + public let name: String + + /// The MIME type of the attachment content (e.g. "application/pdf"). + public let contentType: String + + /// The raw binary data of the attachment. + public let data: Data + + /// Creates a new attachment. + /// + /// - Parameters: + /// - name: The file name. + /// - contentType: The MIME type of the attachment. + /// - data: The binary content of the file. + public init( + name: String, + contentType: String, + data: Data + ) { + self.name = name + self.contentType = contentType + self.data = data + } +} diff --git a/Sources/FeatherMail/Models/Body.swift b/Sources/FeatherMail/Models/Body.swift new file mode 100644 index 0000000..8b446ce --- /dev/null +++ b/Sources/FeatherMail/Models/Body.swift @@ -0,0 +1,16 @@ +// +// Body.swift +// feather-mail +// +// Created by Tibor Bödecs on 2023. 01. 16.. +// + +/// Represents the body content of an email message. +public enum Body: Sendable { + + /// Plain, unformatted text content. + case plainText(String) + + /// HTML-formatted content for rich-text emails. + case html(String) +} diff --git a/Sources/FeatherMail/Models/Mail.swift b/Sources/FeatherMail/Models/Mail.swift new file mode 100644 index 0000000..2982a66 --- /dev/null +++ b/Sources/FeatherMail/Models/Mail.swift @@ -0,0 +1,74 @@ +// +// Mail.swift +// feather-mail +// +// Created by Tibor Bödecs on 2023. 01. 16.. +// + +/// A thread-safe representation of an email message. +public struct Mail: Sendable { + + /// The originating sender address. + public let from: Address + + /// The list of primary recipients. + public let to: [Address] + + /// The list of carbon copy recipients. + public let cc: [Address] + + /// The list of blind carbon copy recipients. + public let bcc: [Address] + + /// The list of addresses where replies should be directed. + public let replyTo: [Address] + + /// The subject line of the email. + public let subject: String + + /// The message body content. + public let body: Body + + /// An optional reference identifier used for threading. + public let reference: String? + + /// Files attached to the email. + public let attachments: [Attachment] + + /// Initializes a Mail object. + /// + /// - Parameters: + /// - from: The sender's address. Must not be empty. + /// - to: An array of primary recipients. + /// - cc: An array of carbon copy recipients. Defaults to empty. + /// - bcc: An array of blind carbon copy recipients. Defaults to empty. + /// - replyTo: An array of addresses for replies. Defaults to empty. + /// - subject: The email subject. Must not be empty to avoid spam filtering. + /// - body: The message body content. + /// - reference: An optional string for message headers (threading). + /// - attachments: An array of file attachments. Defaults to empty. + /// + public init( + from: Address, + to: [Address], + cc: [Address] = [], + bcc: [Address] = [], + replyTo: [Address] = [], + subject: String, + body: Body, + reference: String? = nil, + attachments: [Attachment] = [] + ) { + + self.from = from + self.to = to + self.cc = cc + self.bcc = bcc + self.replyTo = replyTo + self.subject = subject + self.body = body + self.reference = reference + self.attachments = attachments + } + +} diff --git a/Sources/FeatherMail/Models/MailError.swift b/Sources/FeatherMail/Models/MailError.swift new file mode 100644 index 0000000..79e564c --- /dev/null +++ b/Sources/FeatherMail/Models/MailError.swift @@ -0,0 +1,43 @@ +// +// MailError.swift +// feather-mail +// +// Created by Tibor Bödecs on 2023. 01. 16.. +// + +/// Errors that can occur during mail validation or delivery. +public enum MailError: Error, Equatable { + + /// The sender address is missing or invalid. + case invalidSender + + /// The subject line is empty or invalid. + case invalidSubject + + /// No valid recipients were found. + case invalidRecipient + + /// The total size of attachments exceeds the allowed limit. + case attachmentsTooLarge + + /// A potential email header injection was detected. + case headerInjectionDetected + + // MARK: - Transport / delivery errors + + /// The mail transport rejected the message. + case transportRejected + + /// The sender was rejected by the mail provider. + case senderRejected + + /// Authentication with the mail server failed. + case authenticationFailed + + /// The mail provider rate-limited the request. + case rateLimited + + /// An unknown or unexpected error occurred. + case unknown + +} diff --git a/Sources/FeatherMail/Models/MailProtocol.swift b/Sources/FeatherMail/Models/MailProtocol.swift new file mode 100644 index 0000000..c34f70e --- /dev/null +++ b/Sources/FeatherMail/Models/MailProtocol.swift @@ -0,0 +1,17 @@ +// +// MailProtocol.swift +// feather-mail +// +// Created by Tibor Bödecs on 2023. 01. 16.. +// + + +/// A protocol defining a mail delivery mechanism. +public protocol MailProtocol: Sendable { + + /// Sends the specified mail asynchronously. + /// + /// - Parameter email: A `Mail` instance to be delivered. + /// - Throws: `MailError` if delivery fails. + func send(_ email: Mail) async throws(MailError) +} diff --git a/Sources/FeatherMail/Validator/DefaultMailValidator.swift b/Sources/FeatherMail/Validator/DefaultMailValidator.swift new file mode 100644 index 0000000..eb4841b --- /dev/null +++ b/Sources/FeatherMail/Validator/DefaultMailValidator.swift @@ -0,0 +1,101 @@ +// +// DefaultMailValidator.swift +// feather-mail +// +// Created by gerp83 on 2026. 01. 16. +// + +/// Default, transport-agnostic validator for `Mail`. +/// +/// This validator performs lightweight sanity checks that are cheap, +/// deterministic, and safe to run before attempting delivery. +/// +/// Validation rules: +/// - sender address must not be empty +/// - subject must not be empty +/// - at least one valid recipient must exist +/// - basic header injection prevention +/// - optional total attachment size limit +public struct DefaultMailValidator: MailValidating, Sendable { + + /// Maximum allowed total attachment size in bytes. + /// If `nil`, attachment size validation is skipped. + private let maxTotalAttachmentSize: Int? + + /// Creates a new default mail validator. + /// + /// - Parameter maxTotalAttachmentSize: Optional maximum total size of all + /// attachments combined, in bytes. + public init(maxTotalAttachmentSize: Int? = nil) { + self.maxTotalAttachmentSize = maxTotalAttachmentSize + } + + /// Validates the given mail instance. + /// + /// - Parameter mail: The `Mail` object to validate. + /// - Throws: `MailError` if validation fails. + public func validate(_ mail: Mail) throws(MailError) { + + try validateSender(mail) + try validateSubject(mail) + try validateRecipients(mail) + try validateAttachments(mail) + try validateSecurity(mail) + } +} + +// MARK: - Validation Rules + +private extension DefaultMailValidator { + + /// Validates the sender address. + func validateSender(_ mail: Mail) throws(MailError) { + guard !mail.from.email.trimmingCharacters(in: .whitespaces).isEmpty + else { + throw MailError.invalidSender + } + } + + /// Validates the subject line. + func validateSubject(_ mail: Mail) throws(MailError) { + guard !mail.subject.trimmingCharacters(in: .whitespaces).isEmpty else { + throw MailError.invalidSubject + } + } + + /// Ensures at least one valid recipient exists. + func validateRecipients(_ mail: Mail) throws(MailError) { + let recipients = mail.to + mail.cc + mail.bcc + guard recipients.contains(where: \.isValid) else { + throw MailError.invalidRecipient + } + } + + /// Validates total attachment size if a limit is configured. + func validateAttachments(_ mail: Mail) throws(MailError) { + guard let maxSize = maxTotalAttachmentSize else { + return + } + + let totalSize = mail.attachments.reduce(0) { $0 + $1.data.count } + guard totalSize <= maxSize else { + throw MailError.attachmentsTooLarge + } + } + + /// Prevents basic email header injection attacks. + func validateSecurity(_ mail: Mail) throws(MailError) { + let valuesToCheck = [ + mail.subject, + mail.from.email, + ] + + guard + valuesToCheck.allSatisfy({ + !$0.contains("\n") && !$0.contains("\r") + }) + else { + throw MailError.headerInjectionDetected + } + } +} diff --git a/Sources/FeatherMail/Validator/MailValidating.swift b/Sources/FeatherMail/Validator/MailValidating.swift new file mode 100644 index 0000000..893b941 --- /dev/null +++ b/Sources/FeatherMail/Validator/MailValidating.swift @@ -0,0 +1,20 @@ +// +// MailValidating.swift +// feather-mail +// +// Created by gerp83 on 2026. 01. 16. +// + +/// A protocol defining validation rules for `Mail` objects. +/// +/// Validators are responsible for ensuring that a mail instance +/// satisfies required invariants before delivery. This separation +/// allows different validation policies for different transports. +public protocol MailValidating: Sendable { + + /// Validates the given mail instance. + /// + /// - Parameter mail: The `Mail` object to validate. + /// - Throws: `MailError` if validation fails. + func validate(_ mail: Mail) throws(MailError) +} diff --git a/Tests/FeatherMailTests/FeatherMailTestSuite.swift b/Tests/FeatherMailTests/FeatherMailTestSuite.swift index d48be93..eea31cc 100644 --- a/Tests/FeatherMailTests/FeatherMailTestSuite.swift +++ b/Tests/FeatherMailTests/FeatherMailTestSuite.swift @@ -5,70 +5,128 @@ // Created by Tibor Bödecs on 2023. 01. 16.. // +import Foundation import Testing @testable import FeatherMail @Suite struct FeatherMailTestSuite { + // MARK: - Invalid sender + @Test - func testNormal() async throws { + func invalidSenderThrows() async { + let driver = MockMailStruct() - let mail = MockMailStruct() - let mailTestSuite = MockMailTestUtil(mail) + let mail = Mail( + from: .init(" "), + to: [.init("to@example.com")], + subject: "Hello", + body: .plainText("Body") + ) - try await mailTestSuite.testAll(from: "from@from.from", to: "to@to.to") + await #expect(throws: MailError.invalidSender) { + try await driver.send(mail) + } } + // MARK: - Invalid subject + @Test - func testFromError() async throws { + func invalidSubjectThrows() async { + let driver = MockMailStruct() - let mail = MockMailStruct() - let mailTestSuite = MockMailTestUtil(mail) + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: " ", + body: .plainText("Body") + ) - do { - try await mailTestSuite.testAll( - from: "", - to: "to@to.com" - ) - } - catch let error { - #expect(error == .invalidSender) + await #expect(throws: MailError.invalidSubject) { + try await driver.send(mail) } } + // MARK: - Invalid recipient + @Test - func testToError() async throws { + func invalidRecipientThrows() async { + let driver = MockMailStruct() - let mail = MockMailStruct() - let mailTestSuite = MockMailTestUtil(mail) + let mail = Mail( + from: .init("from@example.com"), + to: [.init(" ")], + subject: "Hello", + body: .plainText("Body") + ) - do { - try await mailTestSuite.testAll( - from: "from@from.from", - to: "" - ) - } - catch let error { - #expect(error == .invalidRecipient) + await #expect(throws: MailError.invalidRecipient) { + try await driver.send(mail) } } + // MARK: - Attachments too large + @Test - func testSubjectError() async throws { + func attachmentsTooLargeThrows() async { + let validator = DefaultMailValidator( + maxTotalAttachmentSize: 100 + ) + let driver = MockMailStruct(validator: validator) + + let data = Data(repeating: 0, count: 1_024) - let mail = MockMailStruct() - let mailTestSuite = MockMailTestUtil(mail) + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: "Hello", + body: .plainText("Body"), + attachments: [ + .init( + name: "feather.png", + contentType: "image/png", + data: data + ) + ] + ) - do { - try await mailTestSuite.testAll( - from: "from@from.from", - to: "to@to.com", - subject: "" - ) + await #expect(throws: MailError.attachmentsTooLarge) { + try await driver.send(mail) } - catch let error { - #expect(error == .invalidSubject) + } + + // MARK: - Header injection + + @Test + func headerInjectionThrows() async { + let driver = MockMailStruct() + + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: "Hello\nInjected", + body: .plainText("Body") + ) + + await #expect(throws: MailError.headerInjectionDetected) { + try await driver.send(mail) } } + + // MARK: - Valid mail + + @Test + func validMailSucceeds() async throws { + let driver = MockMailStruct() + + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: "Hello", + body: .plainText("Body") + ) + + try await driver.send(mail) + } } diff --git a/Tests/FeatherMailTests/Mocks/MockMailStruct.swift b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift index 3fb52d0..3c8f9c3 100644 --- a/Tests/FeatherMailTests/Mocks/MockMailStruct.swift +++ b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift @@ -7,18 +7,22 @@ import FeatherMail -/// A concrete implementation of `MailProtocol` used primarily for testing or as a no-op placeholder. -public struct MockMailStruct: MailProtocol { +/// A mock mail driver used for validation testing. +/// +/// This driver does not deliver mail, it only validates it using +/// `DefaultMailValidator`. +public struct MockMailStruct: MailProtocol, Sendable { - /// Simulates the sending of an email. - /// - /// In this implementation, the function performs no action, serving as a "mock" - /// that fulfills the protocol requirements without requiring a real mail server. - /// - /// - Parameter email: The validated `Mail` object to be "sent". - /// - Throws: This implementation does not currently throw, but conforms to the async throws requirement of `MailProtocol`. - public func send(_ email: Mail) async throws(MailError) { - // do nothing + private let validator: MailValidating + + public init( + validator: MailValidating = DefaultMailValidator() + ) { + self.validator = validator } + /// Validates the mail and performs no delivery. + public func send(_ email: Mail) async throws(MailError) { + try validator.validate(email) + } } diff --git a/Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift b/Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift deleted file mode 100644 index b9320f0..0000000 --- a/Tests/FeatherMailTests/Mocks/MockMailTestUtil.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// MockMailTestUtil.swift -// feather-mail -// -// Created by Tibor Bödecs on 2024. 04. 09.. -// - -import Foundation -import FeatherMail - -/// A thread-safe utility designed to run a battery of tests against a `MailProtocol` implementation. -public struct MockMailTestUtil: Sendable { - - /// The mail service instance being tested. - let mail: MockMailStruct - - /// Initializes the test suite with a specific mail service. - /// - Parameter mail: The `MockMailStruct` instance to use for sending emails during tests. - public init(_ mail: MockMailStruct) { - self.mail = mail - } - - /// Executes all available mail tests (Plain Text, HTML, and Attachments) concurrently. - /// - Parameters: - /// - from: The email address to be used as the sender. - /// - to: The email address to be used as the recipient. - /// - subject: The email subject. - /// - Throws: A `MailTestSuiteError` if any of the sub-tests fail. - public func testAll( - from: String, - to: String, - subject: String = "Test subject" - ) async throws(MailError) { - // Executes tests concurrently using async let - async let tests: [Void] = [ - testPlainText(from: from, to: to, subject: subject), - testHTML(from: from, to: to, subject: subject), - testAttachment(from: from, to: to, subject: subject), - ] - - do { - _ = try await tests - } - catch let error as MailError { - throw error - } - catch { - throw MailError.unknown - } - } -} - -public extension MockMailTestUtil { - - /// Attempts to locate the test attachment file within the module bundle. - /// - Returns: A `URL` pointing to the 'feather.png' resource, or nil if not found. - func getAttachmentUrl() -> URL? { - Bundle.module.url(forResource: "feather", withExtension: "png") - } - - // MARK: - Test Cases - - /// Validates the creation and sending of a basic plain-text email. - /// - Parameters: - /// - from: Sender email address. - /// - to: Recipient email address. - /// - subject: The email subject. - /// - /// - Throws: An error if the mail cannot be sent. - func testPlainText( - from: String, - to: String, - subject: String - ) async throws(MailError) { - let email = try Mail( - from: .init(from), - to: [.init(to)], - subject: subject, - body: .plainText("This is a plain text email.") - ) - try await mail.send(email) - } - - /// Validates the creation and sending of an HTML-formatted email. - /// - Parameters: - /// - from: Sender email address. - /// - to: Recipient email address. - /// - `MailError` if the email object is invalid. - /// - `Error` if the mail service fails to send the message. - /// - subject: The email subject. - /// - Throws: An error if the mail cannot be sent. - func testHTML( - from: String, - to: String, - subject: String - ) async throws(MailError) { - let email = try Mail( - from: .init(from), - to: [.init(to)], - subject: subject, - body: .html("This is a HTML email.") - ) - try await mail.send(email) - } - - /// Validates the creation and sending of an email containing a file attachment. - /// - /// This test requires the 'feather.png' resource to be present in the bundle. - /// If the file is missing, the test will print a warning and skip the case. - /// - Parameters: - /// - from: Sender email address. - /// - to: Recipient email address. - /// - subject: The email subject. - /// - Throws: An error if the mail cannot be sent. - func testAttachment( - from: String, - to: String, - subject: String - ) async throws(MailError) { - - guard - let url = getAttachmentUrl(), - let data = try? Data(contentsOf: url) - else { - print("Attachment not found, skipping test case...") - return - } - - let email = try Mail( - from: .init(from), - to: [.init(to)], - subject: subject, - body: .plainText("This is a test email with an attachment."), - attachments: [ - .init( - name: "feather.png", - contentType: "image/png", - data: data - ) - ] - ) - try await mail.send(email) - } - -} From 3b4cc02cc70cc2b094da62ade241f5d87f0dd0b4 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Fri, 16 Jan 2026 10:44:58 +0100 Subject: [PATCH 08/26] format --- Sources/FeatherMail/Models/Address.swift | 6 +++--- Sources/FeatherMail/Models/MailProtocol.swift | 1 - Tests/FeatherMailTests/Mocks/MockMailStruct.swift | 2 ++ 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/FeatherMail/Models/Address.swift b/Sources/FeatherMail/Models/Address.swift index a9b51e5..df7bf88 100644 --- a/Sources/FeatherMail/Models/Address.swift +++ b/Sources/FeatherMail/Models/Address.swift @@ -7,7 +7,7 @@ /// Represents an email identity consisting of an email string and an optional display name. public struct Address: Sendable { - + /// The raw email address string (e.g. "user@example.com"). public let email: String @@ -25,7 +25,7 @@ public struct Address: Sendable { self.email = email self.name = name } - + /// Indicates whether the address contains a non-empty email value. /// /// This is a lightweight sanity check and does not perform full @@ -48,5 +48,5 @@ public struct Address: Sendable { } return email } - + } diff --git a/Sources/FeatherMail/Models/MailProtocol.swift b/Sources/FeatherMail/Models/MailProtocol.swift index c34f70e..c2889df 100644 --- a/Sources/FeatherMail/Models/MailProtocol.swift +++ b/Sources/FeatherMail/Models/MailProtocol.swift @@ -5,7 +5,6 @@ // Created by Tibor Bödecs on 2023. 01. 16.. // - /// A protocol defining a mail delivery mechanism. public protocol MailProtocol: Sendable { diff --git a/Tests/FeatherMailTests/Mocks/MockMailStruct.swift b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift index 3c8f9c3..7a55d86 100644 --- a/Tests/FeatherMailTests/Mocks/MockMailStruct.swift +++ b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift @@ -13,8 +13,10 @@ import FeatherMail /// `DefaultMailValidator`. public struct MockMailStruct: MailProtocol, Sendable { + /// the validator private let validator: MailValidating + /// Initializes a MockMailStruct object. public init( validator: MailValidating = DefaultMailValidator() ) { From 6cf39b37e9f69aa1d6b47878684d931b62acccb9 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Fri, 16 Jan 2026 10:45:45 +0100 Subject: [PATCH 09/26] fix headers --- Package.resolved | 2 +- Sources/FeatherMail/Validator/DefaultMailValidator.swift | 2 +- Sources/FeatherMail/Validator/MailValidating.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index a9b57c8..fe1a2c9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7fabbf17cf01708cb2347144738c1300dc8d5fb882d90337986e603b666ab0b3", + "originHash" : "d97bdac7cd6ab775acd9d527ad9de9adfe931530109593fe2a24453f88a5db8c", "pins" : [ { "identity" : "swift-log", diff --git a/Sources/FeatherMail/Validator/DefaultMailValidator.swift b/Sources/FeatherMail/Validator/DefaultMailValidator.swift index eb4841b..74dd80d 100644 --- a/Sources/FeatherMail/Validator/DefaultMailValidator.swift +++ b/Sources/FeatherMail/Validator/DefaultMailValidator.swift @@ -2,7 +2,7 @@ // DefaultMailValidator.swift // feather-mail // -// Created by gerp83 on 2026. 01. 16. +// Created by gerp83 on 2026. 01. 16.. // /// Default, transport-agnostic validator for `Mail`. diff --git a/Sources/FeatherMail/Validator/MailValidating.swift b/Sources/FeatherMail/Validator/MailValidating.swift index 893b941..c8255e8 100644 --- a/Sources/FeatherMail/Validator/MailValidating.swift +++ b/Sources/FeatherMail/Validator/MailValidating.swift @@ -2,7 +2,7 @@ // MailValidating.swift // feather-mail // -// Created by gerp83 on 2026. 01. 16. +// Created by gerp83 on 2026. 01. 16.. // /// A protocol defining validation rules for `Mail` objects. From b991efddf828e66cdb3cce878cfc8e8d7a0fde68 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 19 Jan 2026 14:05:04 +0100 Subject: [PATCH 10/26] Update MailError.swift --- Sources/FeatherMail/Models/MailError.swift | 96 ++++++++++++++++++---- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/Sources/FeatherMail/Models/MailError.swift b/Sources/FeatherMail/Models/MailError.swift index 79e564c..1080da4 100644 --- a/Sources/FeatherMail/Models/MailError.swift +++ b/Sources/FeatherMail/Models/MailError.swift @@ -8,36 +8,102 @@ /// Errors that can occur during mail validation or delivery. public enum MailError: Error, Equatable { + // MARK: - Validation errors (pre-send) + /// The sender address is missing or invalid. case invalidSender - /// The subject line is empty or invalid. + /// The subject is empty or invalid. case invalidSubject - /// No valid recipients were found. + /// No valid recipients were provided. case invalidRecipient - /// The total size of attachments exceeds the allowed limit. + /// One or more mail headers contain invalid or unsafe values. + /// + /// For example: + /// - CRLF injection attempts + /// - Newlines in subject or address fields + case headerInjectionDetected + + // MARK: - Attachment errors + + /// One or more attachments exceed the allowed size. + /// + /// This may be detected: + /// - During validation + /// - By the provider (SES / SMTP) case attachmentsTooLarge - /// A potential email header injection was detected. - case headerInjectionDetected + // MARK: - Encoding errors + + /// Failed to encode the mail into the format required by the transport. + /// + /// This can occur when generating raw MIME data for providers like SES. + case mailEncodeError + + + // MARK: - Provider / delivery errors + + /// The mail was rejected by the provider. + /// + /// Common causes: + /// - SES sandbox restrictions + /// - Unverified identities + /// - Provider policy violations + case messageRejected + + /// The sender identity or domain is not verified. + case identityNotVerified + + /// The sending quota has been exceeded. + /// + /// For example: + /// - SES daily send limit reached + case quotaExceeded + + /// The sending rate has been exceeded. + /// + /// For example: + /// - SES rate limit + /// - SMTP throttling + case sendRateExceeded + + /// The request payload was too large. + /// + /// For example: + /// - Attachments exceed provider limits + case payloadTooLarge + + /// Access to the mail provider was denied. + /// + /// For example: + /// - Invalid credentials + /// - Missing IAM permissions + case accessDenied + + /// The mail service is temporarily unavailable. + /// + /// For example: + /// - SES sending paused + /// - Provider outage + case serviceUnavailable - // MARK: - Transport / delivery errors - /// The mail transport rejected the message. - case transportRejected + // MARK: - Transport / infrastructure errors - /// The sender was rejected by the mail provider. - case senderRejected + /// A network or transport-level failure occurred. + /// + /// For example: + /// - Connection timeout + /// - DNS resolution failure + /// - SMTP connection drop + case transportError - /// Authentication with the mail server failed. - case authenticationFailed - /// The mail provider rate-limited the request. - case rateLimited + // MARK: - Fallback - /// An unknown or unexpected error occurred. + /// An unknown error occurred. case unknown } From e2f3aaa05cf3be80e8975892f9aaf1cf7d43959d Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 15:49:11 +0100 Subject: [PATCH 11/26] logic changes --- Package.swift | 16 ++- README.md | 2 +- Sources/FeatherMail/Models/Address.swift | 25 ++-- Sources/FeatherMail/Models/Attachment.swift | 20 ++-- Sources/FeatherMail/Models/Body.swift | 8 +- .../FeatherMail/Models/Error/MailError.swift | 36 ++++++ .../Models/Error/MailValidationError.swift | 46 ++++++++ Sources/FeatherMail/Models/Mail.swift | 42 ++++--- Sources/FeatherMail/Models/MailClient.swift | 22 ++++ Sources/FeatherMail/Models/MailError.swift | 109 ------------------ Sources/FeatherMail/Models/MailProtocol.swift | 16 --- Sources/FeatherMail/Utils/Base64.swift | 48 ++++++++ ...lidator.swift => BasicMailValidator.swift} | 61 +++++----- .../Validator/MailValidating.swift | 20 ---- .../FeatherMail/Validator/MailValidator.swift | 19 +++ Tests/FeatherMailTests/Base64Tests.swift | 31 +++++ .../FeatherMailTestSuite.swift | 93 ++++++++++----- .../Mocks/MockMailClient.swift | 34 ++++++ .../Mocks/MockMailStruct.swift | 30 ----- docker/tests/Dockerfile | 2 +- 20 files changed, 391 insertions(+), 289 deletions(-) create mode 100644 Sources/FeatherMail/Models/Error/MailError.swift create mode 100644 Sources/FeatherMail/Models/Error/MailValidationError.swift create mode 100644 Sources/FeatherMail/Models/MailClient.swift delete mode 100644 Sources/FeatherMail/Models/MailError.swift delete mode 100644 Sources/FeatherMail/Models/MailProtocol.swift create mode 100644 Sources/FeatherMail/Utils/Base64.swift rename Sources/FeatherMail/Validator/{DefaultMailValidator.swift => BasicMailValidator.swift} (51%) delete mode 100644 Sources/FeatherMail/Validator/MailValidating.swift create mode 100644 Sources/FeatherMail/Validator/MailValidator.swift create mode 100644 Tests/FeatherMailTests/Base64Tests.swift create mode 100644 Tests/FeatherMailTests/Mocks/MockMailClient.swift delete mode 100644 Tests/FeatherMailTests/Mocks/MockMailStruct.swift diff --git a/Package.swift b/Package.swift index cda01bb..0ae6717 100644 --- a/Package.swift +++ b/Package.swift @@ -1,13 +1,17 @@ // swift-tools-version:6.1 import PackageDescription +// NOTE: https://github.com/swift-server/swift-http-server/blob/main/Package.swift var defaultSwiftSettings: [SwiftSetting] = [ + + // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0441-formalize-language-mode-terminology.md .swiftLanguageMode(.v6), - .enableExperimentalFeature( - "AvailabilityMacro=FeatherMailAvailability:macOS 13, iOS 16, watchOS 9, tvOS 16, visionOS 1" - ), + // https://github.com/swiftlang/swift-evolution/blob/main/proposals/0444-member-import-visibility.md .enableUpcomingFeature("MemberImportVisibility"), + // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 .enableExperimentalFeature("Lifetimes"), + // https://github.com/swiftlang/swift/pull/65218 + .enableExperimentalFeature("AvailabilityMacro=FeatherMailAvailability:macOS 13, iOS 16, watchOS 9, tvOS 16, visionOS 1"), ] #if compiler(>=6.2) @@ -19,13 +23,14 @@ defaultSwiftSettings.append( let package = Package( name: "feather-mail", - products: [ .library(name: "FeatherMail", targets: ["FeatherMail"]), ], dependencies: [ + // [docc-plugin-placeholder] .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], + targets: [ .target( name: "FeatherMail", @@ -42,5 +47,6 @@ let package = Package( .copy("Assets/feather.png") ] ), - ] + ], + ) diff --git a/README.md b/README.md index 5767008..8adf8d9 100644 --- a/README.md +++ b/README.md @@ -46,4 +46,4 @@ let package = Package( ### Documentation -For more information, see the official [API documentation](https://feather-framework.github.io/feather-mail/documentation/) for this package. +For more information, see the official [API documentation](https://feather-framework.github.io/feather-mail/) for this package. diff --git a/Sources/FeatherMail/Models/Address.swift b/Sources/FeatherMail/Models/Address.swift index df7bf88..fa37486 100644 --- a/Sources/FeatherMail/Models/Address.swift +++ b/Sources/FeatherMail/Models/Address.swift @@ -2,22 +2,22 @@ // Address.swift // feather-mail // -// Created by Tibor Bödecs on 2023. 01. 16.. +// Created by Binary Birds on 2026. 01. 19.. // -/// Represents an email identity consisting of an email string and an optional display name. +/// Email address with an optional display name. public struct Address: Sendable { - /// The raw email address string (e.g. "user@example.com"). + /// Raw email address string (e.g. "user@example.com"). public let email: String - /// An optional human-readable display name (e.g. "John Doe"). + /// Optional display name (e.g. "John Doe"). public let name: String? - /// Creates a new email address. + /// Creates an email address. /// - Parameters: /// - email: The email address string. - /// - name: An optional display name. + /// - name: Optional display name. public init( _ email: String, name: String? = nil @@ -26,22 +26,19 @@ public struct Address: Sendable { self.name = name } - /// Indicates whether the address contains a non-empty email value. + /// Indicates whether the email value is non-empty. /// - /// This is a lightweight sanity check and does not perform full - /// RFC-compliant email validation. + /// This is a lightweight sanity check and does not attempt RFC validation. var isValid: Bool { - !email.trimmingCharacters(in: .whitespaces).isEmpty + email.contains(where: { !$0.isWhitespace }) } - /// Returns a MIME-compatible string representation of the address. + /// Returns a header-ready representation of the address. /// - /// If a display name is present, the format will be: + /// If a display name is present, the format is: /// `Name ` /// /// Otherwise, only the email address is returned. - /// - /// This representation is suitable for use in email headers. public var mime: String { if let name { return "\(name) <\(email)>" diff --git a/Sources/FeatherMail/Models/Attachment.swift b/Sources/FeatherMail/Models/Attachment.swift index 3828dae..770f648 100644 --- a/Sources/FeatherMail/Models/Attachment.swift +++ b/Sources/FeatherMail/Models/Attachment.swift @@ -2,33 +2,31 @@ // Attachment.swift // feather-mail // -// Created by Tibor Bödecs on 2023. 01. 16.. +// Created by Binary Birds on 2026. 01. 19.. // -import Foundation - -/// Represents a file attachment included with an email message. +/// File attachment included with an email message. public struct Attachment: Sendable { - /// The file name, including extension (e.g. "invoice.pdf"). + /// File name including extension (e.g. "invoice.pdf"). public let name: String - /// The MIME type of the attachment content (e.g. "application/pdf"). + /// MIME type of the attachment content (e.g. "application/pdf"). public let contentType: String - /// The raw binary data of the attachment. - public let data: Data + /// Raw attachment bytes. + public let data: [UInt8] - /// Creates a new attachment. + /// Creates an attachment. /// /// - Parameters: /// - name: The file name. /// - contentType: The MIME type of the attachment. - /// - data: The binary content of the file. + /// - data: The binary contents of the file. public init( name: String, contentType: String, - data: Data + data: [UInt8] ) { self.name = name self.contentType = contentType diff --git a/Sources/FeatherMail/Models/Body.swift b/Sources/FeatherMail/Models/Body.swift index 8b446ce..00a4640 100644 --- a/Sources/FeatherMail/Models/Body.swift +++ b/Sources/FeatherMail/Models/Body.swift @@ -2,15 +2,15 @@ // Body.swift // feather-mail // -// Created by Tibor Bödecs on 2023. 01. 16.. +// Created by Binary Birds on 2026. 01. 19.. // -/// Represents the body content of an email message. +/// Email body content variants. public enum Body: Sendable { - /// Plain, unformatted text content. + /// Plain text content. case plainText(String) - /// HTML-formatted content for rich-text emails. + /// HTML content for rich text messages. case html(String) } diff --git a/Sources/FeatherMail/Models/Error/MailError.swift b/Sources/FeatherMail/Models/Error/MailError.swift new file mode 100644 index 0000000..406d124 --- /dev/null +++ b/Sources/FeatherMail/Models/Error/MailError.swift @@ -0,0 +1,36 @@ +// +// MailError.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Errors that can occur during validation or delivery. +public enum MailError: Error, Equatable { + + /// Validation failed before delivery. + case validation(MailValidationError) + + /// A caller-provided error message. + case custom(String) + + /// An uncategorized underlying error. + case unknown(Error) + + + /// Compares errors by their meaningful payloads. + public static func == (lhs: MailError, rhs: MailError) -> Bool { + switch (lhs, rhs) { + case let (.validation(left), .validation(right)): + return left == right + case let (.custom(left), .custom(right)): + return left == right + case let (.unknown(left), .unknown(right)): + return String(reflecting: type(of: left)) == String(reflecting: type(of: right)) + && String(describing: left) == String(describing: right) + default: + return false + } + } + +} diff --git a/Sources/FeatherMail/Models/Error/MailValidationError.swift b/Sources/FeatherMail/Models/Error/MailValidationError.swift new file mode 100644 index 0000000..01c47dc --- /dev/null +++ b/Sources/FeatherMail/Models/Error/MailValidationError.swift @@ -0,0 +1,46 @@ +// +// MailValidationError.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Errors that can occur during mail validation. +public enum MailValidationError: Error, Equatable { + + /// The sender address is missing or invalid. + case invalidSender + + /// The subject line is empty or invalid. + case invalidSubject + + /// No valid recipients were provided. + case invalidRecipient + + /// One or more headers contain invalid or unsafe values. + /// + /// Examples include: + /// - CRLF injection attempts + /// - Newlines in subject or address fields + case headerInjectionDetected + + // MARK: - Attachment errors + + /// One or more attachments exceed the allowed size. + /// + /// This may be detected: + /// - During validation + /// - By the provider (SES / SMTP) + case attachmentsTooLarge + + // MARK: - Encoding errors + + /// Failed to encode the mail into the required transport format. + /// + /// This can occur when generating raw MIME data for providers like SES. + case mailEncodeError + + /// An unknown validation error occurred. + case unknown + +} diff --git a/Sources/FeatherMail/Models/Mail.swift b/Sources/FeatherMail/Models/Mail.swift index 2982a66..5d9f71a 100644 --- a/Sources/FeatherMail/Models/Mail.swift +++ b/Sources/FeatherMail/Models/Mail.swift @@ -2,52 +2,51 @@ // Mail.swift // feather-mail // -// Created by Tibor Bödecs on 2023. 01. 16.. +// Created by Binary Birds on 2026. 01. 19.. // -/// A thread-safe representation of an email message. +/// Immutable email message payload. public struct Mail: Sendable { - /// The originating sender address. + /// Sender address. public let from: Address - /// The list of primary recipients. + /// Primary recipients. public let to: [Address] - /// The list of carbon copy recipients. + /// Carbon copy recipients. public let cc: [Address] - /// The list of blind carbon copy recipients. + /// Blind carbon copy recipients. public let bcc: [Address] - /// The list of addresses where replies should be directed. + /// Reply-to addresses. public let replyTo: [Address] - /// The subject line of the email. + /// Subject line. public let subject: String - /// The message body content. + /// Message body content. public let body: Body - /// An optional reference identifier used for threading. + /// Optional reference identifier for threading. public let reference: String? - /// Files attached to the email. + /// File attachments. public let attachments: [Attachment] - /// Initializes a Mail object. + /// Creates a mail message. /// /// - Parameters: - /// - from: The sender's address. Must not be empty. + /// - from: The sender's address. /// - to: An array of primary recipients. - /// - cc: An array of carbon copy recipients. Defaults to empty. - /// - bcc: An array of blind carbon copy recipients. Defaults to empty. - /// - replyTo: An array of addresses for replies. Defaults to empty. - /// - subject: The email subject. Must not be empty to avoid spam filtering. - /// - body: The message body content. - /// - reference: An optional string for message headers (threading). - /// - attachments: An array of file attachments. Defaults to empty. - /// + /// - cc: Carbon copy recipients. Defaults to empty. + /// - bcc: Blind carbon copy recipients. Defaults to empty. + /// - replyTo: Reply-to addresses. Defaults to empty. + /// - subject: Subject line. + /// - body: Message body content. + /// - reference: Optional threading reference string. + /// - attachments: File attachments. Defaults to empty. public init( from: Address, to: [Address], @@ -59,7 +58,6 @@ public struct Mail: Sendable { reference: String? = nil, attachments: [Attachment] = [] ) { - self.from = from self.to = to self.cc = cc diff --git a/Sources/FeatherMail/Models/MailClient.swift b/Sources/FeatherMail/Models/MailClient.swift new file mode 100644 index 0000000..f726479 --- /dev/null +++ b/Sources/FeatherMail/Models/MailClient.swift @@ -0,0 +1,22 @@ +// +// MailClient.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Mail delivery interface. +public protocol MailClient: Sendable { + + /// Sends a mail message using the underlying transport. + /// + /// - Parameter mail: The `Mail` instance to deliver. + /// - Throws: `MailError` when delivery fails. + func send(_ mail: Mail) async throws(MailError) + + /// Validates a mail message without delivering it. + /// + /// - Parameter mail: The `Mail` instance to validate. + /// - Throws: `MailValidationError` when validation fails. + func validate(_ mail: Mail) async throws(MailValidationError) +} diff --git a/Sources/FeatherMail/Models/MailError.swift b/Sources/FeatherMail/Models/MailError.swift deleted file mode 100644 index 1080da4..0000000 --- a/Sources/FeatherMail/Models/MailError.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// MailError.swift -// feather-mail -// -// Created by Tibor Bödecs on 2023. 01. 16.. -// - -/// Errors that can occur during mail validation or delivery. -public enum MailError: Error, Equatable { - - // MARK: - Validation errors (pre-send) - - /// The sender address is missing or invalid. - case invalidSender - - /// The subject is empty or invalid. - case invalidSubject - - /// No valid recipients were provided. - case invalidRecipient - - /// One or more mail headers contain invalid or unsafe values. - /// - /// For example: - /// - CRLF injection attempts - /// - Newlines in subject or address fields - case headerInjectionDetected - - // MARK: - Attachment errors - - /// One or more attachments exceed the allowed size. - /// - /// This may be detected: - /// - During validation - /// - By the provider (SES / SMTP) - case attachmentsTooLarge - - // MARK: - Encoding errors - - /// Failed to encode the mail into the format required by the transport. - /// - /// This can occur when generating raw MIME data for providers like SES. - case mailEncodeError - - - // MARK: - Provider / delivery errors - - /// The mail was rejected by the provider. - /// - /// Common causes: - /// - SES sandbox restrictions - /// - Unverified identities - /// - Provider policy violations - case messageRejected - - /// The sender identity or domain is not verified. - case identityNotVerified - - /// The sending quota has been exceeded. - /// - /// For example: - /// - SES daily send limit reached - case quotaExceeded - - /// The sending rate has been exceeded. - /// - /// For example: - /// - SES rate limit - /// - SMTP throttling - case sendRateExceeded - - /// The request payload was too large. - /// - /// For example: - /// - Attachments exceed provider limits - case payloadTooLarge - - /// Access to the mail provider was denied. - /// - /// For example: - /// - Invalid credentials - /// - Missing IAM permissions - case accessDenied - - /// The mail service is temporarily unavailable. - /// - /// For example: - /// - SES sending paused - /// - Provider outage - case serviceUnavailable - - - // MARK: - Transport / infrastructure errors - - /// A network or transport-level failure occurred. - /// - /// For example: - /// - Connection timeout - /// - DNS resolution failure - /// - SMTP connection drop - case transportError - - - // MARK: - Fallback - - /// An unknown error occurred. - case unknown - -} diff --git a/Sources/FeatherMail/Models/MailProtocol.swift b/Sources/FeatherMail/Models/MailProtocol.swift deleted file mode 100644 index c2889df..0000000 --- a/Sources/FeatherMail/Models/MailProtocol.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// MailProtocol.swift -// feather-mail -// -// Created by Tibor Bödecs on 2023. 01. 16.. -// - -/// A protocol defining a mail delivery mechanism. -public protocol MailProtocol: Sendable { - - /// Sends the specified mail asynchronously. - /// - /// - Parameter email: A `Mail` instance to be delivered. - /// - Throws: `MailError` if delivery fails. - func send(_ email: Mail) async throws(MailError) -} diff --git a/Sources/FeatherMail/Utils/Base64.swift b/Sources/FeatherMail/Utils/Base64.swift new file mode 100644 index 0000000..73a75e8 --- /dev/null +++ b/Sources/FeatherMail/Utils/Base64.swift @@ -0,0 +1,48 @@ +// +// Base64.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +// 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/Validator/DefaultMailValidator.swift b/Sources/FeatherMail/Validator/BasicMailValidator.swift similarity index 51% rename from Sources/FeatherMail/Validator/DefaultMailValidator.swift rename to Sources/FeatherMail/Validator/BasicMailValidator.swift index 74dd80d..ab1517e 100644 --- a/Sources/FeatherMail/Validator/DefaultMailValidator.swift +++ b/Sources/FeatherMail/Validator/BasicMailValidator.swift @@ -1,28 +1,27 @@ // -// DefaultMailValidator.swift +// BasicMailValidator.swift // feather-mail // -// Created by gerp83 on 2026. 01. 16.. +// Created by Binary Birds on 2026. 01. 19.. // -/// Default, transport-agnostic validator for `Mail`. +/// Transport-agnostic validator for `Mail`. /// -/// This validator performs lightweight sanity checks that are cheap, -/// deterministic, and safe to run before attempting delivery. +/// Performs inexpensive, deterministic checks before delivery. /// /// Validation rules: -/// - sender address must not be empty -/// - subject must not be empty +/// - sender address must be non-empty +/// - subject must be non-empty /// - at least one valid recipient must exist /// - basic header injection prevention /// - optional total attachment size limit -public struct DefaultMailValidator: MailValidating, Sendable { +public struct BasicMailValidator: MailValidator, Sendable { /// Maximum allowed total attachment size in bytes. /// If `nil`, attachment size validation is skipped. private let maxTotalAttachmentSize: Int? - /// Creates a new default mail validator. + /// Creates a mail validator. /// /// - Parameter maxTotalAttachmentSize: Optional maximum total size of all /// attachments combined, in bytes. @@ -30,11 +29,11 @@ public struct DefaultMailValidator: MailValidating, Sendable { self.maxTotalAttachmentSize = maxTotalAttachmentSize } - /// Validates the given mail instance. + /// Validates a mail message. /// /// - Parameter mail: The `Mail` object to validate. - /// - Throws: `MailError` if validation fails. - public func validate(_ mail: Mail) throws(MailError) { + /// - Throws: `MailValidationError` if validation fails. + public func validate(_ mail: Mail) throws(MailValidationError) { try validateSender(mail) try validateSubject(mail) @@ -42,49 +41,49 @@ public struct DefaultMailValidator: MailValidating, Sendable { try validateAttachments(mail) try validateSecurity(mail) } + } // MARK: - Validation Rules -private extension DefaultMailValidator { +private extension BasicMailValidator { - /// Validates the sender address. - func validateSender(_ mail: Mail) throws(MailError) { - guard !mail.from.email.trimmingCharacters(in: .whitespaces).isEmpty - else { - throw MailError.invalidSender + /// Ensures the sender address is non-empty. + func validateSender(_ mail: Mail) throws(MailValidationError) { + guard hasNonWhitespace(mail.from.email) else { + throw MailValidationError.invalidSender } } - /// Validates the subject line. - func validateSubject(_ mail: Mail) throws(MailError) { - guard !mail.subject.trimmingCharacters(in: .whitespaces).isEmpty else { - throw MailError.invalidSubject + /// Ensures the subject line is non-empty. + func validateSubject(_ mail: Mail) throws(MailValidationError) { + guard hasNonWhitespace(mail.subject) else { + throw MailValidationError.invalidSubject } } - /// Ensures at least one valid recipient exists. - func validateRecipients(_ mail: Mail) throws(MailError) { + /// Ensures at least one recipient is valid. + func validateRecipients(_ mail: Mail) throws(MailValidationError) { let recipients = mail.to + mail.cc + mail.bcc guard recipients.contains(where: \.isValid) else { - throw MailError.invalidRecipient + throw MailValidationError.invalidRecipient } } /// Validates total attachment size if a limit is configured. - func validateAttachments(_ mail: Mail) throws(MailError) { + func validateAttachments(_ mail: Mail) throws(MailValidationError) { guard let maxSize = maxTotalAttachmentSize else { return } let totalSize = mail.attachments.reduce(0) { $0 + $1.data.count } guard totalSize <= maxSize else { - throw MailError.attachmentsTooLarge + throw MailValidationError.attachmentsTooLarge } } /// Prevents basic email header injection attacks. - func validateSecurity(_ mail: Mail) throws(MailError) { + func validateSecurity(_ mail: Mail) throws(MailValidationError) { let valuesToCheck = [ mail.subject, mail.from.email, @@ -95,7 +94,11 @@ private extension DefaultMailValidator { !$0.contains("\n") && !$0.contains("\r") }) else { - throw MailError.headerInjectionDetected + throw MailValidationError.headerInjectionDetected } } + + func hasNonWhitespace(_ value: String) -> Bool { + value.contains(where: { !$0.isWhitespace }) + } } diff --git a/Sources/FeatherMail/Validator/MailValidating.swift b/Sources/FeatherMail/Validator/MailValidating.swift deleted file mode 100644 index c8255e8..0000000 --- a/Sources/FeatherMail/Validator/MailValidating.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// MailValidating.swift -// feather-mail -// -// Created by gerp83 on 2026. 01. 16.. -// - -/// A protocol defining validation rules for `Mail` objects. -/// -/// Validators are responsible for ensuring that a mail instance -/// satisfies required invariants before delivery. This separation -/// allows different validation policies for different transports. -public protocol MailValidating: Sendable { - - /// Validates the given mail instance. - /// - /// - Parameter mail: The `Mail` object to validate. - /// - Throws: `MailError` if validation fails. - func validate(_ mail: Mail) throws(MailError) -} diff --git a/Sources/FeatherMail/Validator/MailValidator.swift b/Sources/FeatherMail/Validator/MailValidator.swift new file mode 100644 index 0000000..3df0202 --- /dev/null +++ b/Sources/FeatherMail/Validator/MailValidator.swift @@ -0,0 +1,19 @@ +// +// MailValidator.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Validation interface for `Mail` messages. +/// +/// Validators enforce invariants before delivery and allow +/// different policies per transport. +public protocol MailValidator: Sendable { + + /// Validates a mail message. + /// + /// - Parameter mail: The `Mail` object to validate. + /// - Throws: `MailValidationError` if validation fails. + func validate(_ mail: Mail) async throws(MailValidationError) +} diff --git a/Tests/FeatherMailTests/Base64Tests.swift b/Tests/FeatherMailTests/Base64Tests.swift new file mode 100644 index 0000000..37980cb --- /dev/null +++ b/Tests/FeatherMailTests/Base64Tests.swift @@ -0,0 +1,31 @@ +// +// Base64Tests.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +import Testing +@testable import FeatherMail + +@Suite +struct Base64Tests { + + @Test + func emptyEncodesToEmptyString() { + #expect([UInt8]().base64EncodedString() == "") + } + + @Test + func knownVectorsEncodeCorrectly() { + #expect([0x4D, 0x61, 0x6E].base64EncodedString() == "TWFu") + #expect([0x4D, 0x61].base64EncodedString() == "TWE=") + #expect([0x4D].base64EncodedString() == "TQ==") + } + + @Test + func arrayExtensionMatchesFunction() { + let bytes: [UInt8] = [0x66, 0x6F, 0x6F] + #expect(bytes.base64EncodedString() == "Zm9v") + } +} diff --git a/Tests/FeatherMailTests/FeatherMailTestSuite.swift b/Tests/FeatherMailTests/FeatherMailTestSuite.swift index eea31cc..5adce12 100644 --- a/Tests/FeatherMailTests/FeatherMailTestSuite.swift +++ b/Tests/FeatherMailTests/FeatherMailTestSuite.swift @@ -2,21 +2,26 @@ // FeatherMailTestSuite.swift // feather-mail // -// Created by Tibor Bödecs on 2023. 01. 16.. +// Created by Binary Birds on 2026. 01. 19.. // -import Foundation import Testing @testable import FeatherMail +/// Validation tests for basic mail rules. @Suite struct FeatherMailTestSuite { - // MARK: - Invalid sender + struct SampleError: Error, CustomStringConvertible { + let message: String + var description: String { message } + } + + // MARK: - Invalid Sender @Test func invalidSenderThrows() async { - let driver = MockMailStruct() + let client = MockMailClient() let mail = Mail( from: .init(" "), @@ -25,16 +30,16 @@ struct FeatherMailTestSuite { body: .plainText("Body") ) - await #expect(throws: MailError.invalidSender) { - try await driver.send(mail) + await #expect(throws: MailValidationError.invalidSender) { + try await client.validate(mail) } } - // MARK: - Invalid subject + // MARK: - Invalid Subject @Test func invalidSubjectThrows() async { - let driver = MockMailStruct() + let client = MockMailClient() let mail = Mail( from: .init("from@example.com"), @@ -43,16 +48,16 @@ struct FeatherMailTestSuite { body: .plainText("Body") ) - await #expect(throws: MailError.invalidSubject) { - try await driver.send(mail) + await #expect(throws: MailValidationError.invalidSubject) { + try await client.validate(mail) } } - // MARK: - Invalid recipient + // MARK: - Invalid Recipient @Test func invalidRecipientThrows() async { - let driver = MockMailStruct() + let client = MockMailClient() let mail = Mail( from: .init("from@example.com"), @@ -61,21 +66,21 @@ struct FeatherMailTestSuite { body: .plainText("Body") ) - await #expect(throws: MailError.invalidRecipient) { - try await driver.send(mail) + await #expect(throws: MailValidationError.invalidRecipient) { + try await client.validate(mail) } } - // MARK: - Attachments too large + // MARK: - Attachments Too Large @Test func attachmentsTooLargeThrows() async { - let validator = DefaultMailValidator( + let validator = BasicMailValidator( maxTotalAttachmentSize: 100 ) - let driver = MockMailStruct(validator: validator) + let client = MockMailClient(validator: validator) - let data = Data(repeating: 0, count: 1_024) + let data = [UInt8](repeating: 0, count: 1_024) let mail = Mail( from: .init("from@example.com"), @@ -91,16 +96,16 @@ struct FeatherMailTestSuite { ] ) - await #expect(throws: MailError.attachmentsTooLarge) { - try await driver.send(mail) + await #expect(throws: MailValidationError.attachmentsTooLarge) { + try await client.validate(mail) } } - // MARK: - Header injection + // MARK: - Header Injection @Test func headerInjectionThrows() async { - let driver = MockMailStruct() + let client = MockMailClient() let mail = Mail( from: .init("from@example.com"), @@ -109,16 +114,50 @@ struct FeatherMailTestSuite { body: .plainText("Body") ) - await #expect(throws: MailError.headerInjectionDetected) { - try await driver.send(mail) + await #expect(throws: MailValidationError.headerInjectionDetected) { + try await client.validate(mail) } } - // MARK: - Valid mail + // MARK: - Address MIME + + @Test + func addressMimeFormatsDisplayName() { + let named = Address("john@example.com", name: "John Doe") + #expect(named.mime == "John Doe ") + + let plain = Address("john@example.com") + #expect(plain.mime == "john@example.com") + } + + // MARK: - MailError Equality + + @Test + func mailErrorEqualityMatchesCases() { + #expect( + MailError.validation(.invalidSender) == .validation(.invalidSender) + ) + #expect( + MailError.validation(.invalidSender) != .validation(.invalidSubject) + ) + + #expect(MailError.custom("A") == .custom("A")) + #expect(MailError.custom("A") != .custom("B")) + + let leftUnknown = MailError.unknown(SampleError(message: "boom")) + let rightUnknown = MailError.unknown(SampleError(message: "boom")) + let otherUnknown = MailError.unknown(SampleError(message: "other")) + + #expect(leftUnknown == rightUnknown) + #expect(leftUnknown != otherUnknown) + #expect(leftUnknown != .custom("boom")) + } + + // MARK: - Valid Mail @Test func validMailSucceeds() async throws { - let driver = MockMailStruct() + let client = MockMailClient() let mail = Mail( from: .init("from@example.com"), @@ -127,6 +166,6 @@ struct FeatherMailTestSuite { body: .plainText("Body") ) - try await driver.send(mail) + try await client.validate(mail) } } diff --git a/Tests/FeatherMailTests/Mocks/MockMailClient.swift b/Tests/FeatherMailTests/Mocks/MockMailClient.swift new file mode 100644 index 0000000..5443864 --- /dev/null +++ b/Tests/FeatherMailTests/Mocks/MockMailClient.swift @@ -0,0 +1,34 @@ +// +// MockMailClient.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +import FeatherMail + +/// Test mail client used for validation scenarios. +/// +/// This client performs validation only and never delivers mail. +public struct MockMailClient: MailClient, Sendable { + + /// Validation strategy used by the mock. + private let validator: MailValidator + + /// Creates a mock mail client. + public init( + validator: MailValidator = BasicMailValidator() + ) { + self.validator = validator + } + + /// No-op send used for tests. + public func send(_ mail: Mail) async throws(MailError) { + // Intentionally no-op for tests. + } + + /// Validates the mail using the configured validator. + public func validate(_ mail: Mail) async throws(MailValidationError) { + try await validator.validate(mail) + } +} diff --git a/Tests/FeatherMailTests/Mocks/MockMailStruct.swift b/Tests/FeatherMailTests/Mocks/MockMailStruct.swift deleted file mode 100644 index 7a55d86..0000000 --- a/Tests/FeatherMailTests/Mocks/MockMailStruct.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// MockMailStruct.swift -// feather-mail -// -// Created by Binary Birds on 2026. 01. 06.. -// - -import FeatherMail - -/// A mock mail driver used for validation testing. -/// -/// This driver does not deliver mail, it only validates it using -/// `DefaultMailValidator`. -public struct MockMailStruct: MailProtocol, Sendable { - - /// the validator - private let validator: MailValidating - - /// Initializes a MockMailStruct object. - public init( - validator: MailValidating = DefaultMailValidator() - ) { - self.validator = validator - } - - /// Validates the mail and performs no delivery. - public func send(_ email: Mail) async throws(MailError) { - try validator.validate(email) - } -} diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile index 8fa3bbf..73be175 100644 --- a/docker/tests/Dockerfile +++ b/docker/tests/Dockerfile @@ -1,4 +1,4 @@ -FROM swift:6.2 +FROM swift:6.1 WORKDIR /app From 8672045cc7c5c12f6ca91776f1bf10def177c01e Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:07:42 +0100 Subject: [PATCH 12/26] Update Makefile --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 11bd428..a204c73 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHELL=/bin/bash -baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts +#baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts +baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/fix/docc-inject/scripts check: symlinks language deps lint docc-warnings headers From 890fa1db6dba617c35969d86b53e36179b44e614 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:19:56 +0100 Subject: [PATCH 13/26] Update Package.swift --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 0ae6717..57515a9 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,6 @@ let package = Package( .library(name: "FeatherMail", targets: ["FeatherMail"]), ], dependencies: [ - // [docc-plugin-placeholder] .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], From fb2a3977f4c66fc1b689eaf258bbb41e7016b69e Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:29:35 +0100 Subject: [PATCH 14/26] Update Package.swift --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index 57515a9..28b21ed 100644 --- a/Package.swift +++ b/Package.swift @@ -28,6 +28,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), + // [docc-plugin-placeholder] ], targets: [ From a0c9e4988aba54a042d80112a9dd9527b84c31a8 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:47:15 +0100 Subject: [PATCH 15/26] Update Package.swift --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 28b21ed..0ae6717 100644 --- a/Package.swift +++ b/Package.swift @@ -27,8 +27,8 @@ let package = Package( .library(name: "FeatherMail", targets: ["FeatherMail"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), // [docc-plugin-placeholder] + .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], targets: [ From 5c8c4204f7059970ad9c8a8acb5cff3e495b2770 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:47:43 +0100 Subject: [PATCH 16/26] Update Package.swift --- Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Package.swift b/Package.swift index 0ae6717..57515a9 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,6 @@ let package = Package( .library(name: "FeatherMail", targets: ["FeatherMail"]), ], dependencies: [ - // [docc-plugin-placeholder] .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], From 9c8557582418c87ecb70d630501818013a0ce9c0 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:50:58 +0100 Subject: [PATCH 17/26] test for inject --- Package.resolved | 47 ++++++++++++++++++++++++++++++++++++++++++++++- Package.swift | 2 ++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index fe1a2c9..6309f55 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "d97bdac7cd6ab775acd9d527ad9de9adfe931530109593fe2a24453f88a5db8c", + "originHash" : "a6d39857c3b3fc4b1f8fd9c6e3f60b515aa5b0df23cbfaa9a7a604c9114e95dd", "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration", + "state" : { + "revision" : "6ffef195ed4ba98ee98029970c94db7edc60d4c6", + "version" : "1.0.1" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -9,6 +36,24 @@ "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", "version" : "1.8.0" } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", + "version" : "2.9.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 57515a9..b2fe408 100644 --- a/Package.swift +++ b/Package.swift @@ -27,6 +27,8 @@ let package = Package( .library(name: "FeatherMail", targets: ["FeatherMail"]), ], dependencies: [ + .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), + // [docc-plugin-placeholder] .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], From 21ae1798afac42bbcceaab77600f5e6b4226bb20 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Mon, 26 Jan 2026 16:51:50 +0100 Subject: [PATCH 18/26] remove test dep --- Package.resolved | 47 +---------------------------------------------- Package.swift | 1 - 2 files changed, 1 insertion(+), 47 deletions(-) diff --git a/Package.resolved b/Package.resolved index 6309f55..eb703a0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,33 +1,6 @@ { - "originHash" : "a6d39857c3b3fc4b1f8fd9c6e3f60b515aa5b0df23cbfaa9a7a604c9114e95dd", + "originHash" : "83da2b52618699fd2dcd60ee4af8cc30370871f48fec6964f6852050c8e2998f", "pins" : [ - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-configuration", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-configuration", - "state" : { - "revision" : "6ffef195ed4ba98ee98029970c94db7edc60d4c6", - "version" : "1.0.1" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -36,24 +9,6 @@ "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", "version" : "1.8.0" } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle", - "state" : { - "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", - "version" : "2.9.1" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" - } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index b2fe408..0ae6717 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,6 @@ let package = Package( .library(name: "FeatherMail", targets: ["FeatherMail"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), // [docc-plugin-placeholder] .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], From 49971e49758972697578479c6f331fa33eae3869 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Tue, 27 Jan 2026 17:34:17 +0100 Subject: [PATCH 19/26] add RawMailEncoder and tests --- Makefile | 3 +- README.md | 70 ++++---- .../FeatherMail/Models/Error/MailError.swift | 12 +- Sources/FeatherMail/Utils/Base64.swift | 14 +- .../FeatherMail/Utils/RawMailEncoder.swift | 127 +++++++++++++++ .../RawMailEncoderTests.swift | 152 ++++++++++++++++++ 6 files changed, 337 insertions(+), 41 deletions(-) create mode 100644 Sources/FeatherMail/Utils/RawMailEncoder.swift create mode 100644 Tests/FeatherMailTests/RawMailEncoderTests.swift diff --git a/Makefile b/Makefile index a204c73..11bd428 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ SHELL=/bin/bash -#baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts -baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/fix/docc-inject/scripts +baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/heads/main/scripts check: symlinks language deps lint docc-warnings headers diff --git a/README.md b/README.md index 8adf8d9..adccc14 100644 --- a/README.md +++ b/README.md @@ -2,48 +2,60 @@ An abstract mail component for Feather CMS. -## Getting started +![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138) -⚠️ This repository is a work in progress, things can break until it reaches v1.0.0. +## Features -Use at your own risk. +- Immutable mail payload model +- Validation helpers and errors +- Raw MIME encoder for transport providers +- Attachments and HTML support -### Adding the dependency +## Requirements -To add a dependency on the package, declare it in your `Package.swift`: +![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) + +- Swift 6.1+ +- Platforms: + - macOS 13+ + - iOS 16+ + - tvOS 16+ + - watchOS 9+ + - visionOS 1+ + +## Installation + +Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift .package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "1.0.0-beta.1")), ``` -and to your application target, add `FeatherMail` to your dependencies: +Then add `FeatherMail` to your target dependencies: ```swift -.product(name: "FeatherMail", package: "feather-mail") +.product(name: "FeatherMail", package: "feather-mail"), ``` -Example `Package.swift` file with `FeatherMail` as a dependency: +## Usage -```swift -// swift-tools-version:6.2 -import PackageDescription - -let package = Package( - name: "my-application", - dependencies: [ - .package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "1.0.0-beta.1")), - ], - targets: [ - .target(name: "MyApplication", dependencies: [ - .product(name: "FeatherMail", package: "feather-mail") - ]), - .testTarget(name: "MyApplicationTests", dependencies: [ - .target(name: "MyApplication"), - ]), - ] -) -``` +![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) + +API documentation is available at the following [link](https://feather-framework.github.io/feather-mail/). + +> [!WARNING] +> This repository is a work in progress, things can break until it reaches v1.0.0. + +## Development + +- Build: `swift build` +- Test: + - local: `make test` + - using Docker: `make docker-test` +- Format: `make format` +- Check: `make check` -### Documentation +## Contributing -For more information, see the official [API documentation](https://feather-framework.github.io/feather-mail/) for this package. +[Pull requests](https://github.com/feather-framework/feather-mail/pulls) are welcome. Please keep changes focused and include tests for new logic. diff --git a/Sources/FeatherMail/Models/Error/MailError.swift b/Sources/FeatherMail/Models/Error/MailError.swift index 406d124..d27c9d8 100644 --- a/Sources/FeatherMail/Models/Error/MailError.swift +++ b/Sources/FeatherMail/Models/Error/MailError.swift @@ -7,17 +7,16 @@ /// Errors that can occur during validation or delivery. public enum MailError: Error, Equatable { - + /// Validation failed before delivery. case validation(MailValidationError) - + /// A caller-provided error message. case custom(String) - + /// An uncategorized underlying error. case unknown(Error) - - + /// Compares errors by their meaningful payloads. public static func == (lhs: MailError, rhs: MailError) -> Bool { switch (lhs, rhs) { @@ -26,7 +25,8 @@ public enum MailError: Error, Equatable { case let (.custom(left), .custom(right)): return left == right case let (.unknown(left), .unknown(right)): - return String(reflecting: type(of: left)) == String(reflecting: type(of: right)) + return String(reflecting: type(of: left)) + == String(reflecting: type(of: right)) && String(describing: left) == String(describing: right) default: return false diff --git a/Sources/FeatherMail/Utils/Base64.swift b/Sources/FeatherMail/Utils/Base64.swift index 73a75e8..c448e53 100644 --- a/Sources/FeatherMail/Utils/Base64.swift +++ b/Sources/FeatherMail/Utils/Base64.swift @@ -13,7 +13,10 @@ public extension Array where Element == UInt8 { return "" } - let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".utf8) + let alphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + .utf8 + ) var output: [UInt8] = [] output.reserveCapacity(((count + 2) / 3) * 4) @@ -23,20 +26,23 @@ public extension Array where Element == UInt8 { 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) + 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 { + } + else { output.append(UInt8(ascii: "=")) } if index + 2 < count { output.append(alphabet[Int(triple & 0x3F)]) - } else { + } + else { output.append(UInt8(ascii: "=")) } diff --git a/Sources/FeatherMail/Utils/RawMailEncoder.swift b/Sources/FeatherMail/Utils/RawMailEncoder.swift new file mode 100644 index 0000000..fc1fe76 --- /dev/null +++ b/Sources/FeatherMail/Utils/RawMailEncoder.swift @@ -0,0 +1,127 @@ +// +// RawMailEncoder.swift +// feather-mail +// +// Created by gerp83 on 2025. 01. 16.. +// + +/// Encodes `Mail` values into raw MIME messages. +/// +/// `RawMailEncoder` is responsible for transforming a validated `Mail` +/// instance into a raw MIME representation suitable for transport providers. +/// The encoding logic preserves the legacy behavior and output format. +/// +/// The encoder: +/// - builds standard email headers (From, To, Cc, Reply-To, Subject, Date) +/// - 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 { + + /// Creates a raw mail encoder. + public init() {} + + /// 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. + public func encode( + _ mail: Mail, + dateHeader: String, + messageID: String + ) throws(MailError) -> String { + + var out = String() + out.reserveCapacity(4096) + + out += "From: \(mail.from.mime)\r\n" + + if !mail.to.isEmpty { + out += "To: \(mail.to.map(\.mime).joined(separator: ", "))\r\n" + } + + if !mail.cc.isEmpty { + out += "Cc: \(mail.cc.map(\.mime).joined(separator: ", "))\r\n" + } + + if !mail.replyTo.isEmpty { + out += + "Reply-to: \(mail.replyTo.map(\.mime).joined(separator: ", "))\r\n" + } + + out += "Subject: \(mail.subject)\r\n" + out += "Date: \(dateHeader)\r\n" + out += "Message-ID: \(messageID)\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() + + if let boundary { + out += "Content-type: multipart/mixed; boundary=\"\(boundary)\"\r\n" + out += "Mime-Version: 1.0\r\n\r\n" + } + + switch mail.body { + + case .plainText(let value): + if let boundary { + out += "--\(boundary)\r\n" + } + out += "Content-Type: text/plain; charset=\"UTF-8\"\r\n" + out += "Mime-Version: 1.0\r\n\r\n" + out += value + out += "\r\n\r\n" + + case .html(let value): + if let boundary { + out += "--\(boundary)\r\n" + } + out += "Content-Type: text/html; charset=\"UTF-8\"\r\n" + out += "Mime-Version: 1.0\r\n\r\n" + out += value + out += "\r\n" + } + + if let boundary { + for attachment in mail.attachments { + out += "--\(boundary)\r\n" + out += "Content-type: \(attachment.contentType)\r\n" + out += "Content-Transfer-Encoding: base64\r\n" + out += + "Content-Disposition: attachment; filename=\"\(attachment.name)\"\r\n\r\n" + out += attachment.data.base64EncodedString() + out += "\r\n" + } + } + + out += "\r\n" + + return out + } +} + +// MARK: - Helpers + +private extension RawMailEncoder { + + /// Creates a unique MIME boundary without Foundation. + func createBoundary() -> String { + var generator = SystemRandomNumberGenerator() + let value = UInt64.random( + in: UInt64.min...UInt64.max, + using: &generator + ) + return "Boundary-\(String(value, radix: 16))" + } +} diff --git a/Tests/FeatherMailTests/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift new file mode 100644 index 0000000..0fddc8a --- /dev/null +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -0,0 +1,152 @@ +// +// RawMailEncoderTests.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 26.. +// + +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 makeMail( + body: Body, + cc: [Address] = [], + replyTo: [Address] = [], + reference: String? = nil, + attachments: [FeatherMail.Attachment] = [] + ) -> Mail { + Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + cc: cc, + replyTo: replyTo, + subject: "Test Subject", + body: body, + reference: reference, + attachments: attachments + ) + } + + @Test + func plainTextWithoutAttachmentsDoesNotUseMultipart() throws { + let mail = makeMail(body: .plainText("Hello")) + + let raw = try encoder.encode( + mail, + dateHeader: dateHeader, + messageID: messageID + ) + + #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("Content-Type: text/plain; charset=\"UTF-8\"\r\n")) + #expect(!raw.contains("multipart/mixed")) + #expect(!raw.contains("Content-Disposition: attachment")) + } + + @Test + func htmlBodyUsesHtmlContentType() throws { + let mail = makeMail(body: .html("Hi")) + + let raw = try encoder.encode( + mail, + dateHeader: dateHeader, + messageID: messageID + ) + + #expect(raw.contains("Content-Type: text/html; charset=\"UTF-8\"\r\n")) + } + + @Test + func headersIncludeCcReplyToAndReferences() throws { + let mail = makeMail( + body: .plainText("Hello"), + cc: [.init("cc@example.com")], + replyTo: [.init("reply@example.com")], + reference: "" + ) + + let raw = try encoder.encode( + mail, + dateHeader: dateHeader, + messageID: messageID + ) + + #expect(raw.contains("Cc: cc@example.com\r\n")) + #expect(raw.contains("Reply-to: reply@example.com\r\n")) + #expect(raw.contains("In-Reply-To: \r\n")) + #expect(raw.contains("References: \r\n")) + } + + + @Test + func htmlWithAttachmentUsesMultipartAndHtmlBody() throws { + let attachment = FeatherMail.Attachment( + name: "image.png", + contentType: "image/png", + data: [0x01, 0x02, 0x03] + ) + let mail = makeMail( + body: .html("Hi"), + attachments: [attachment] + ) + + let raw = try encoder.encode( + mail, + dateHeader: dateHeader, + messageID: messageID + ) + + #expect(raw.contains("Content-type: multipart/mixed; boundary=\"")) + #expect(raw.contains("Content-Type: text/html; charset=\"UTF-8\"\r\n")) + #expect(raw.contains("Content-Disposition: attachment; filename=\"image.png\"")) + } + + @Test + func attachmentsUseMultipartWithBoundary() throws { + 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, + dateHeader: dateHeader, + messageID: messageID + ) + + #expect(raw.contains("Content-type: multipart/mixed; boundary=\"")) + #expect(raw.contains("Content-Disposition: attachment; filename=\"file.txt\"")) + #expect(raw.contains("Content-Transfer-Encoding: base64")) + #expect(raw.contains("SGVsbG8=")) + + let boundaryLinePrefix = "Content-type: multipart/mixed; boundary=\"" + guard let boundaryLineStart = raw.range(of: boundaryLinePrefix) else { + Issue.record("Missing boundary header") + return + } + let afterPrefix = raw[boundaryLineStart.upperBound...] + guard let endQuote = afterPrefix.firstIndex(of: "\"") else { + Issue.record("Missing boundary terminator") + return + } + let boundary = String(afterPrefix[.. Date: Tue, 27 Jan 2026 17:34:49 +0100 Subject: [PATCH 20/26] format --- Tests/FeatherMailTests/RawMailEncoderTests.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Tests/FeatherMailTests/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift index 0fddc8a..868dff2 100644 --- a/Tests/FeatherMailTests/RawMailEncoderTests.swift +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -88,7 +88,6 @@ struct RawMailEncoderTests { #expect(raw.contains("References: \r\n")) } - @Test func htmlWithAttachmentUsesMultipartAndHtmlBody() throws { let attachment = FeatherMail.Attachment( @@ -109,7 +108,11 @@ struct RawMailEncoderTests { #expect(raw.contains("Content-type: multipart/mixed; boundary=\"")) #expect(raw.contains("Content-Type: text/html; charset=\"UTF-8\"\r\n")) - #expect(raw.contains("Content-Disposition: attachment; filename=\"image.png\"")) + #expect( + raw.contains( + "Content-Disposition: attachment; filename=\"image.png\"" + ) + ) } @Test @@ -131,7 +134,11 @@ struct RawMailEncoderTests { ) #expect(raw.contains("Content-type: multipart/mixed; boundary=\"")) - #expect(raw.contains("Content-Disposition: attachment; filename=\"file.txt\"")) + #expect( + raw.contains( + "Content-Disposition: attachment; filename=\"file.txt\"" + ) + ) #expect(raw.contains("Content-Transfer-Encoding: base64")) #expect(raw.contains("SGVsbG8=")) From 72499064ba7d196605fd11a7a1fe510528cbab47 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Tue, 27 Jan 2026 17:38:49 +0100 Subject: [PATCH 21/26] Create .unacceptablelanguageignore --- .unacceptablelanguageignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .unacceptablelanguageignore diff --git a/.unacceptablelanguageignore b/.unacceptablelanguageignore new file mode 100644 index 0000000..0472b38 --- /dev/null +++ b/.unacceptablelanguageignore @@ -0,0 +1 @@ +Sources/FeatherMail/Models/Address.swift \ No newline at end of file From 773cd39e1d60a566c5bb4cc4e14dac930bd39743 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Tue, 27 Jan 2026 17:49:55 +0100 Subject: [PATCH 22/26] Update README.md --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index adccc14..72c6a6b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ API documentation is available at the following [link](https://feather-framework > [!WARNING] > This repository is a work in progress, things can break until it reaches v1.0.0. +## Mail drivers + +The following mail driver implementations are available for use: + +- [SES](https://github.com/feather-framework/feather-mail-driver-ses) +- [SMTP](https://github.com/feather-framework/feather-mail-driver-smtp) + ## Development - Build: `swift build` From f528e095e7f00f0d6e7c7ef90f076df6dd715991 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Tue, 27 Jan 2026 22:17:52 +0100 Subject: [PATCH 23/26] Update README.md --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 72c6a6b..0555dc9 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ API documentation is available at the following [link](https://feather-framework The following mail driver implementations are available for use: -- [SES](https://github.com/feather-framework/feather-mail-driver-ses) -- [SMTP](https://github.com/feather-framework/feather-mail-driver-smtp) +- [SES Driver](https://github.com/feather-framework/feather-mail-driver-ses) +- [SMTP Driver](https://github.com/feather-framework/feather-mail-driver-smtp) +- [Memory Driver](https://github.com/feather-framework/feather-memory-mail) ## Development From 105d4d0f94f34392587bf5a6f7ff01e0aeec010c Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 28 Jan 2026 18:41:53 +0100 Subject: [PATCH 24/26] update readme package.swift --- Package.swift | 2 +- README.md | 24 ++++++++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index 0ae6717..8dc22a6 100644 --- a/Package.swift +++ b/Package.swift @@ -11,7 +11,7 @@ var defaultSwiftSettings: [SwiftSetting] = [ // https://forums.swift.org/t/experimental-support-for-lifetime-dependencies-in-swift-6-2-and-beyond/78638 .enableExperimentalFeature("Lifetimes"), // https://github.com/swiftlang/swift/pull/65218 - .enableExperimentalFeature("AvailabilityMacro=FeatherMailAvailability:macOS 13, iOS 16, watchOS 9, tvOS 16, visionOS 1"), + .enableExperimentalFeature("AvailabilityMacro=FeatherMailAvailability:macOS 15, iOS 18, watchOS 9, tvOS 11, visionOS 2"), ] #if compiler(>=6.2) diff --git a/README.md b/README.md index 0555dc9..b964012 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ 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) +[ + ![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 +) ## Features @@ -18,11 +22,11 @@ An abstract mail component for Feather CMS. - Swift 6.1+ - Platforms: - - macOS 13+ - - iOS 16+ - - tvOS 16+ - - watchOS 9+ - - visionOS 1+ + - macOS 15+ + - iOS 18+ + - tvOS 18+ + - watchOS 11+ + - visionOS 2+ ## Installation @@ -40,9 +44,13 @@ Then add `FeatherMail` to your target dependencies: ## Usage -![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +[ + ![DocC API documentation](https://img.shields.io/badge/DocC-API_documentation-F05138) +]( + https://feather-framework.github.io/feather-mail/ +) -API documentation is available at the following [link](https://feather-framework.github.io/feather-mail/). +API documentation is available at the following link. > [!WARNING] > This repository is a work in progress, things can break until it reaches v1.0.0. From 54fab333496d54bfab93eae8391602499376c467 Mon Sep 17 00:00:00 2001 From: GErP83 Date: Wed, 28 Jan 2026 18:46:36 +0100 Subject: [PATCH 25/26] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b964012..6199e1a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,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", .upToNextMinor(from: "1.0.0-beta.1")), +.package(url: "https://github.com/feather-framework/feather-mail", exact: "1.0.0-beta.1"), ``` Then add `FeatherMail` to your target dependencies: From 6fc9ddc6ba4945fb675a8e5b1fe9bb6d1579bace Mon Sep 17 00:00:00 2001 From: GErP83 Date: Thu, 29 Jan 2026 17:55:30 +0100 Subject: [PATCH 26/26] MailError not Equatable, Base64 is private --- .../{Utils => Encoder}/RawMailEncoder.swift | 53 ++++++++++++++++-- .../FeatherMail/Models/Error/MailError.swift | 18 +------ Sources/FeatherMail/Utils/Base64.swift | 54 ------------------- Tests/FeatherMailTests/Base64Tests.swift | 31 ----------- .../FeatherMailTestSuite.swift | 23 -------- 5 files changed, 49 insertions(+), 130 deletions(-) rename Sources/FeatherMail/{Utils => Encoder}/RawMailEncoder.swift (71%) delete mode 100644 Sources/FeatherMail/Utils/Base64.swift delete mode 100644 Tests/FeatherMailTests/Base64Tests.swift diff --git a/Sources/FeatherMail/Utils/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift similarity index 71% rename from Sources/FeatherMail/Utils/RawMailEncoder.swift rename to Sources/FeatherMail/Encoder/RawMailEncoder.swift index fc1fe76..56dba96 100644 --- a/Sources/FeatherMail/Utils/RawMailEncoder.swift +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -117,11 +117,54 @@ private extension RawMailEncoder { /// Creates a unique MIME boundary without Foundation. func createBoundary() -> String { - var generator = SystemRandomNumberGenerator() - let value = UInt64.random( - in: UInt64.min...UInt64.max, - using: &generator + "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 ) - return "Boundary-\(String(value, radix: 16))" + 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/MailError.swift b/Sources/FeatherMail/Models/Error/MailError.swift index d27c9d8..1a2446c 100644 --- a/Sources/FeatherMail/Models/Error/MailError.swift +++ b/Sources/FeatherMail/Models/Error/MailError.swift @@ -6,7 +6,7 @@ // /// Errors that can occur during validation or delivery. -public enum MailError: Error, Equatable { +public enum MailError: Error { /// Validation failed before delivery. case validation(MailValidationError) @@ -17,20 +17,4 @@ public enum MailError: Error, Equatable { /// An uncategorized underlying error. case unknown(Error) - /// Compares errors by their meaningful payloads. - public static func == (lhs: MailError, rhs: MailError) -> Bool { - switch (lhs, rhs) { - case let (.validation(left), .validation(right)): - return left == right - case let (.custom(left), .custom(right)): - return left == right - case let (.unknown(left), .unknown(right)): - return String(reflecting: type(of: left)) - == String(reflecting: type(of: right)) - && String(describing: left) == String(describing: right) - default: - return false - } - } - } diff --git a/Sources/FeatherMail/Utils/Base64.swift b/Sources/FeatherMail/Utils/Base64.swift deleted file mode 100644 index c448e53..0000000 --- a/Sources/FeatherMail/Utils/Base64.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// Base64.swift -// feather-mail -// -// Created by Binary Birds on 2026. 01. 19.. -// - -// 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/Tests/FeatherMailTests/Base64Tests.swift b/Tests/FeatherMailTests/Base64Tests.swift deleted file mode 100644 index 37980cb..0000000 --- a/Tests/FeatherMailTests/Base64Tests.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Base64Tests.swift -// feather-mail -// -// Created by Binary Birds on 2026. 01. 19.. -// - -import Testing -@testable import FeatherMail - -@Suite -struct Base64Tests { - - @Test - func emptyEncodesToEmptyString() { - #expect([UInt8]().base64EncodedString() == "") - } - - @Test - func knownVectorsEncodeCorrectly() { - #expect([0x4D, 0x61, 0x6E].base64EncodedString() == "TWFu") - #expect([0x4D, 0x61].base64EncodedString() == "TWE=") - #expect([0x4D].base64EncodedString() == "TQ==") - } - - @Test - func arrayExtensionMatchesFunction() { - let bytes: [UInt8] = [0x66, 0x6F, 0x6F] - #expect(bytes.base64EncodedString() == "Zm9v") - } -} diff --git a/Tests/FeatherMailTests/FeatherMailTestSuite.swift b/Tests/FeatherMailTests/FeatherMailTestSuite.swift index 5adce12..81c12de 100644 --- a/Tests/FeatherMailTests/FeatherMailTestSuite.swift +++ b/Tests/FeatherMailTests/FeatherMailTestSuite.swift @@ -130,29 +130,6 @@ struct FeatherMailTestSuite { #expect(plain.mime == "john@example.com") } - // MARK: - MailError Equality - - @Test - func mailErrorEqualityMatchesCases() { - #expect( - MailError.validation(.invalidSender) == .validation(.invalidSender) - ) - #expect( - MailError.validation(.invalidSender) != .validation(.invalidSubject) - ) - - #expect(MailError.custom("A") == .custom("A")) - #expect(MailError.custom("A") != .custom("B")) - - let leftUnknown = MailError.unknown(SampleError(message: "boom")) - let rightUnknown = MailError.unknown(SampleError(message: "boom")) - let otherUnknown = MailError.unknown(SampleError(message: "other")) - - #expect(leftUnknown == rightUnknown) - #expect(leftUnknown != otherUnknown) - #expect(leftUnknown != .custom("boom")) - } - // MARK: - Valid Mail @Test