Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
888f73e
Add SwiftLint config, CI workflow, and pre-commit hook
odrobnik May 18, 2026
222a337
Apply SwiftLint --fix tree-wide
odrobnik May 18, 2026
4f14d27
Wrap long lines in 13 single-line_length-violation files
odrobnik May 18, 2026
a1f78a3
Fix 6 single-violation files (non-line_length)
odrobnik May 18, 2026
33afc9a
Indent switch case statements tree-wide
odrobnik May 18, 2026
e5586e4
Wrap function arguments to fit 120-char limit
odrobnik May 18, 2026
eefa44a
Wrap 15 single-line_length files
odrobnik May 18, 2026
6ccd323
Wrap line_length in 3 two-violation files
odrobnik May 18, 2026
4f2afe7
Wrap line_length in 6 three-violation files
odrobnik May 18, 2026
28a8284
Wrap line_length in IMAPConnection, SMTPServer, Email+Demo
odrobnik May 18, 2026
a305756
Wrap 27 line_length violations in IMAPServer.swift
odrobnik May 18, 2026
67bc135
Fix 9 force_try violations
odrobnik May 18, 2026
13ddead
Fix 16 non_optional_string_data_conversion violations
odrobnik May 18, 2026
1196f42
Document 4 lossy optional_data_string_conversion sites
odrobnik May 18, 2026
0054a20
Fix 3 function_parameter_count violations
odrobnik May 18, 2026
5c49f29
Rename 9 short identifiers in IMAPTestServer.swift
odrobnik May 18, 2026
249ab0b
Rename 8 short identifiers in PipelinedFetchTests.swift
odrobnik May 18, 2026
bfb4215
Rename a/b to lhs/rhs in MessageIDTests equality/hash tests
odrobnik May 18, 2026
5036025
Rename short identifiers in SearchCriteria.swift
odrobnik May 18, 2026
1c6ef8c
Rename ~30 short identifiers across 16 files
odrobnik May 18, 2026
7f7669d
Clean up 10 small remaining violations
odrobnik May 18, 2026
add6841
Split IMAPServer.swift: extract IDLE methods to IMAPServer+Idle.swift
odrobnik May 18, 2026
7b9dd28
Extract IMAPResilientIdleRunner from IMAPServer+Idle.swift
odrobnik May 18, 2026
35629e6
Split IMAPServer.swift across 13 feature-grouped extension files
odrobnik May 18, 2026
247d183
Split IMAPConnection.swift across 7 extension files
odrobnik May 18, 2026
1f09637
Split SMTPServer.swift across 4 extension files
odrobnik May 18, 2026
1e856a6
Split String+MIME.swift dictionary tables across 6 files
odrobnik May 18, 2026
96f7732
Split EMLParser, IMAPNamedConnection, String+QuotedPrintable
odrobnik May 18, 2026
31635b6
Split 5 oversized test files
odrobnik May 18, 2026
7af8a2d
Split Demos/SwiftIMAPCLI/main.swift across Commands subcommand files
odrobnik May 18, 2026
eac2367
Disable structural rules at the function level + final FetchMessageIn…
odrobnik May 18, 2026
edccc87
Revert IMAPTestServer split: it caused a macOS CI hang
odrobnik May 18, 2026
5e301ff
Revert remaining 4 test-file splits: macOS CI still hangs
odrobnik May 18, 2026
997d835
Remove 8 swiftlint exceptions via real refactors
odrobnik May 18, 2026
7aa87ea
Remove 6 more swiftlint exceptions via real refactors
odrobnik May 18, 2026
8c1d1e1
Remove 7 more exceptions via response-handler refactors
odrobnik May 18, 2026
feacbd3
Refactor 3 more disables: model dispatchers + MIME assembler split
odrobnik May 18, 2026
d8bb0a1
Group ESEARCH + SearchCriteria switches into per-category helpers
odrobnik May 18, 2026
e3b5672
Split charset detection + extract test FakeServer to file scope
odrobnik May 18, 2026
d3f6a67
Decompose Array<MessagePart>(from: BodyStructure) into focused helpers
odrobnik May 18, 2026
2f6cfc3
Consolidate lossy UTF-8 decoding + replace 3-tuple test helper return
odrobnik May 18, 2026
cc8ee78
Refactor MessageIdentifierSet bridge + extract demo HTML helper
odrobnik May 18, 2026
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
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
root = true

[*.swift]
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
18 changes: 18 additions & 0 deletions .github/workflows/swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
name: SwiftLint
on:
pull_request:
paths:
- '.github/workflows/swiftlint.yml'
- '.swiftlint.yml'
- '**/*.swift'
push:
branches: [main]

jobs:
lint:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- run: brew install swiftlint
- run: swiftlint lint --strict
36 changes: 36 additions & 0 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
included:
- Sources
- Tests
- Demos

excluded:
- .build
- .swiftpm
- "**/.build"
- "**/.swiftpm"
- Sources/SwiftMail/SwiftMail.docc

# 4-space indentation is enforced by `.editorconfig` (read by Xcode and most
# editors) and by running `swiftformat --indent 4` locally before committing.
# SwiftLint's `indentation_width` rule is not enabled — it rejects the standard
# multi-line condition continuation style (alignment with first condition past
# `if `/`guard `), which is widely used and not worth reformatting away.

# Match Xcode's default "Indent switch statement `case` labels in" setting —
# cases are indented one level past the `switch` keyword, body two levels.
switch_case_alignment:
indented_cases: true

identifier_name:
# Domain-specific names allowed because renaming would either break public
# API or lose protocol-level meaning:
# - `to` / `cc`: RFC 5322 header field names on Message / MessageInfo / SearchCriteria
# - `os`: RFC 2971 IMAP ID parameter name on Identification
# - `on` / `or`: RFC 3501 IMAP SEARCH keyword enum cases on SearchCriteria
excluded:
- id
- to
- cc
- os
- on
- or
105 changes: 105 additions & 0 deletions Demos/SwiftIMAPCLI/Commands/DownloadAttachment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import Foundation
import ArgumentParser
import SwiftMail

struct DownloadAttachment: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "attachment",
abstract: "Download attachments for a message UID"
)

@Argument(help: "UID(s) of the message (comma-separated; ranges like 1-3 allowed)")
var uid: String

@Option(name: .shortAndLong, help: "Mailbox")
var mailbox: String = "INBOX"

@Option(help: "Attachment file extension to match (repeatable, e.g. pdf, docx)")
var attachment: [String] = []

@Option(help: "Output directory")
var out: String = "."

private func attachmentExtensions() -> Set<String> {
Set(
attachment
.map { $0.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.map { $0.hasPrefix(".") ? String($0.dropFirst()) : $0 }
)
}

func run() throws {
runAsyncBlock {
try await withServer { server in
print("Selecting mailbox \(mailbox)...")
_ = try await server.selectMailbox(mailbox)
print("Mailbox selected.")

guard let uids = MessageIdentifierSet<UID>(string: uid) else {
throw ValidationError("Invalid UID list: \(uid)")
}
let outputURL = URL(fileURLWithPath: out, isDirectory: true)
try FileManager.default.createDirectory(
at: outputURL,
withIntermediateDirectories: true,
attributes: nil
)
print("Output directory: \(outputURL.path)")

let attachmentExts = attachmentExtensions()
print("Fetching message UID(s) \(uid)...")
let found = try await downloadAll(
server: server,
uids: uids,
attachmentExts: attachmentExts,
outputURL: outputURL
)

if !found {
print("Message UID(s) \(uid) not found.")
}
}
}
}

private func downloadAll(
server: IMAPServer,
uids: MessageIdentifierSet<UID>,
attachmentExts: Set<String>,
outputURL: URL
) async throws -> Bool {
var found = false
for try await message in server.fetchMessages(using: uids) {
found = true
let parts = Self.filterAttachments(in: message, extensions: attachmentExts)
if parts.isEmpty {
print("No matching attachments found for UID \(message.uid?.value ?? 0).")
return found
}
try Self.saveAttachments(parts, to: outputURL)
}
return found
}

private static func filterAttachments(in message: Message, extensions: Set<String>) -> [MessagePart] {
guard !extensions.isEmpty else { return message.attachments }
return message.attachments.filter { part in
guard let filename = part.filename?.lowercased() else { return false }
return extensions.contains(where: { filename.hasSuffix(".\($0)") })
}
}

private static func saveAttachments(_ parts: [MessagePart], to outputURL: URL) throws {
for part in parts {
let filename = part.suggestedFilename
let destination = outputURL.appendingPathComponent(filename)
print("Saving \(filename)...")
guard let data = part.decodedData() ?? part.data else {
throw ValidationError("Attachment data missing for \(filename)")
}
try data.write(to: destination)
print("Saved \(filename) to \(destination.path)")
}
}
}
157 changes: 157 additions & 0 deletions Demos/SwiftIMAPCLI/Commands/Fetch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import Foundation
import ArgumentParser
import SwiftMail

struct Fetch: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "Fetch a specific email by UID")

@Argument(help: "UID(s) of the message (comma-separated; ranges like 1-3 allowed)")
var uid: String

@Option(name: .shortAndLong, help: "Mailbox")
var mailbox: String = "INBOX"

@ArgumentParser.Flag(help: "Download raw RFC 822 message as .eml file")
var eml: Bool = false

@Option(help: "Output directory (saves .eml with --eml, or .txt/.html without)")
var out: String?

func run() throws {
runAsyncBlock {
try await withServer { server in
print("Selecting mailbox \(mailbox)...")
_ = try await server.selectMailbox(mailbox)
print("Mailbox selected.")

guard let uids = MessageIdentifierSet<UID>(string: uid) else {
throw ValidationError("Invalid UID list: \(uid)")
}

let outputURL = try Self.prepareOutputURL(out: out)
var found = false
for try await message in server.fetchMessages(using: uids) {
found = true
try await handle(message: message, server: server, outputURL: outputURL)
}

if !found {
print("Message UID \(uid) not found.")
}
}
}
}

private func handle(
message: Message,
server: IMAPServer,
outputURL: URL?
) async throws {
guard let msgUID = message.uid else { return }
let safeSubject = Self.safeSubject(for: message)

if eml {
try await Self.saveRawEml(
server: server,
msgUID: msgUID,
safeSubject: safeSubject,
outputURL: outputURL
)
} else if let outputURL {
try Self.saveParsedContent(
message: message,
msgUID: msgUID,
safeSubject: safeSubject,
outputURL: outputURL
)
} else {
Self.printMessage(message: message, uid: uid)
}
}

private static func prepareOutputURL(out: String?) throws -> URL? {
guard let out else { return nil }
let outputURL = URL(fileURLWithPath: out, isDirectory: true)
try FileManager.default.createDirectory(
at: outputURL,
withIntermediateDirectories: true,
attributes: nil
)
return outputURL
}

private static func safeSubject(for message: Message) -> String? {
message.subject.map {
String($0
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: "\\", with: "-")
.prefix(80))
}
}

private static func saveRawEml(
server: IMAPServer,
msgUID: UID,
safeSubject: String?,
outputURL: URL?
) async throws {
let data = try await server.fetchRawMessage(identifier: msgUID)
let filename = safeSubject.map { "\(msgUID.value)-\($0).eml" } ?? "message-\(msgUID.value).eml"
let destination = (outputURL ?? URL(fileURLWithPath: ".")).appendingPathComponent(filename)
try data.write(to: destination)
print("Saved \(destination.path) (\(data.count) bytes)")
}

private static func saveParsedContent(
message: Message,
msgUID: UID,
safeSubject: String?,
outputURL: URL
) throws {
var content = ""
content += "From: \(message.from ?? "")\n"
content += "To: \(message.to.joined(separator: ", "))\n"
content += "Subject: \(message.subject ?? "")\n"
content += "Date: \(message.date?.description ?? "")\n\n"

let ext: String
if let text = message.textBody {
content += text
ext = "txt"
} else if let html = message.htmlBody {
content += html
ext = "html"
} else {
content += "(No body)"
ext = "txt"
}

let filename = safeSubject.map { "\(msgUID.value)-\($0).\(ext)" }
?? "message-\(msgUID.value).\(ext)"
let destination = outputURL.appendingPathComponent(filename)
try content.write(to: destination, atomically: true, encoding: .utf8)
print("Saved \(destination.path)")
}

private static func printMessage(message: Message, uid: String) {
print("--- Message \(uid) ---")
print("From: \(message.from ?? "")")
print("Subject: \(message.subject ?? "")")
print("Date: \(message.date?.description ?? "")")
print("\nBody:")
if let text = message.textBody {
print(text)
} else if let html = message.htmlBody {
print("(HTML Body)\n")
print(html)
}

if !message.attachments.isEmpty {
print("\nAttachments: \(message.attachments.count)")
for part in message.attachments {
print("- \(part.filename ?? "unnamed") (\(part.contentType))")
}
}
}
}
22 changes: 22 additions & 0 deletions Demos/SwiftIMAPCLI/Commands/Folders.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import ArgumentParser
import SwiftMail

struct Folders: ParsableCommand {
static let configuration = CommandConfiguration(abstract: "List all mailboxes")

func run() throws {
runAsyncBlock {
try await withServer { server in
let special = try await server.listSpecialUseMailboxes()
print("📂 Special Folders:")
if let inbox = special.inbox { print(" - INBOX: \(inbox.name)") }
if let drafts = special.drafts { print(" - Drafts: \(drafts.name)") }
if let sent = special.sent { print(" - Sent: \(sent.name)") }
if let trash = special.trash { print(" - Trash: \(trash.name)") }
if let junk = special.junk { print(" - Junk: \(junk.name)") }
if let archive = special.archive { print(" - Archive: \(archive.name)") }
}
}
}
}
Loading