diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index cd98d5f..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\": \"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..c73cf0c 100644 --- a/.swiftformatignore +++ b/.swiftformatignore @@ -1,3 +1 @@ -Package.swift -Package@swift-6.0.swift -Package@swift-6.1.swift \ No newline at end of file +Package.swift \ No newline at end of file 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 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.resolved b/Package.resolved index a9b57c8..eb703a0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7fabbf17cf01708cb2347144738c1300dc8d5fb882d90337986e603b666ab0b3", + "originHash" : "83da2b52618699fd2dcd60ee4af8cc30370871f48fec6964f6852050c8e2998f", "pins" : [ { "identity" : "swift-log", diff --git a/Package.swift b/Package.swift index 3ff9cd3..8dc22a6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,25 +1,36 @@ -// swift-tools-version:6.2 +// swift-tools-version:6.1 import PackageDescription -let defaultSwiftSettings: [SwiftSetting] = [ +// 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 15, iOS 18, watchOS 9, tvOS 11, visionOS 2"), ] +#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", - products: [ .library(name: "FeatherMail", targets: ["FeatherMail"]), - .library(name: "FeatherMailTesting", targets: ["FeatherMailTesting"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-log", from: "1.8.0"), + // [docc-plugin-placeholder] + .package(url: "https://github.com/apple/swift-log", from: "1.6.0"), ], + targets: [ .target( name: "FeatherMail", @@ -27,8 +38,8 @@ let package = Package( .product(name: "Logging", package: "swift-log"), ] ), - .target( - name: "FeatherMailTesting", + .testTarget( + name: "FeatherMailTests", dependencies: [ .target(name: "FeatherMail"), ], @@ -36,11 +47,6 @@ 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 deleted file mode 100644 index f7148c8..0000000 --- a/Package@swift-6.1.swift +++ /dev/null @@ -1,47 +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"]), - .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/README.md b/README.md index e13eec7..6199e1a 100644 --- a/README.md +++ b/README.md @@ -2,49 +2,76 @@ 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) +]( + https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.1 +) + +## Features + +- Immutable mail payload model +- Validation helpers and errors +- Raw MIME encoder for transport providers +- Attachments and HTML support + +## Requirements -⚠️ This repository is a work in progress, things can break until it reaches v1.0.0. +![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) -Use at your own risk. +- Swift 6.1+ +- Platforms: + - macOS 15+ + - iOS 18+ + - tvOS 18+ + - watchOS 11+ + - visionOS 2+ -### Adding the dependency +## Installation -To add a dependency on the package, declare it in your `Package.swift`: +Use Swift Package Manager; add the dependency to your `Package.swift` file: ```swift -.package(url: "https://github.com/feather-framework/feather-mail", .upToNextMinor(from: "0.4.0")), +.package(url: "https://github.com/feather-framework/feather-mail", exact: "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: "0.6.0")), - ], - 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) +]( + https://feather-framework.github.io/feather-mail/ ) -``` -### Documentation +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. + +## Mail drivers + +The following mail driver implementations are available for use: + +- [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 + +- Build: `swift build` +- Test: + - local: `make test` + - using Docker: `make docker-test` +- Format: `make format` +- Check: `make check` + +## Contributing -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/) +[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/Encoder/RawMailEncoder.swift b/Sources/FeatherMail/Encoder/RawMailEncoder.swift new file mode 100644 index 0000000..56dba96 --- /dev/null +++ b/Sources/FeatherMail/Encoder/RawMailEncoder.swift @@ -0,0 +1,170 @@ +// +// 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 { + "Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))" + } +} + +// Foundation-free Base64 encoding for byte arrays. +private extension Array where Element == UInt8 { + /// Returns a Base64 string representation of the byte array. + func base64EncodedString() -> String { + guard !isEmpty else { + return "" + } + + let alphabet = Array( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + .utf8 + ) + var output: [UInt8] = [] + output.reserveCapacity(((count + 2) / 3) * 4) + + var index = 0 + while index < count { + let byte0 = self[index] + let byte1 = (index + 1 < count) ? self[index + 1] : 0 + let byte2 = (index + 2 < count) ? self[index + 2] : 0 + + let triple = + (UInt32(byte0) << 16) | (UInt32(byte1) << 8) | UInt32(byte2) + + output.append(alphabet[Int((triple >> 18) & 0x3F)]) + output.append(alphabet[Int((triple >> 12) & 0x3F)]) + + if index + 1 < count { + output.append(alphabet[Int((triple >> 6) & 0x3F)]) + } + else { + output.append(UInt8(ascii: "=")) + } + + if index + 2 < count { + output.append(alphabet[Int(triple & 0x3F)]) + } + else { + output.append(UInt8(ascii: "=")) + } + + index += 3 + } + + return String(decoding: output, as: UTF8.self) + } +} diff --git a/Sources/FeatherMail/Mail.swift b/Sources/FeatherMail/Mail.swift deleted file mode 100644 index f3631f2..0000000 --- a/Sources/FeatherMail/Mail.swift +++ /dev/null @@ -1,141 +0,0 @@ -// -// Mail.swift -// feather-mail -// -// Created by Tibor Bodecs 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 04966af..0000000 --- a/Sources/FeatherMail/MailError.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// MailError.swift -// feather-mail -// -// Created by Tibor Bodecs 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 ef329eb..0000000 --- a/Sources/FeatherMail/MailProtocol.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// MailProtocol.swift -// feather-mail -// -// Created by Tibor Bodecs 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..fa37486 --- /dev/null +++ b/Sources/FeatherMail/Models/Address.swift @@ -0,0 +1,49 @@ +// +// Address.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Email address with an optional display name. +public struct Address: Sendable { + + /// Raw email address string (e.g. "user@example.com"). + public let email: String + + /// Optional display name (e.g. "John Doe"). + public let name: String? + + /// Creates an email address. + /// - Parameters: + /// - email: The email address string. + /// - name: Optional display name. + public init( + _ email: String, + name: String? = nil + ) { + self.email = email + self.name = name + } + + /// Indicates whether the email value is non-empty. + /// + /// This is a lightweight sanity check and does not attempt RFC validation. + var isValid: Bool { + email.contains(where: { !$0.isWhitespace }) + } + + /// Returns a header-ready representation of the address. + /// + /// If a display name is present, the format is: + /// `Name ` + /// + /// Otherwise, only the email address is returned. + 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..770f648 --- /dev/null +++ b/Sources/FeatherMail/Models/Attachment.swift @@ -0,0 +1,35 @@ +// +// Attachment.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// File attachment included with an email message. +public struct Attachment: Sendable { + + /// File name including extension (e.g. "invoice.pdf"). + public let name: String + + /// MIME type of the attachment content (e.g. "application/pdf"). + public let contentType: String + + /// Raw attachment bytes. + public let data: [UInt8] + + /// Creates an attachment. + /// + /// - Parameters: + /// - name: The file name. + /// - contentType: The MIME type of the attachment. + /// - data: The binary contents of the file. + public init( + name: String, + contentType: String, + data: [UInt8] + ) { + 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..00a4640 --- /dev/null +++ b/Sources/FeatherMail/Models/Body.swift @@ -0,0 +1,16 @@ +// +// Body.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Email body content variants. +public enum Body: Sendable { + + /// Plain text content. + case plainText(String) + + /// 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..1a2446c --- /dev/null +++ b/Sources/FeatherMail/Models/Error/MailError.swift @@ -0,0 +1,20 @@ +// +// MailError.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Errors that can occur during validation or delivery. +public enum MailError: Error { + + /// Validation failed before delivery. + case validation(MailValidationError) + + /// A caller-provided error message. + case custom(String) + + /// An uncategorized underlying error. + case unknown(Error) + +} 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 new file mode 100644 index 0000000..5d9f71a --- /dev/null +++ b/Sources/FeatherMail/Models/Mail.swift @@ -0,0 +1,72 @@ +// +// Mail.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Immutable email message payload. +public struct Mail: Sendable { + + /// Sender address. + public let from: Address + + /// Primary recipients. + public let to: [Address] + + /// Carbon copy recipients. + public let cc: [Address] + + /// Blind carbon copy recipients. + public let bcc: [Address] + + /// Reply-to addresses. + public let replyTo: [Address] + + /// Subject line. + public let subject: String + + /// Message body content. + public let body: Body + + /// Optional reference identifier for threading. + public let reference: String? + + /// File attachments. + public let attachments: [Attachment] + + /// Creates a mail message. + /// + /// - Parameters: + /// - from: The sender's address. + /// - to: An array of primary recipients. + /// - 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], + 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/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/Validator/BasicMailValidator.swift b/Sources/FeatherMail/Validator/BasicMailValidator.swift new file mode 100644 index 0000000..ab1517e --- /dev/null +++ b/Sources/FeatherMail/Validator/BasicMailValidator.swift @@ -0,0 +1,104 @@ +// +// BasicMailValidator.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +/// Transport-agnostic validator for `Mail`. +/// +/// Performs inexpensive, deterministic checks before delivery. +/// +/// Validation rules: +/// - 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 BasicMailValidator: MailValidator, Sendable { + + /// Maximum allowed total attachment size in bytes. + /// If `nil`, attachment size validation is skipped. + private let maxTotalAttachmentSize: Int? + + /// Creates a mail validator. + /// + /// - Parameter maxTotalAttachmentSize: Optional maximum total size of all + /// attachments combined, in bytes. + public init(maxTotalAttachmentSize: Int? = nil) { + self.maxTotalAttachmentSize = maxTotalAttachmentSize + } + + /// Validates a mail message. + /// + /// - Parameter mail: The `Mail` object to validate. + /// - Throws: `MailValidationError` if validation fails. + public func validate(_ mail: Mail) throws(MailValidationError) { + + try validateSender(mail) + try validateSubject(mail) + try validateRecipients(mail) + try validateAttachments(mail) + try validateSecurity(mail) + } + +} + +// MARK: - Validation Rules + +private extension BasicMailValidator { + + /// Ensures the sender address is non-empty. + func validateSender(_ mail: Mail) throws(MailValidationError) { + guard hasNonWhitespace(mail.from.email) else { + throw MailValidationError.invalidSender + } + } + + /// 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 recipient is valid. + func validateRecipients(_ mail: Mail) throws(MailValidationError) { + let recipients = mail.to + mail.cc + mail.bcc + guard recipients.contains(where: \.isValid) else { + throw MailValidationError.invalidRecipient + } + } + + /// Validates total attachment size if a limit is configured. + 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 MailValidationError.attachmentsTooLarge + } + } + + /// Prevents basic email header injection attacks. + func validateSecurity(_ mail: Mail) throws(MailValidationError) { + let valuesToCheck = [ + mail.subject, + mail.from.email, + ] + + guard + valuesToCheck.allSatisfy({ + !$0.contains("\n") && !$0.contains("\r") + }) + else { + throw MailValidationError.headerInjectionDetected + } + } + + func hasNonWhitespace(_ value: String) -> Bool { + value.contains(where: { !$0.isWhitespace }) + } +} 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/Sources/FeatherMailTesting/MailStruct.swift b/Sources/FeatherMailTesting/MailStruct.swift deleted file mode 100644 index 5a2f838..0000000 --- a/Sources/FeatherMailTesting/MailStruct.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// MailStruct.swift -// feather-mail -// -// Created by Binary Birds on 2026. 01. 06.. -// - -import FeatherMail - -/// A concrete implementation of `MailProtocol` used primarily for testing or as a no-op placeholder. -public struct MailStruct: MailProtocol { - - /// 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 - } - -} diff --git a/Sources/FeatherMailTesting/MailTestSuite.swift b/Sources/FeatherMailTesting/MailTestSuite.swift deleted file mode 100644 index 6119303..0000000 --- a/Sources/FeatherMailTesting/MailTestSuite.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// MailTestSuite.swift -// feather-mail -// -// Created by Tibor Bodecs 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 { - - /// The mail service instance being tested. - let mail: MailStruct - - /// 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) { - 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 MailTestSuite { - - /// 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) - } - -} 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/FeatherMailTestSuite.swift b/Tests/FeatherMailTests/FeatherMailTestSuite.swift new file mode 100644 index 0000000..81c12de --- /dev/null +++ b/Tests/FeatherMailTests/FeatherMailTestSuite.swift @@ -0,0 +1,148 @@ +// +// FeatherMailTestSuite.swift +// feather-mail +// +// Created by Binary Birds on 2026. 01. 19.. +// + +import Testing +@testable import FeatherMail + +/// Validation tests for basic mail rules. +@Suite +struct FeatherMailTestSuite { + + struct SampleError: Error, CustomStringConvertible { + let message: String + var description: String { message } + } + + // MARK: - Invalid Sender + + @Test + func invalidSenderThrows() async { + let client = MockMailClient() + + let mail = Mail( + from: .init(" "), + to: [.init("to@example.com")], + subject: "Hello", + body: .plainText("Body") + ) + + await #expect(throws: MailValidationError.invalidSender) { + try await client.validate(mail) + } + } + + // MARK: - Invalid Subject + + @Test + func invalidSubjectThrows() async { + let client = MockMailClient() + + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: " ", + body: .plainText("Body") + ) + + await #expect(throws: MailValidationError.invalidSubject) { + try await client.validate(mail) + } + } + + // MARK: - Invalid Recipient + + @Test + func invalidRecipientThrows() async { + let client = MockMailClient() + + let mail = Mail( + from: .init("from@example.com"), + to: [.init(" ")], + subject: "Hello", + body: .plainText("Body") + ) + + await #expect(throws: MailValidationError.invalidRecipient) { + try await client.validate(mail) + } + } + + // MARK: - Attachments Too Large + + @Test + func attachmentsTooLargeThrows() async { + let validator = BasicMailValidator( + maxTotalAttachmentSize: 100 + ) + let client = MockMailClient(validator: validator) + + let data = [UInt8](repeating: 0, count: 1_024) + + 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 + ) + ] + ) + + await #expect(throws: MailValidationError.attachmentsTooLarge) { + try await client.validate(mail) + } + } + + // MARK: - Header Injection + + @Test + func headerInjectionThrows() async { + let client = MockMailClient() + + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: "Hello\nInjected", + body: .plainText("Body") + ) + + await #expect(throws: MailValidationError.headerInjectionDetected) { + try await client.validate(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: - Valid Mail + + @Test + func validMailSucceeds() async throws { + let client = MockMailClient() + + let mail = Mail( + from: .init("from@example.com"), + to: [.init("to@example.com")], + subject: "Hello", + body: .plainText("Body") + ) + + try await client.validate(mail) + } +} diff --git a/Tests/FeatherMailTests/FeatherMailTests.swift b/Tests/FeatherMailTests/FeatherMailTests.swift deleted file mode 100644 index ef95807..0000000 --- a/Tests/FeatherMailTests/FeatherMailTests.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// FeatherMailTests.swift -// feather-mail -// -// Created by Tibor Bodecs on 2023. 01. 16.. -// - -import Testing -import FeatherMail -@testable import FeatherMailTesting - -final class FeatherMailTests { - - @Test - func testNormal() async throws { - - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) - - try await mailTestSuite.testAll(from: "from@from.from", to: "to@to.to") - } - - @Test - func testFromError() async throws { - - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) - - do { - try await mailTestSuite.testAll( - from: "", - to: "to@to.com" - ) - } - catch let error { - #expect(error == .invalidSender) - } - } - - @Test - func testToError() async throws { - - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) - - do { - try await mailTestSuite.testAll( - from: "from@from.from", - to: "" - ) - } - catch let error { - #expect(error == .invalidRecipient) - } - } - - @Test - func testSubjectError() async throws { - - let mail = MailStruct() - let mailTestSuite = MailTestSuite(mail) - - do { - try await mailTestSuite.testAll( - from: "from@from.from", - to: "to@to.com", - subject: "" - ) - } - catch let error { - #expect(error == .invalidSubject) - } - } -} 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/RawMailEncoderTests.swift b/Tests/FeatherMailTests/RawMailEncoderTests.swift new file mode 100644 index 0000000..868dff2 --- /dev/null +++ b/Tests/FeatherMailTests/RawMailEncoderTests.swift @@ -0,0 +1,159 @@ +// +// 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[..