Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ on:

jobs:

api_breakage:
name: Check API breakage
uses: BinaryBirds/github-workflows/.github/workflows/api_breakage.yml@main

swiftlang_checks:
name: Swiftlang Checks
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main
Expand All @@ -27,13 +31,7 @@ jobs:
uses: BinaryBirds/github-workflows/.github/workflows/extra_soundness.yml@main
with:
local_swift_dependencies_check_enabled : true
run_tests_with_cache_enabled : true
headers_check_enabled : false
docc_warnings_check_enabled : true

swiftlang_tests:
name: Swiftlang Tests
uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main
with:
enable_windows_checks : false
linux_build_command: "swift test --parallel --enable-code-coverage"
linux_exclude_swift_versions: "[{\"swift_version\": \"5.8\"}, {\"swift_version\": \"5.9\"}, {\"swift_version\": \"5.10\"}, {\"swift_version\": \"nightly\"}, {\"swift_version\": \"nightly-main\"}, {\"swift_version\": \"6.0\"}, {\"swift_version\": \"nightly-6.0\"}, {\"swift_version\": \"nightly-6.1\"}, {\"swift_version\": \"nightly-6.3\"}]"
run_tests_swift_versions: '["6.1","6.2"]'
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ baseUrl = https://raw.githubusercontent.com/BinaryBirds/github-workflows/refs/he

check: symlinks language deps lint headers docc-warnings package

breakage:
curl -s $(baseUrl)/check-api-breakage.sh | bash

package:
curl -s $(baseUrl)/check-swift-package.sh | bash

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

An abstract mail component for Feather CMS.

[![Release: 1.0.0-beta.1](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E1-F05138)](
https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.1
[![Release: 1.0.0-beta.2](https://img.shields.io/badge/Release-1%2E0%2E0--beta%2E2-F05138)](
https://github.com/feather-framework/feather-mail/releases/tag/1.0.0-beta.2
)

## Features
Expand All @@ -16,10 +16,11 @@ An abstract mail component for Feather CMS.
## Requirements

![Swift 6.1+](https://img.shields.io/badge/Swift-6%2E1%2B-F05138)
![Platforms: macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138)
![Platforms: Linux, macOS, iOS, tvOS, watchOS, visionOS](https://img.shields.io/badge/Platforms-Linux_%7C_macOS_%7C_iOS_%7C_tvOS_%7C_watchOS_%7C_visionOS-F05138)

- Swift 6.1+
- Platforms:
- Linux
- macOS 15+
- iOS 18+
- tvOS 18+
Expand All @@ -31,7 +32,7 @@ An abstract mail component for Feather CMS.
Use Swift Package Manager; add the dependency to your `Package.swift` file:

```swift
.package(url: "https://github.com/feather-framework/feather-mail", exact: "1.0.0-beta.1"),
.package(url: "https://github.com/feather-framework/feather-mail", exact: "1.0.0-beta.2"),
```

Then add `FeatherMail` to your target dependencies:
Expand Down
54 changes: 54 additions & 0 deletions Sources/FeatherMail/Encoder/Base64+UInt8.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// Base64+UInt8.swift
// feather-mail
//
// Created by Binary Birds on 2026. 02. 06..
//

// Foundation-free Base64 encoding for byte arrays.
public extension Array where Element == UInt8 {
/// Returns a Base64 string representation of the byte array.
func base64EncodedString() -> String {
guard !isEmpty else {
return ""
}

let alphabet = Array(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
.utf8
)
var output: [UInt8] = []
output.reserveCapacity(((count + 2) / 3) * 4)

var index = 0
while index < count {
let byte0 = self[index]
let byte1 = (index + 1 < count) ? self[index + 1] : 0
let byte2 = (index + 2 < count) ? self[index + 2] : 0

let triple =
(UInt32(byte0) << 16) | (UInt32(byte1) << 8) | UInt32(byte2)

output.append(alphabet[Int((triple >> 18) & 0x3F)])
output.append(alphabet[Int((triple >> 12) & 0x3F)])

if index + 1 < count {
output.append(alphabet[Int((triple >> 6) & 0x3F)])
}
else {
output.append(UInt8(ascii: "="))
}

if index + 2 < count {
output.append(alphabet[Int(triple & 0x3F)])
}
else {
output.append(UInt8(ascii: "="))
}

index += 3
}

return String(decoding: output, as: UTF8.self)
}
}
20 changes: 20 additions & 0 deletions Sources/FeatherMail/Encoder/MailEncoder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// MailEncoder.swift
// feather-mail
//
// Created by Binary Birds on 2026. 02. 06.
//

/// Encodes `Mail` values into raw MIME messages suitable for transport providers.
public protocol MailEncoder: Sendable {

/// Encodes a mail into a raw MIME message string.
///
/// - Parameter mail: The mail to encode. The mail must be validated
/// before calling this method.
/// - Returns: A raw MIME string suitable for transport providers.
/// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed.
func encode(
mail: Mail
) throws(MailError) -> String
}
119 changes: 45 additions & 74 deletions Sources/FeatherMail/Encoder/RawMailEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,35 +12,59 @@
/// The encoding logic preserves the legacy behavior and output format.
///
/// The encoder:
/// - builds standard email headers (From, To, Cc, Reply-To, Subject, Date)
/// - builds standard email headers (From, To, Cc, Reply-To, Subject, Date, Message-ID)
/// - supports plain text and HTML bodies
/// - supports attachments using `multipart/mixed`
///
/// This type performs **no validation**. Callers are expected to validate
/// the `Mail` instance before encoding.
public struct RawMailEncoder: Sendable {
public struct RawMailEncoder: MailEncoder {

var boundaryEncodingStrategy: (@Sendable (Mail) -> String)
var messageIDEncodingStrategy: (@Sendable (Mail) -> String)
var headerDateEncodingStrategy: (@Sendable () -> String)

/// Creates a raw mail encoder.
public init() {}
///
/// - Parameters:
/// - boundaryEncodingStrategy: Provides the MIME boundary string used
/// when attachments are present.
/// - messageIDEncodingStrategy: Provides the Message-ID header value,
/// including angle brackets.
/// - headerDateEncodingStrategy: Provides the RFC 2822-formatted Date header
/// value, for example `Mon, 26 Jan 2026 12:34:56 +0000`.
public init(
boundaryEncodingStrategy: (@escaping @Sendable (Mail) -> String) = {
_ in
"Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))"
},
messageIDEncodingStrategy: (@escaping @Sendable (Mail) -> String) = {
mail in
let nonce = UInt64.random(in: UInt64.min...UInt64.max)
return "<\(nonce)\(mail.from.email.drop { $0 != "@" })>"
},
headerDateEncodingStrategy: (@escaping @Sendable () -> String)
) {
self.boundaryEncodingStrategy = boundaryEncodingStrategy
self.messageIDEncodingStrategy = messageIDEncodingStrategy
self.headerDateEncodingStrategy = headerDateEncodingStrategy
}

/// Encodes a mail into a raw MIME message string.
///
/// - Parameters:
/// - mail: The mail to encode. The mail must be validated
/// before calling this method.
/// - dateHeader: RFC 2822-formatted date header value.
/// - messageID: Message identifier value, including angle brackets.
/// - Parameter mail: The mail to encode. The mail must be validated
/// before calling this method.
/// - Returns: A raw MIME string suitable for transport providers.
/// - Throws: `MailError.validation(.mailEncodeError)` when the message cannot be constructed.
/// - Throws: `MailError.validation(.emptyHeaderDateString)` when the configured Date header value is empty.
public func encode(
_ mail: Mail,
dateHeader: String,
messageID: String
mail: Mail
) throws(MailError) -> String {

var out = String()
out.reserveCapacity(4096)
guard !headerDateEncodingStrategy().isEmpty else {
throw MailError.validation(.emptyHeaderDateString)
}

var out = String()
out += "From: \(mail.from.mime)\r\n"

if !mail.to.isEmpty {
Expand All @@ -57,15 +81,18 @@ public struct RawMailEncoder: Sendable {
}

out += "Subject: \(mail.subject)\r\n"
out += "Date: \(dateHeader)\r\n"
out += "Message-ID: \(messageID)\r\n"
out += "Date: \(headerDateEncodingStrategy())\r\n"
out += "Message-ID: \(messageIDEncodingStrategy(mail))\r\n"

if let reference = mail.reference {
out += "In-Reply-To: \(reference)\r\n"
out += "References: \(reference)\r\n"
}

let boundary = mail.attachments.isEmpty ? nil : createBoundary()
let boundary =
mail.attachments.isEmpty
? nil
: (boundaryEncodingStrategy(mail))

if let boundary {
out += "Content-type: multipart/mixed; boundary=\"\(boundary)\"\r\n"
Expand Down Expand Up @@ -103,68 +130,12 @@ public struct RawMailEncoder: Sendable {
out += attachment.data.base64EncodedString()
out += "\r\n"
}
out += "--\(boundary)--\r\n"
}

out += "\r\n"

return out
}
}

// MARK: - Helpers

private extension RawMailEncoder {

/// Creates a unique MIME boundary without Foundation.
func createBoundary() -> String {
"Boundary-\(String(UInt64.random(in: UInt64.min...UInt64.max), radix: 16))"
}
}

// Foundation-free Base64 encoding for byte arrays.
private extension Array where Element == UInt8 {
/// Returns a Base64 string representation of the byte array.
func base64EncodedString() -> String {
guard !isEmpty else {
return ""
}

let alphabet = Array(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
.utf8
)
var output: [UInt8] = []
output.reserveCapacity(((count + 2) / 3) * 4)

var index = 0
while index < count {
let byte0 = self[index]
let byte1 = (index + 1 < count) ? self[index + 1] : 0
let byte2 = (index + 2 < count) ? self[index + 2] : 0

let triple =
(UInt32(byte0) << 16) | (UInt32(byte1) << 8) | UInt32(byte2)

output.append(alphabet[Int((triple >> 18) & 0x3F)])
output.append(alphabet[Int((triple >> 12) & 0x3F)])

if index + 1 < count {
output.append(alphabet[Int((triple >> 6) & 0x3F)])
}
else {
output.append(UInt8(ascii: "="))
}

if index + 2 < count {
output.append(alphabet[Int(triple & 0x3F)])
}
else {
output.append(UInt8(ascii: "="))
}

index += 3
}

return String(decoding: output, as: UTF8.self)
}
}
3 changes: 3 additions & 0 deletions Sources/FeatherMail/Models/Error/MailValidationError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public enum MailValidationError: Error, Equatable {

// MARK: - Encoding errors

/// The required Date header value is empty.
case emptyHeaderDateString

/// Failed to encode the mail into the required transport format.
///
/// This can occur when generating raw MIME data for providers like SES.
Expand Down
Loading
Loading