- move backend/, clients/, scripts/ to DELETE/ (v0.1 era, replaced by on-device arch) - delete feature/v0.1-backend-and-macos branch - add TaskStore dependency to project.yml - fix ComposeViewModel deinit concurrency, make toMessageSummary public - regenerate Xcode project, verify macOS build succeeds Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
93 KiB
Magnum Opus v0.3 — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn the read-only v0.2 email client into a fully functional email client. Add SMTP sending (compose, reply, forward), IMAP write-back (flags, move, delete, append), and an offline-safe action queue. Plain text compose only.
Builds on: v0.2 native Swift email client (IMAP sync, GRDB/SQLite, SwiftUI three-column UI, threaded messages, FTS5 search).
Tech Stack: Swift 6 (strict concurrency), SwiftUI, GRDB.swift, swift-nio + swift-nio-ssl (SMTP), swift-nio-imap (IMAP), Keychain Services
Design Document: docs/plans/2026-03-13-v0.3-compose-triage-design.md
Branch: feature/v0.3-compose-triage from main.
File Structure (changes from v0.2)
MagnumOpus/
├── Packages/
│ └── MagnumOpusCore/
│ ├── Package.swift ← ADD SMTPClient target
│ ├── Sources/
│ │ ├── Models/
│ │ │ ├── AccountConfig.swift ← EDIT: add smtpHost/smtpPort/smtpSecurity
│ │ │ ├── OutgoingMessage.swift ← NEW
│ │ │ └── SMTPSecurity.swift ← NEW
│ │ │
│ │ ├── SMTPClient/ ← NEW module
│ │ │ ├── SMTPClient.swift ← actor: public send/testConnection API
│ │ │ ├── SMTPConnection.swift ← NIO bootstrap + TLS
│ │ │ ├── SMTPResponseHandler.swift ← ChannelInboundHandler
│ │ │ ├── SMTPCommandRunner.swift ← sequential command execution
│ │ │ ├── SMTPError.swift ← error types
│ │ │ └── MessageFormatter.swift ← RFC 5322 message builder
│ │ │
│ │ ├── IMAPClient/
│ │ │ ├── IMAPClientProtocol.swift ← EDIT: add write methods
│ │ │ └── IMAPClient.swift ← EDIT: implement write methods
│ │ │
│ │ ├── MailStore/
│ │ │ ├── DatabaseSetup.swift ← EDIT: add v2 migrations
│ │ │ ├── MailStore.swift ← EDIT: add draft/action/role queries
│ │ │ ├── Records/
│ │ │ │ ├── MailboxRecord.swift ← EDIT: add role field
│ │ │ │ ├── AccountRecord.swift ← EDIT: add SMTP fields
│ │ │ │ ├── DraftRecord.swift ← NEW
│ │ │ │ └── PendingActionRecord.swift ← NEW
│ │ │ └── Queries.swift ← EDIT: add draft/action queries
│ │ │
│ │ └── SyncEngine/
│ │ ├── SyncCoordinator.swift ← EDIT: flush queue before sync
│ │ └── ActionQueue.swift ← NEW: offline action dispatcher
│ │
│ └── Tests/
│ ├── SMTPClientTests/ ← NEW
│ │ ├── MockSMTPServer.swift
│ │ ├── SMTPConnectionTests.swift
│ │ └── MessageFormatterTests.swift
│ ├── IMAPClientTests/
│ │ └── IMAPWriteTests.swift ← NEW
│ ├── MailStoreTests/
│ │ └── MigrationTests.swift ← NEW
│ └── SyncEngineTests/
│ ├── ActionQueueTests.swift ← NEW
│ └── MockIMAPClient.swift ← EDIT: add write methods
│
├── Apps/
│ └── MagnumOpus/
│ ├── Services/
│ │ └── AutoDiscovery.swift ← EDIT: add SMTP discovery
│ ├── ViewModels/
│ │ ├── MailViewModel.swift ← EDIT: add triage actions
│ │ └── ComposeViewModel.swift ← NEW
│ └── Views/
│ ├── ThreadListView.swift ← EDIT: add triage toolbar + swipes
│ ├── ThreadDetailView.swift ← EDIT: add reply/forward buttons
│ ├── ComposeView.swift ← NEW
│ ├── MoveToSheet.swift ← NEW
│ └── AccountSetupView.swift ← EDIT: add SMTP fields
Dependency graph: SyncEngine → IMAPClient + MailStore + SMTPClient → Models. App targets import all five.
Chunk 1: Schema & Models
Extend the data layer for SMTP, drafts, mailbox roles, and the action queue. No networking — pure schema and types.
Task 1: New Model Types
Files:
-
Create:
Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift -
Edit:
Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift -
Step 1: Create SMTPSecurity enum
Create Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift:
public enum SMTPSecurity: String, Sendable, Codable {
case ssl // implicit TLS, port 465
case starttls // upgrade after connect, port 587
}
- Step 2: Create OutgoingMessage
Create Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift:
public struct OutgoingMessage: Sendable, Codable, Equatable {
public var from: EmailAddress
public var to: [EmailAddress]
public var cc: [EmailAddress]
public var bcc: [EmailAddress]
public var subject: String
public var bodyText: String
public var inReplyTo: String?
public var references: String?
public var messageId: String
public init(
from: EmailAddress,
to: [EmailAddress],
cc: [EmailAddress] = [],
bcc: [EmailAddress] = [],
subject: String,
bodyText: String,
inReplyTo: String? = nil,
references: String? = nil,
messageId: String
) {
self.from = from
self.to = to
self.cc = cc
self.bcc = bcc
self.subject = subject
self.bodyText = bodyText
self.inReplyTo = inReplyTo
self.references = references
self.messageId = messageId
}
}
- Step 3: Add SMTP fields to AccountConfig
Edit Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift — add optional SMTP fields:
public struct AccountConfig: Sendable, Codable, Equatable {
public var id: String
public var name: String
public var email: String
public var imapHost: String
public var imapPort: Int
public var smtpHost: String?
public var smtpPort: Int?
public var smtpSecurity: SMTPSecurity?
public init(
id: String,
name: String,
email: String,
imapHost: String,
imapPort: Int,
smtpHost: String? = nil,
smtpPort: Int? = nil,
smtpSecurity: SMTPSecurity? = nil
) {
self.id = id
self.name = name
self.email = email
self.imapHost = imapHost
self.imapPort = imapPort
self.smtpHost = smtpHost
self.smtpPort = smtpPort
self.smtpSecurity = smtpSecurity
}
}
- Step 4: Verify Models compile
cd Packages/MagnumOpusCore && swift build --target Models
- Step 5: Commit
git add -A && git commit -m "add SMTP model types: SMTPSecurity, OutgoingMessage, extend AccountConfig"
Task 2: Database Migrations
Files:
-
Edit:
Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift -
Edit:
Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift -
Edit:
Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift -
Step 1: Add SMTP fields to AccountRecord
Edit Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift:
import GRDB
public struct AccountRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "account"
public var id: String
public var name: String
public var email: String
public var imapHost: String
public var imapPort: Int
public var smtpHost: String?
public var smtpPort: Int?
public var smtpSecurity: String?
public init(
id: String,
name: String,
email: String,
imapHost: String,
imapPort: Int,
smtpHost: String? = nil,
smtpPort: Int? = nil,
smtpSecurity: String? = nil
) {
self.id = id
self.name = name
self.email = email
self.imapHost = imapHost
self.imapPort = imapPort
self.smtpHost = smtpHost
self.smtpPort = smtpPort
self.smtpSecurity = smtpSecurity
}
}
- Step 2: Add role field to MailboxRecord
Edit Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift:
import GRDB
public struct MailboxRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "mailbox"
public var id: String
public var accountId: String
public var name: String
public var uidValidity: Int
public var uidNext: Int
public var role: String? // "trash", "archive", "sent", "drafts", "junk", or nil
public init(
id: String,
accountId: String,
name: String,
uidValidity: Int,
uidNext: Int,
role: String? = nil
) {
self.id = id
self.accountId = accountId
self.name = name
self.uidValidity = uidValidity
self.uidNext = uidNext
self.role = role
}
}
- Step 3: Create DraftRecord
Create Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift:
import GRDB
public struct DraftRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "draft"
public var id: String
public var accountId: String
public var inReplyTo: String?
public var forwardOf: String?
public var toAddresses: String? // JSON array of {"name", "address"}
public var ccAddresses: String?
public var bccAddresses: String?
public var subject: String?
public var bodyText: String?
public var createdAt: String
public var updatedAt: String
public init(
id: String,
accountId: String,
inReplyTo: String? = nil,
forwardOf: String? = nil,
toAddresses: String? = nil,
ccAddresses: String? = nil,
bccAddresses: String? = nil,
subject: String? = nil,
bodyText: String? = nil,
createdAt: String,
updatedAt: String
) {
self.id = id
self.accountId = accountId
self.inReplyTo = inReplyTo
self.forwardOf = forwardOf
self.toAddresses = toAddresses
self.ccAddresses = ccAddresses
self.bccAddresses = bccAddresses
self.subject = subject
self.bodyText = bodyText
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}
- Step 4: Create PendingActionRecord
Create Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift:
import GRDB
public struct PendingActionRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "pendingAction"
public var id: String
public var accountId: String
public var actionType: String // "setFlags", "move", "delete", "send", "append"
public var payload: String // JSON with action-specific data
public var createdAt: String
public var retryCount: Int
public var lastError: String?
public init(
id: String,
accountId: String,
actionType: String,
payload: String,
createdAt: String,
retryCount: Int = 0,
lastError: String? = nil
) {
self.id = id
self.accountId = accountId
self.actionType = actionType
self.payload = payload
self.createdAt = createdAt
self.retryCount = retryCount
self.lastError = lastError
}
}
- Step 5: Add v2 migrations to DatabaseSetup
Edit Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift — add four new migrations after the existing v1_fts5:
migrator.registerMigration("v2_smtp") { db in
try db.alter(table: "account") { t in
t.add(column: "smtpHost", .text)
t.add(column: "smtpPort", .integer)
t.add(column: "smtpSecurity", .text)
}
}
migrator.registerMigration("v2_mailboxRole") { db in
try db.alter(table: "mailbox") { t in
t.add(column: "role", .text)
}
}
migrator.registerMigration("v2_draft") { db in
try db.create(table: "draft") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.column("inReplyTo", .text)
t.column("forwardOf", .text)
t.column("toAddresses", .text)
t.column("ccAddresses", .text)
t.column("bccAddresses", .text)
t.column("subject", .text)
t.column("bodyText", .text)
t.column("createdAt", .text).notNull()
t.column("updatedAt", .text).notNull()
}
}
migrator.registerMigration("v2_pendingAction") { db in
try db.create(table: "pendingAction") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.column("actionType", .text).notNull()
t.column("payload", .text).notNull()
t.column("createdAt", .text).notNull()
t.column("retryCount", .integer).notNull().defaults(to: 0)
t.column("lastError", .text)
}
try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"])
}
- Step 6: Add migration tests
Create Packages/MagnumOpusCore/Tests/MailStoreTests/MigrationTests.swift:
import Testing
@testable import MailStore
@Suite("Database Migrations")
struct MigrationTests {
@Test("v2 migrations create expected tables and columns")
func v2Migrations() throws {
let db = try DatabaseSetup.openInMemoryDatabase()
// Verify account has SMTP columns
try db.read { db in
let columns = try db.columns(in: "account").map(\.name)
#expect(columns.contains("smtpHost"))
#expect(columns.contains("smtpPort"))
#expect(columns.contains("smtpSecurity"))
}
// Verify mailbox has role column
try db.read { db in
let columns = try db.columns(in: "mailbox").map(\.name)
#expect(columns.contains("role"))
}
// Verify draft table exists
try db.read { db in
let tables = try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table'")
#expect(tables.contains("draft"))
#expect(tables.contains("pendingAction"))
}
}
@Test("DraftRecord round-trip")
func draftRoundTrip() throws {
let db = try DatabaseSetup.openInMemoryDatabase()
let now = "2026-03-14T10:00:00Z"
let draft = DraftRecord(
id: "d1",
accountId: "a1",
toAddresses: "[{\"address\":\"test@example.com\"}]",
subject: "Test",
bodyText: "Hello",
createdAt: now,
updatedAt: now
)
// Need account first (FK constraint)
try db.write { db in
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
try draft.insert(db)
}
let loaded = try db.read { db in
try DraftRecord.fetchOne(db, key: "d1")
}
#expect(loaded?.subject == "Test")
}
@Test("PendingActionRecord round-trip")
func actionRoundTrip() throws {
let db = try DatabaseSetup.openInMemoryDatabase()
try db.write { db in
try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
try PendingActionRecord(
id: "pa1",
accountId: "a1",
actionType: "setFlags",
payload: "{\"setFlags\":{\"uid\":42,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Seen\"],\"remove\":[]}}",
createdAt: "2026-03-14T10:00:00Z"
).insert(db)
}
let loaded = try db.read { db in
try PendingActionRecord.fetchOne(db, key: "pa1")
}
#expect(loaded?.actionType == "setFlags")
#expect(loaded?.retryCount == 0)
}
}
- Step 7: Verify MailStore compiles and migration tests pass
cd Packages/MagnumOpusCore && swift test --filter MigrationTests
- Step 8: Commit
git add -A && git commit -m "add v2 schema migrations: smtp fields, mailbox role, draft, pendingAction"
Task 3: MailStore Query Extensions
Files:
-
Edit:
Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift -
Edit:
Packages/MagnumOpusCore/Sources/MailStore/Queries.swift -
Step 1: Add draft CRUD to MailStore
Add to MailStore.swift:
// MARK: - Drafts
public func insertDraft(_ draft: DraftRecord) throws {
try dbWriter.write { db in
try draft.insert(db)
}
}
public func updateDraft(_ draft: DraftRecord) throws {
try dbWriter.write { db in
try draft.update(db)
}
}
public func deleteDraft(id: String) throws {
_ = try dbWriter.write { db in
try DraftRecord.deleteOne(db, key: id)
}
}
public func draft(id: String) throws -> DraftRecord? {
try dbWriter.read { db in
try DraftRecord.fetchOne(db, key: id)
}
}
public func drafts(accountId: String) throws -> [DraftRecord] {
try dbWriter.read { db in
try DraftRecord
.filter(Column("accountId") == accountId)
.order(Column("updatedAt").desc)
.fetchAll(db)
}
}
- Step 2: Add pending action CRUD to MailStore
Add to MailStore.swift:
// MARK: - Pending Actions
public func insertPendingAction(_ action: PendingActionRecord) throws {
try dbWriter.write { db in
try action.insert(db)
}
}
public func insertPendingActions(_ actions: [PendingActionRecord]) throws {
try dbWriter.write { db in
for action in actions {
try action.insert(db)
}
}
}
public func pendingActions(accountId: String) throws -> [PendingActionRecord] {
try dbWriter.read { db in
try PendingActionRecord
.filter(Column("accountId") == accountId)
.order(Column("createdAt").asc)
.fetchAll(db)
}
}
public func deletePendingAction(id: String) throws {
_ = try dbWriter.write { db in
try PendingActionRecord.deleteOne(db, key: id)
}
}
public func updatePendingAction(_ action: PendingActionRecord) throws {
try dbWriter.write { db in
try action.update(db)
}
}
public func pendingActionCount(accountId: String) throws -> Int {
try dbWriter.read { db in
try PendingActionRecord
.filter(Column("accountId") == accountId)
.fetchCount(db)
}
}
- Step 3: Add mailbox role queries
Add to MailStore.swift:
// MARK: - Mailbox Roles
public func mailboxWithRole(_ role: String, accountId: String) throws -> MailboxRecord? {
try dbWriter.read { db in
try MailboxRecord
.filter(Column("accountId") == accountId)
.filter(Column("role") == role)
.fetchOne(db)
}
}
public func updateMailboxRole(id: String, role: String?) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE mailbox SET role = ? WHERE id = ?",
arguments: [role, id]
)
}
}
- Step 4: Add message flag/mailbox update methods
Add to MailStore.swift:
// MARK: - Message Mutations
public func updateMessageMailbox(messageId: String, newMailboxId: String) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET mailboxId = ? WHERE id = ?",
arguments: [newMailboxId, messageId]
)
}
}
public func deleteMessage(id: String) throws {
_ = try dbWriter.write { db in
try MessageRecord.deleteOne(db, key: id)
}
}
public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] {
try dbWriter.read { db in
try MessageRecord
.joining(required: MessageRecord.hasOne(ThreadMessageRecord.self, using: ForeignKey(["messageId"])))
.filter(sql: "threadMessage.threadId = ?", arguments: [threadId])
.filter(Column("mailboxId") == mailboxId)
.fetchAll(db)
}
}
Note: The exact GRDB join syntax may need adjustment based on existing association definitions. If MessageRecord doesn't have an association defined, use raw SQL instead:
public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] {
try dbWriter.read { db in
try MessageRecord.fetchAll(db, sql: """
SELECT message.* FROM message
JOIN threadMessage ON threadMessage.messageId = message.id
WHERE threadMessage.threadId = ? AND message.mailboxId = ?
""",
arguments: [threadId, mailboxId]
)
}
}
- Step 5: Verify MailStore compiles and existing tests pass
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests
- Step 6: Commit
git add -A && git commit -m "add MailStore queries: drafts, pending actions, mailbox roles, message mutations"
Chunk 2: IMAP Write Operations
Extend the existing IMAPClient with write capabilities (flags, move, copy, append, expunge, capabilities).
Task 4: IMAPClient Protocol Extension
Files:
-
Edit:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift -
Edit:
Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift -
Step 1: Add write methods to IMAPClientProtocol
Edit Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift:
public protocol IMAPClientProtocol: Sendable {
// existing v0.2 read methods
func connect() async throws
func disconnect() async throws
func listMailboxes() async throws -> [IMAPMailboxInfo]
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope]
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair]
func fetchBody(uid: Int) async throws -> (text: String?, html: String?)
// v0.3 write operations
func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws
func moveMessage(uid: Int, from: String, to: String) async throws
func copyMessage(uid: Int, from: String, to: String) async throws
func expunge(mailbox: String) async throws
func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws
func capabilities() async throws -> Set<String>
}
- Step 2: Update MockIMAPClient with write methods
Edit Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift:
import Foundation
import IMAPClient
import Models
final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
var mailboxes: [IMAPMailboxInfo] = []
var mailboxStatuses: [String: IMAPMailboxStatus] = [:]
var envelopes: [FetchedEnvelope] = []
var mailboxEnvelopes: [String: [FetchedEnvelope]] = [:]
var flagUpdates: [UIDFlagsPair] = []
var bodies: [Int: (text: String?, html: String?)] = [:]
var connectCalled = false
var disconnectCalled = false
var selectedMailbox: String?
// v0.3 tracking
var storedFlags: [(uid: Int, mailbox: String, add: [String], remove: [String])] = []
var movedMessages: [(uid: Int, from: String, to: String)] = []
var copiedMessages: [(uid: Int, from: String, to: String)] = []
var expungedMailboxes: [String] = []
var appendedMessages: [(mailbox: String, message: Data, flags: [String])] = []
var serverCapabilities: Set<String> = ["IMAP4rev1", "MOVE"]
func connect() async throws { connectCalled = true }
func disconnect() async throws { disconnectCalled = true }
func listMailboxes() async throws -> [IMAPMailboxInfo] { mailboxes }
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
selectedMailbox = name
guard let status = mailboxStatuses[name] else {
throw MockIMAPError.mailboxNotFound(name)
}
return status
}
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
let source: [FetchedEnvelope]
if let mailbox = selectedMailbox, let perMailbox = mailboxEnvelopes[mailbox] {
source = perMailbox
} else {
source = envelopes
}
return source.filter { $0.uid > uid }
}
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
flagUpdates.filter { uids.contains($0.uid) }
}
func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
bodies[uid] ?? (nil, nil)
}
// v0.3 write operations
func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
storedFlags.append((uid: uid, mailbox: mailbox, add: add, remove: remove))
}
func moveMessage(uid: Int, from: String, to: String) async throws {
movedMessages.append((uid: uid, from: from, to: to))
}
func copyMessage(uid: Int, from: String, to: String) async throws {
copiedMessages.append((uid: uid, from: from, to: to))
}
func expunge(mailbox: String) async throws {
expungedMailboxes.append(mailbox)
}
func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
appendedMessages.append((mailbox: mailbox, message: message, flags: flags))
}
func capabilities() async throws -> Set<String> {
serverCapabilities
}
}
enum MockIMAPError: Error {
case mailboxNotFound(String)
}
- Step 3: Verify tests still compile and pass
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
- Step 4: Commit
git add -A && git commit -m "extend IMAPClientProtocol with write operations, update mock"
Task 5: IMAPClient Write Implementation
Files:
-
Edit:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift -
Edit:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift(if needed) -
Step 1: Implement storeFlags
Add to the IMAPClient actor implementation. Uses IMAP UID STORE command:
public func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
try await ensureConnected()
_ = try await runner.selectMailbox(mailbox)
if !add.isEmpty {
let flags = add.joined(separator: " ")
try await runner.sendCommand("UID STORE \(uid) +FLAGS (\(flags))")
}
if !remove.isEmpty {
let flags = remove.joined(separator: " ")
try await runner.sendCommand("UID STORE \(uid) -FLAGS (\(flags))")
}
}
Note: The actual implementation depends on how IMAPCommandRunner works with the swift-nio-imap library. The existing codebase uses NIOIMAP types, so the implementation must use the library's command types rather than raw strings. Read the existing IMAPClient.swift implementation patterns and follow them — this step describes the logic but the exact swift-nio-imap API calls need to match what's already in use.
- Step 2: Implement moveMessage with MOVE/COPY+DELETE fallback
public func moveMessage(uid: Int, from: String, to: String) async throws {
try await ensureConnected()
let caps = try await capabilities()
_ = try await runner.selectMailbox(from)
if caps.contains("MOVE") {
try await runner.sendCommand("UID MOVE \(uid) \(to)")
} else {
try await copyMessage(uid: uid, from: from, to: to)
try await storeFlags(uid: uid, mailbox: from, add: ["\\Deleted"], remove: [])
try await expunge(mailbox: from)
}
}
- Step 3: Implement copyMessage, expunge, appendMessage, capabilities
public func copyMessage(uid: Int, from: String, to: String) async throws {
try await ensureConnected()
_ = try await runner.selectMailbox(from)
try await runner.sendCommand("UID COPY \(uid) \(to)")
}
public func expunge(mailbox: String) async throws {
try await ensureConnected()
_ = try await runner.selectMailbox(mailbox)
try await runner.sendCommand("EXPUNGE")
}
public func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
try await ensureConnected()
let flagStr = flags.isEmpty ? "" : " (\(flags.joined(separator: " ")))"
try await runner.sendAppend(mailbox: mailbox, flags: flagStr, message: message)
}
public func capabilities() async throws -> Set<String> {
try await ensureConnected()
return try await runner.fetchCapabilities()
}
Important: These pseudo-implementations show the logic. The real code must use swift-nio-imap's typed command system (e.g., Command.uidStore, Command.uidMove, Command.uidCopy, Command.append). Read the existing IMAPClient.swift to match the established patterns for sending commands and handling responses.
- Step 4: Verify IMAPClient compiles
cd Packages/MagnumOpusCore && swift build --target IMAPClient
- Step 5: Commit
git add -A && git commit -m "implement IMAP write operations: flags, move, copy, append, expunge"
Task 6: Special Folder Detection
Files:
-
Edit:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift(orIMAPTypes.swift) -
Edit:
Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift -
Step 1: Expose mailbox attributes from listMailboxes
The existing IMAPMailboxInfo has a name and attributes field. Verify that LIST response attributes (like \Trash, \Sent, \Archive, \Drafts, \Junk) are already captured. If IMAPMailboxInfo.attributes doesn't exist yet, add it:
public struct IMAPMailboxInfo: Sendable {
public var name: String
public var attributes: [String] // e.g., ["\\HasNoChildren", "\\Trash"]
public init(name: String, attributes: [String] = []) {
self.name = name
self.attributes = attributes
}
}
- Step 2: Add role detection helper
Add a static helper (could be on MailboxRecord or a free function in SyncEngine):
/// Detect the well-known role from IMAP LIST attributes, with name-based fallback.
func detectMailboxRole(name: String, attributes: [String]) -> String? {
let attrSet = Set(attributes.map { $0.lowercased() })
if attrSet.contains("\\trash") { return "trash" }
if attrSet.contains("\\archive") || attrSet.contains("\\all") { return "archive" }
if attrSet.contains("\\sent") { return "sent" }
if attrSet.contains("\\drafts") { return "drafts" }
if attrSet.contains("\\junk") { return "junk" }
// Name-based fallback
switch name.lowercased() {
case "trash", "deleted messages", "bin": return "trash"
case "archive", "all mail": return "archive"
case "sent", "sent messages", "sent mail": return "sent"
case "drafts": return "drafts"
case "junk", "spam": return "junk"
default: return nil
}
}
- Step 3: Store role during sync
Edit SyncCoordinator.syncMailbox() to pass attributes through and store the detected role when upserting/updating mailbox records:
// In syncMailbox, after creating or finding the mailbox:
let role = detectMailboxRole(name: remoteMailbox.name, attributes: remoteMailbox.attributes)
try store.updateMailboxRole(id: mailboxId, role: role)
- Step 4: Verify sync + role detection works
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
- Step 5: Commit
git add -A && git commit -m "detect special folder roles from LIST attributes with name fallback"
Chunk 3: SMTPClient Module
Build the SMTP sending module from scratch using SwiftNIO + TLS.
Task 7: Package.swift Update
Files:
-
Edit:
Packages/MagnumOpusCore/Package.swift -
Step 1: Add SMTPClient target and test target
Add a new library product, target, and test target:
// In products:
.library(name: "SMTPClient", targets: ["SMTPClient"]),
// In targets:
.target(
name: "SMTPClient",
dependencies: [
"Models",
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
]
),
// In test targets:
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient"]),
Also add "SMTPClient" to the SyncEngine target's dependencies:
.target(
name: "SyncEngine",
dependencies: ["Models", "IMAPClient", "MailStore", "SMTPClient"]
),
Note: SwiftNIO is already a transitive dependency via swift-nio-imap, but for SMTPClient we need it directly. Add swift-nio as an explicit dependency if not already present:
// In dependencies (check if already present — swift-nio-imap pulls it transitively):
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
- Step 2: Create placeholder file
mkdir -p Packages/MagnumOpusCore/Sources/SMTPClient
mkdir -p Packages/MagnumOpusCore/Tests/SMTPClientTests
echo 'enum SMTPClientPlaceholder {}' > Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift
- Step 3: Verify package resolves
cd Packages/MagnumOpusCore && swift package resolve && swift build
- Step 4: Commit
git add -A && git commit -m "add SMTPClient target to Package.swift"
Task 8: SMTP Connection Layer
Files:
-
Create:
Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift -
Create:
Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift -
Create:
Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift -
Create:
Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift -
Delete:
Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift -
Step 1: Create SMTPError
Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift:
public enum SMTPError: Error, Sendable {
case notConnected
case connectionFailed(String)
case authenticationFailed(String)
case recipientRejected(String)
case sendFailed(String)
case unexpectedResponse(code: Int, message: String)
case tlsUpgradeFailed
}
- Step 2: Create SMTPResponseHandler
Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift.
SMTP responses are line-based: <3-digit code><separator><text>. Separator is - for continuation, space for final line. The handler buffers lines and delivers the complete response.
import NIOCore
struct SMTPResponse: Sendable {
var code: Int
var lines: [String]
var isSuccess: Bool { code >= 200 && code < 400 }
var message: String { lines.joined(separator: "\n") }
}
final class SMTPResponseHandler: ChannelInboundHandler, RemovableChannelHandler {
typealias InboundIn = ByteBuffer
typealias InboundOut = SMTPResponse
private var buffer = ""
private var responseLines: [String] = []
private var continuation: CheckedContinuation<SMTPResponse, Error>?
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
var buf = unwrapInboundIn(data)
guard let str = buf.readString(length: buf.readableBytes) else { return }
buffer += str
while let newlineRange = buffer.range(of: "\r\n") {
let line = String(buffer[buffer.startIndex..<newlineRange.lowerBound])
buffer = String(buffer[newlineRange.upperBound...])
processLine(line)
}
}
private func processLine(_ line: String) {
guard line.count >= 3,
let code = Int(line.prefix(3))
else { return }
let separator = line.count > 3 ? line[line.index(line.startIndex, offsetBy: 3)] : " "
let text = line.count > 4 ? String(line.dropFirst(4)) : ""
responseLines.append(text)
if separator == " " {
// Final line — deliver complete response
let response = SMTPResponse(code: code, lines: responseLines)
responseLines = []
continuation?.resume(returning: response)
continuation = nil
}
// separator == "-" means more lines coming
}
func waitForResponse() async throws -> SMTPResponse {
try await withCheckedThrowingContinuation { cont in
self.continuation = cont
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
continuation?.resume(throwing: error)
continuation = nil
context.close(promise: nil)
}
}
Note: This is a simplified sketch. The real implementation needs careful NIO lifecycle management — the waitForResponse() continuation must be set before channel reads arrive. Use the same patterns as IMAPResponseHandler in the existing codebase.
- Step 3: Create SMTPConnection
Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift.
Actor managing the NIO channel. Handles SSL (port 465) and STARTTLS (port 587):
import NIOCore
import NIOPosix
import NIOSSL
import Models
actor SMTPConnection {
private let host: String
private let port: Int
private let security: SMTPSecurity
private var channel: Channel?
private var responseHandler: SMTPResponseHandler?
private let eventLoopGroup: EventLoopGroup
init(host: String, port: Int, security: SMTPSecurity) {
self.host = host
self.port = port
self.security = security
self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
}
func connect() async throws {
let handler = SMTPResponseHandler()
self.responseHandler = handler
var tlsConfig = TLSConfiguration.makeClientConfiguration()
tlsConfig.certificateVerification = .fullVerification
let bootstrap = ClientBootstrap(group: eventLoopGroup)
.channelOption(.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
var handlers: [ChannelHandler] = []
if self.security == .ssl {
let sslContext = try! NIOSSLContext(configuration: tlsConfig)
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: self.host)
handlers.append(sslHandler)
}
handlers.append(ByteToMessageHandler(LineBasedFrameDecoder()))
handlers.append(handler)
return channel.pipeline.addHandlers(handlers)
}
channel = try await bootstrap.connect(host: host, port: port).get()
// Read server greeting
let greeting = try await handler.waitForResponse()
guard greeting.isSuccess else {
throw SMTPError.connectionFailed(greeting.message)
}
}
func sendCommand(_ command: String) async throws -> SMTPResponse {
guard let channel, let handler = responseHandler else {
throw SMTPError.notConnected
}
var buf = channel.allocator.buffer(capacity: command.utf8.count + 2)
buf.writeString(command + "\r\n")
try await channel.writeAndFlush(buf)
return try await handler.waitForResponse()
}
func upgradeToTLS() async throws {
guard let channel else { throw SMTPError.notConnected }
var tlsConfig = TLSConfiguration.makeClientConfiguration()
let sslContext = try NIOSSLContext(configuration: tlsConfig)
let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host)
try await channel.pipeline.addHandler(sslHandler, position: .first)
}
func sendData(_ data: Data) async throws {
guard let channel else { throw SMTPError.notConnected }
var buf = channel.allocator.buffer(capacity: data.count)
buf.writeBytes(data)
try await channel.writeAndFlush(buf)
}
func disconnect() async throws {
try await channel?.close()
channel = nil
}
deinit {
try? eventLoopGroup.syncShutdownGracefully()
}
}
Note: This is a structural sketch. The NIO bootstrap patterns should mirror IMAPConnection.swift from the existing codebase. Pay attention to how the existing code sets up TLS, event loop groups, and channel pipelines.
- Step 4: Create SMTPCommandRunner
Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift.
Orchestrates the SMTP command sequence:
import Foundation
import Models
actor SMTPCommandRunner {
private let connection: SMTPConnection
private let credentials: Credentials
init(connection: SMTPConnection, credentials: Credentials) {
self.connection = connection
self.credentials = credentials
}
func ehlo(hostname: String) async throws -> Set<String> {
let response = try await connection.sendCommand("EHLO \(hostname)")
guard response.isSuccess else {
throw SMTPError.unexpectedResponse(code: response.code, message: response.message)
}
// Parse capabilities from response lines
return Set(response.lines.map { $0.split(separator: " ").first.map(String.init) ?? $0 })
}
func startTLS() async throws {
let response = try await connection.sendCommand("STARTTLS")
guard response.code == 220 else {
throw SMTPError.tlsUpgradeFailed
}
try await connection.upgradeToTLS()
}
func authenticate() async throws {
// Try AUTH PLAIN first
let credentials = "\0\(self.credentials.username)\0\(self.credentials.password)"
let base64 = Data(credentials.utf8).base64EncodedString()
let response = try await connection.sendCommand("AUTH PLAIN \(base64)")
if response.isSuccess { return }
// Fallback: AUTH LOGIN
let loginResponse = try await connection.sendCommand("AUTH LOGIN")
guard loginResponse.code == 334 else {
throw SMTPError.authenticationFailed(response.message)
}
let userResp = try await connection.sendCommand(Data(self.credentials.username.utf8).base64EncodedString())
guard userResp.code == 334 else {
throw SMTPError.authenticationFailed(userResp.message)
}
let passResp = try await connection.sendCommand(Data(self.credentials.password.utf8).base64EncodedString())
guard passResp.isSuccess else {
throw SMTPError.authenticationFailed(passResp.message)
}
}
func mailFrom(_ address: String) async throws {
let response = try await connection.sendCommand("MAIL FROM:<\(address)>")
guard response.isSuccess else {
throw SMTPError.sendFailed("MAIL FROM rejected: \(response.message)")
}
}
func rcptTo(_ address: String) async throws {
let response = try await connection.sendCommand("RCPT TO:<\(address)>")
guard response.isSuccess else {
throw SMTPError.recipientRejected(address)
}
}
func data(_ messageContent: String) async throws {
let response = try await connection.sendCommand("DATA")
guard response.code == 354 else {
throw SMTPError.sendFailed("DATA rejected: \(response.message)")
}
// Send message content, dot-stuffed, ending with \r\n.\r\n
let dotStuffed = messageContent
.split(separator: "\n", omittingEmptySubsequences: false)
.map { line in
let l = String(line)
return l.hasPrefix(".") ? "." + l : l
}
.joined(separator: "\r\n")
let endResponse = try await connection.sendCommand(dotStuffed + "\r\n.")
guard endResponse.isSuccess else {
throw SMTPError.sendFailed("Message rejected: \(endResponse.message)")
}
}
func quit() async throws {
_ = try? await connection.sendCommand("QUIT")
try await connection.disconnect()
}
}
- Step 5: Delete placeholder and verify build
rm Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target SMTPClient
- Step 6: Commit
git add -A && git commit -m "implement SMTP connection layer: connection, response handler, command runner"
Task 9: Message Formatter & SMTPClient Public API
Files:
-
Create:
Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift -
Create:
Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift -
Step 1: Create MessageFormatter
Create Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift.
Builds RFC 5322 formatted messages:
import Foundation
import Models
public enum MessageFormatter {
/// Formats an OutgoingMessage into an RFC 5322 message string
public static func format(_ message: OutgoingMessage) -> String {
var headers: [(String, String)] = []
headers.append(("From", formatAddress(message.from)))
if !message.to.isEmpty {
headers.append(("To", message.to.map(formatAddress).joined(separator: ", ")))
}
if !message.cc.isEmpty {
headers.append(("Cc", message.cc.map(formatAddress).joined(separator: ", ")))
}
// BCC is intentionally omitted from headers
headers.append(("Subject", message.subject))
headers.append(("Date", formatRFC2822Date(Date())))
headers.append(("Message-ID", "<\(message.messageId)>"))
if let inReplyTo = message.inReplyTo {
headers.append(("In-Reply-To", "<\(inReplyTo)>"))
}
if let references = message.references {
headers.append(("References", references))
}
headers.append(("MIME-Version", "1.0"))
headers.append(("Content-Type", "text/plain; charset=utf-8"))
headers.append(("Content-Transfer-Encoding", "quoted-printable"))
var result = headers.map { "\($0.0): \($0.1)" }.joined(separator: "\r\n")
result += "\r\n\r\n"
result += quotedPrintableEncode(message.bodyText)
return result
}
/// Generates a Message-ID: UUID@domain
public static func generateMessageId(domain: String) -> String {
"\(UUID().uuidString.lowercased())@\(domain)"
}
/// Extracts domain from email address
public static func domainFromEmail(_ email: String) -> String {
email.split(separator: "@").last.map(String.init) ?? "localhost"
}
static func formatAddress(_ addr: EmailAddress) -> String {
if let name = addr.name, !name.isEmpty {
return "\"\(name)\" <\(addr.address)>"
}
return addr.address
}
static func formatRFC2822Date(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.string(from: date)
}
static func quotedPrintableEncode(_ text: String) -> String {
var result = ""
let data = Array(text.utf8)
var lineLength = 0
for byte in data {
let char: String
if byte == 0x0A {
// Newline — emit as-is
result += "\r\n"
lineLength = 0
continue
} else if byte == 0x0D {
// CR — skip (we add CRLF for newlines)
continue
} else if (byte >= 33 && byte <= 126 && byte != 61) || byte == 9 || byte == 32 {
// Printable ASCII (except =) or tab/space
char = String(UnicodeScalar(byte))
} else {
char = String(format: "=%02X", byte)
}
if lineLength + char.count > 75 {
result += "=\r\n"
lineLength = 0
}
result += char
lineLength += char.count
}
return result
}
}
- Step 2: Create SMTPClient
Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift:
import Foundation
import Models
public actor SMTPClient: Sendable {
private let host: String
private let port: Int
private let security: SMTPSecurity
private let credentials: Credentials
public init(host: String, port: Int, security: SMTPSecurity, credentials: Credentials) {
self.host = host
self.port = port
self.security = security
self.credentials = credentials
}
public func send(message: OutgoingMessage) async throws {
let connection = SMTPConnection(host: host, port: port, security: security)
let runner = SMTPCommandRunner(connection: connection, credentials: credentials)
try await connection.connect()
do {
let hostname = MessageFormatter.domainFromEmail(message.from.address)
let caps = try await runner.ehlo(hostname: hostname)
if security == .starttls {
try await runner.startTLS()
_ = try await runner.ehlo(hostname: hostname)
}
try await runner.authenticate()
try await runner.mailFrom(message.from.address)
let allRecipients = message.to + message.cc + message.bcc
for recipient in allRecipients {
try await runner.rcptTo(recipient.address)
}
let formatted = MessageFormatter.format(message)
try await runner.data(formatted)
try await runner.quit()
} catch {
try? await runner.quit()
throw error
}
}
public func testConnection() async throws {
let connection = SMTPConnection(host: host, port: port, security: security)
let runner = SMTPCommandRunner(connection: connection, credentials: credentials)
try await connection.connect()
let hostname = "localhost"
let caps = try await runner.ehlo(hostname: hostname)
if security == .starttls {
try await runner.startTLS()
_ = try await runner.ehlo(hostname: hostname)
}
try await runner.authenticate()
try await runner.quit()
}
}
- Step 3: Verify SMTPClient compiles
cd Packages/MagnumOpusCore && swift build --target SMTPClient
- Step 4: Commit
git add -A && git commit -m "implement SMTPClient: message formatter, public send/testConnection API"
Task 10: SMTPClient Tests
Files:
-
Create:
Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift -
Delete:
Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift -
Step 1: Create MessageFormatterTests
Create Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift:
import Testing
import Models
@testable import SMTPClient
@Suite("MessageFormatter")
struct MessageFormatterTests {
@Test("formats basic message with required headers")
func basicMessage() {
let message = OutgoingMessage(
from: EmailAddress(name: "Alice", address: "alice@example.com"),
to: [EmailAddress(name: "Bob", address: "bob@example.com")],
subject: "Hello",
bodyText: "Hi Bob!",
messageId: "test-123@example.com"
)
let result = MessageFormatter.format(message)
#expect(result.contains("From: \"Alice\" <alice@example.com>"))
#expect(result.contains("To: \"Bob\" <bob@example.com>"))
#expect(result.contains("Subject: Hello"))
#expect(result.contains("Message-ID: <test-123@example.com>"))
#expect(result.contains("MIME-Version: 1.0"))
#expect(result.contains("Content-Type: text/plain; charset=utf-8"))
#expect(result.contains("Content-Transfer-Encoding: quoted-printable"))
#expect(result.contains("Hi Bob!"))
}
@Test("includes reply headers when set")
func replyHeaders() {
let message = OutgoingMessage(
from: EmailAddress(address: "a@example.com"),
to: [EmailAddress(address: "b@example.com")],
subject: "Re: Hello",
bodyText: "Reply text",
inReplyTo: "original-123@example.com",
references: "<original-123@example.com>",
messageId: "reply-456@example.com"
)
let result = MessageFormatter.format(message)
#expect(result.contains("In-Reply-To: <original-123@example.com>"))
#expect(result.contains("References: <original-123@example.com>"))
}
@Test("omits BCC from formatted headers")
func bccOmitted() {
let message = OutgoingMessage(
from: EmailAddress(address: "a@example.com"),
to: [EmailAddress(address: "b@example.com")],
bcc: [EmailAddress(address: "secret@example.com")],
subject: "Test",
bodyText: "Body",
messageId: "test@example.com"
)
let result = MessageFormatter.format(message)
#expect(!result.contains("secret@example.com"))
#expect(!result.contains("Bcc"))
}
@Test("formats CC with multiple recipients")
func multipleCC() {
let message = OutgoingMessage(
from: EmailAddress(address: "a@example.com"),
to: [EmailAddress(address: "b@example.com")],
cc: [
EmailAddress(name: "Carol", address: "c@example.com"),
EmailAddress(address: "d@example.com"),
],
subject: "Test",
bodyText: "Body",
messageId: "test@example.com"
)
let result = MessageFormatter.format(message)
#expect(result.contains("Cc: \"Carol\" <c@example.com>, d@example.com"))
}
@Test("quoted-printable encodes non-ASCII")
func quotedPrintableNonAscii() {
let encoded = MessageFormatter.quotedPrintableEncode("Grüße")
#expect(encoded.contains("="))
// ü is 0xC3 0xBC in UTF-8
#expect(encoded.contains("=C3=BC"))
}
@Test("domain extraction from email")
func domainExtraction() {
#expect(MessageFormatter.domainFromEmail("alice@example.com") == "example.com")
#expect(MessageFormatter.domainFromEmail("noat") == "localhost")
}
@Test("message-id generation uses domain")
func messageIdGeneration() {
let id = MessageFormatter.generateMessageId(domain: "example.com")
#expect(id.hasSuffix("@example.com"))
#expect(id.count > 20) // UUID + @ + domain
}
@Test("formats address without name")
func addressWithoutName() {
let addr = EmailAddress(address: "plain@example.com")
#expect(MessageFormatter.formatAddress(addr) == "plain@example.com")
}
}
- Step 2: Delete placeholder and run tests
rm Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift
cd Packages/MagnumOpusCore && swift test --filter SMTPClientTests
- Step 3: Commit
git add -A && git commit -m "add MessageFormatter tests: headers, reply, bcc, quoted-printable, message-id"
Chunk 4: ActionQueue
Build the offline-safe action queue that dispatches write operations.
Task 11: Action Types
Files:
-
Create:
Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift -
Step 1: Create PendingAction and ActionPayload types
Create Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift:
import Foundation
import Models
public struct PendingAction: Sendable, Codable {
public var id: String
public var accountId: String
public var actionType: ActionType
public var payload: ActionPayload
public var createdAt: Date
public init(
id: String = UUID().uuidString,
accountId: String,
actionType: ActionType,
payload: ActionPayload,
createdAt: Date = Date()
) {
self.id = id
self.accountId = accountId
self.actionType = actionType
self.payload = payload
self.createdAt = createdAt
}
}
public enum ActionType: String, Sendable, Codable {
case setFlags
case move
case delete
case send
case append
}
public enum ActionPayload: Sendable, Codable {
case setFlags(uid: Int, mailbox: String, add: [String], remove: [String])
case move(uid: Int, from: String, to: String)
case delete(uid: Int, mailbox: String, trashMailbox: String)
case send(message: OutgoingMessage)
case append(mailbox: String, messageData: String, flags: [String])
public var actionType: ActionType {
switch self {
case .setFlags: return .setFlags
case .move: return .move
case .delete: return .delete
case .send: return .send
case .append: return .append
}
}
}
- Step 2: Verify build
cd Packages/MagnumOpusCore && swift build --target SyncEngine
- Step 3: Commit
git add -A && git commit -m "add action queue types: PendingAction, ActionType, ActionPayload"
Task 12: ActionQueue Implementation
Files:
-
Create:
Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift -
Step 1: Implement ActionQueue
Create Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift:
import Foundation
import Models
import IMAPClient
import SMTPClient
import MailStore
public actor ActionQueue {
private let store: MailStore
private let imapClientProvider: () -> any IMAPClientProtocol
private let smtpClientProvider: (() -> SMTPClient)?
private let accountId: String
private static let maxRetries = 5
public init(
store: MailStore,
accountId: String,
imapClientProvider: @escaping () -> any IMAPClientProtocol,
smtpClientProvider: (() -> SMTPClient)? = nil
) {
self.store = store
self.accountId = accountId
self.imapClientProvider = imapClientProvider
self.smtpClientProvider = smtpClientProvider
}
// MARK: - Enqueue
/// Enqueue a single action. Applies local change first, then attempts remote dispatch.
public func enqueue(_ action: PendingAction) throws {
// Phase 1: Apply local change + persist action
try applyLocally(action)
try persistAction(action)
// Phase 2: Attempt immediate remote dispatch (fire-and-forget)
Task { [weak self] in
await self?.dispatchSingle(action)
}
}
/// Enqueue multiple actions in a single transaction (e.g., send + append).
public func enqueue(_ actions: [PendingAction]) throws {
for action in actions {
try applyLocally(action)
try persistAction(action)
}
Task { [weak self] in
for action in actions {
await self?.dispatchSingle(action)
}
}
}
// MARK: - Flush
/// Flush all pending actions. Called by SyncCoordinator before fetch.
public func flush() async {
guard let actions = try? store.pendingActions(accountId: accountId),
!actions.isEmpty
else { return }
for record in actions {
guard let action = decodeAction(record) else {
// Corrupt action — remove it
try? store.deletePendingAction(id: record.id)
continue
}
do {
try await dispatch(action)
try store.deletePendingAction(id: record.id)
} catch {
var updated = record
updated.retryCount += 1
updated.lastError = error.localizedDescription
if updated.retryCount >= Self.maxRetries {
// Exceeded retries — mark failed, remove from queue
try? store.deletePendingAction(id: record.id)
// TODO: Surface to user via notification
} else {
try? store.updatePendingAction(updated)
}
}
}
}
public var pendingCount: Int {
(try? store.pendingActionCount(accountId: accountId)) ?? 0
}
// MARK: - Local Application
private func applyLocally(_ action: PendingAction) throws {
switch action.payload {
case .setFlags(let uid, let mailbox, let add, let remove):
// Find the message by uid + mailbox and update flags
if let messages = try? store.messages(mailboxId: mailbox) {
if let msg = messages.first(where: { $0.uid == uid }) {
var isRead = msg.isRead
var isFlagged = msg.isFlagged
if add.contains("\\Seen") { isRead = true }
if remove.contains("\\Seen") { isRead = false }
if add.contains("\\Flagged") { isFlagged = true }
if remove.contains("\\Flagged") { isFlagged = false }
try store.updateFlags(messageId: msg.id, isRead: isRead, isFlagged: isFlagged)
}
}
case .move(_, _, let to):
// Local move: update mailboxId
// The MailStore query by uid+mailbox is needed to find the message
break // Handled by caller via MailStore directly
case .delete(_, _, _):
// Local delete: handled by caller
break
case .send(_), .append(_, _, _):
// No local change for send/append
break
}
}
// MARK: - Remote Dispatch
private func dispatchSingle(_ action: PendingAction) async {
do {
try await dispatch(action)
try store.deletePendingAction(id: action.id)
} catch {
// Failed — leave in queue for flush
if var record = try? store.pendingActions(accountId: accountId).first(where: { $0.id == action.id }) {
record.retryCount += 1
record.lastError = error.localizedDescription
try? store.updatePendingAction(record)
}
}
}
private func dispatch(_ action: PendingAction) async throws {
switch action.payload {
case .setFlags(let uid, let mailbox, let add, let remove):
let imap = imapClientProvider()
try await imap.connect()
try await imap.storeFlags(uid: uid, mailbox: mailbox, add: add, remove: remove)
try await imap.disconnect()
case .move(let uid, let from, let to):
let imap = imapClientProvider()
try await imap.connect()
try await imap.moveMessage(uid: uid, from: from, to: to)
try await imap.disconnect()
case .delete(let uid, let mailbox, let trashMailbox):
let imap = imapClientProvider()
try await imap.connect()
if mailbox == trashMailbox {
// Already in trash — permanent delete
_ = try await imap.selectMailbox(mailbox)
try await imap.storeFlags(uid: uid, mailbox: mailbox, add: ["\\Deleted"], remove: [])
try await imap.expunge(mailbox: mailbox)
} else {
try await imap.moveMessage(uid: uid, from: mailbox, to: trashMailbox)
}
try await imap.disconnect()
case .send(let message):
guard let smtpProvider = smtpClientProvider else {
throw SMTPError.notConnected
}
let smtp = smtpProvider()
try await smtp.send(message: message)
case .append(let mailbox, let messageData, let flags):
let imap = imapClientProvider()
try await imap.connect()
guard let data = messageData.data(using: .utf8) else {
throw SMTPError.sendFailed("Could not encode message data")
}
try await imap.appendMessage(to: mailbox, message: data, flags: flags)
try await imap.disconnect()
}
}
// MARK: - Serialization
private func persistAction(_ action: PendingAction) throws {
let payloadData = try JSONEncoder().encode(action.payload)
let payloadJson = String(data: payloadData, encoding: .utf8) ?? "{}"
let isoFormatter = ISO8601DateFormatter()
try store.insertPendingAction(PendingActionRecord(
id: action.id,
accountId: action.accountId,
actionType: action.actionType.rawValue,
payload: payloadJson,
createdAt: isoFormatter.string(from: action.createdAt)
))
}
private func decodeAction(_ record: PendingActionRecord) -> PendingAction? {
guard let data = record.payload.data(using: .utf8),
let payload = try? JSONDecoder().decode(ActionPayload.self, from: data),
let actionType = ActionType(rawValue: record.actionType)
else { return nil }
let isoFormatter = ISO8601DateFormatter()
return PendingAction(
id: record.id,
accountId: record.accountId,
actionType: actionType,
payload: payload,
createdAt: isoFormatter.date(from: record.createdAt) ?? Date()
)
}
}
- Step 2: Verify build
cd Packages/MagnumOpusCore && swift build --target SyncEngine
- Step 3: Commit
git add -A && git commit -m "implement ActionQueue: two-phase enqueue, flush, retry, dispatch"
Task 13: SyncCoordinator Integration
Files:
-
Edit:
Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift -
Step 1: Add ActionQueue to SyncCoordinator
Edit SyncCoordinator.swift to:
- Accept and store an optional
ActionQueue - Call
actionQueue.flush()at the start ofperformSync(), before connecting to IMAP
// Add property:
private let actionQueue: ActionQueue?
// Extend init:
public init(
accountConfig: AccountConfig,
imapClient: any IMAPClientProtocol,
store: MailStore,
actionQueue: ActionQueue? = nil
) {
self.accountConfig = accountConfig
self.imapClient = imapClient
self.store = store
self.actionQueue = actionQueue
}
// In performSync(), before connecting to IMAP:
private func performSync() async throws {
// Flush pending actions before fetching new state
if let queue = actionQueue {
await queue.flush()
}
// ... existing sync code ...
}
- Step 2: Verify existing tests still pass
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
- Step 3: Commit
git add -A && git commit -m "integrate ActionQueue into SyncCoordinator: flush before fetch"
Task 14: ActionQueue Tests
Files:
-
Create:
Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift -
Step 1: Create ActionQueueTests
Create Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift:
import Testing
import Foundation
@testable import SyncEngine
@testable import MailStore
@testable import IMAPClient
import Models
@Suite("ActionQueue")
struct ActionQueueTests {
func makeStore() throws -> MailStore {
let db = try DatabaseSetup.openInMemoryDatabase()
return MailStore(dbWriter: db)
}
func seedAccount(_ store: MailStore, id: String = "a1") throws {
try store.insertAccount(AccountRecord(
id: id, name: "Test", email: "test@example.com",
imapHost: "imap.example.com", imapPort: 993
))
}
@Test("enqueue persists action to database")
func enqueuePersists() async throws {
let store = try makeStore()
try seedAccount(store)
let mock = MockIMAPClient()
let queue = ActionQueue(
store: store,
accountId: "a1",
imapClientProvider: { mock }
)
let action = PendingAction(
accountId: "a1",
actionType: .setFlags,
payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
)
try await queue.enqueue(action)
// Give dispatch time to attempt
try await Task.sleep(for: .milliseconds(100))
// If dispatch succeeded, action is removed. If not, it's still there.
// With mock, dispatch will succeed → action removed
let remaining = try store.pendingActions(accountId: "a1")
// MockIMAPClient methods succeed, so action should be dispatched and removed
// But the mock doesn't actually connect, so it may throw
// The exact behavior depends on implementation details
}
@Test("flush dispatches actions in order")
func flushOrder() async throws {
let store = try makeStore()
try seedAccount(store)
let mock = MockIMAPClient()
mock.mailboxStatuses["INBOX"] = IMAPMailboxStatus(
name: "INBOX", uidValidity: 1, uidNext: 10, messageCount: 5, recentCount: 0
)
let queue = ActionQueue(
store: store,
accountId: "a1",
imapClientProvider: { mock }
)
// Persist actions directly (bypassing immediate dispatch)
let now = ISO8601DateFormatter().string(from: Date())
try store.insertPendingActions([
PendingActionRecord(
id: "pa1", accountId: "a1", actionType: "setFlags",
payload: "{\"setFlags\":{\"uid\":1,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Seen\"],\"remove\":[]}}",
createdAt: now
),
PendingActionRecord(
id: "pa2", accountId: "a1", actionType: "setFlags",
payload: "{\"setFlags\":{\"uid\":2,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Flagged\"],\"remove\":[]}}",
createdAt: now
),
])
await queue.flush()
// Verify actions were dispatched
#expect(mock.storedFlags.count == 2)
}
@Test("pending count reflects queue state")
func pendingCountReflects() async throws {
let store = try makeStore()
try seedAccount(store)
let mock = MockIMAPClient()
let queue = ActionQueue(
store: store,
accountId: "a1",
imapClientProvider: { mock }
)
#expect(await queue.pendingCount == 0)
let now = ISO8601DateFormatter().string(from: Date())
try store.insertPendingAction(PendingActionRecord(
id: "pa1", accountId: "a1", actionType: "send",
payload: "{\"send\":{\"message\":{}}}",
createdAt: now
))
#expect(await queue.pendingCount == 1)
}
}
- Step 2: Run tests
cd Packages/MagnumOpusCore && swift test --filter ActionQueueTests
- Step 3: Commit
git add -A && git commit -m "add ActionQueue tests: enqueue, flush, pending count"
Chunk 5: Account Setup & AutoDiscovery
Extend auto-discovery and account setup for SMTP.
Task 15: AutoDiscovery SMTP Extension
Files:
-
Edit:
Apps/MagnumOpus/Services/AutoDiscovery.swift -
Step 1: Extend AutoDiscovery for SMTP
Rename discoverIMAP(for:) to discover(for:) and return both IMAP and SMTP settings:
struct DiscoveredConfig: Sendable {
var imap: DiscoveredServer?
var smtp: DiscoveredServer?
}
enum AutoDiscovery {
static func discover(for email: String) async -> DiscoveredConfig {
guard let domain = email.split(separator: "@").last.map(String.init) else {
return DiscoveredConfig(imap: nil, smtp: nil)
}
if let config = await queryISPDB(domain: domain) {
return config
}
// Fallback: probe common hostnames
let imap = await probeIMAP(domain: domain)
let smtp = await probeSMTP(domain: domain)
return DiscoveredConfig(imap: imap, smtp: smtp)
}
// Keep the old method as a convenience wrapper for backwards compatibility
static func discoverIMAP(for email: String) async -> DiscoveredServer? {
await discover(for: email).imap
}
private static func queryISPDB(domain: String) async -> DiscoveredConfig? {
let url = URL(string: "https://autoconfig.thunderbird.net/v1.1/\(domain)")!
guard let (data, response) = try? await URLSession.shared.data(from: url),
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let xml = String(data: data, encoding: .utf8)
else { return nil }
let imap = parseISPDBXML(xml, serverType: "imap", tag: "incomingServer")
let smtp = parseISPDBXML(xml, serverType: "smtp", tag: "outgoingServer")
guard imap != nil || smtp != nil else { return nil }
return DiscoveredConfig(imap: imap, smtp: smtp)
}
private static func parseISPDBXML(_ xml: String, serverType: String, tag: String) -> DiscoveredServer? {
guard let startRange = xml.range(of: "<\(tag) type=\"\(serverType)\">"),
let endRange = xml.range(of: "</\(tag)>", range: startRange.upperBound..<xml.endIndex)
else { return nil }
let section = String(xml[startRange.upperBound..<endRange.lowerBound])
func extractTag(_ name: String) -> String? {
guard let start = section.range(of: "<\(name)>"),
let end = section.range(of: "</\(name)>", range: start.upperBound..<section.endIndex)
else { return nil }
return String(section[start.upperBound..<end.lowerBound])
}
guard let hostname = extractTag("hostname"),
let portStr = extractTag("port"),
let port = Int(portStr)
else { return nil }
let socketType = extractTag("socketType") ?? "SSL"
return DiscoveredServer(hostname: hostname, port: port, socketType: socketType)
}
private static func probeIMAP(domain: String) async -> DiscoveredServer? {
for candidate in ["imap.\(domain)", "mail.\(domain)"] {
if await testConnection(host: candidate, port: 993) {
return DiscoveredServer(hostname: candidate, port: 993, socketType: "SSL")
}
}
return nil
}
private static func probeSMTP(domain: String) async -> DiscoveredServer? {
// Try submission port (587 STARTTLS) first, then 465 SSL
for candidate in ["smtp.\(domain)", "mail.\(domain)"] {
if await testConnection(host: candidate, port: 587) {
return DiscoveredServer(hostname: candidate, port: 587, socketType: "STARTTLS")
}
if await testConnection(host: candidate, port: 465) {
return DiscoveredServer(hostname: candidate, port: 465, socketType: "SSL")
}
}
return nil
}
private static func testConnection(host: String, port: Int) async -> Bool {
// ... existing implementation unchanged ...
}
}
- Step 2: Update AccountSetupViewModel for SMTP
Edit Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift:
-
Add
smtpHost,smtpPort,smtpSecurityproperties -
Update
autoDiscover()to useAutoDiscovery.discover(for:)and fill SMTP fields -
Update
buildConfig()to include SMTP fields inAccountConfig -
Step 3: Update AccountSetupView for SMTP
Edit Apps/MagnumOpus/Views/AccountSetupView.swift:
-
Add SMTP fields in manual mode (host, port, security picker)
-
Show auto-discovered SMTP settings
-
Test both IMAP and SMTP connections before saving
-
Step 4: Verify app compiles
cd Apps && xcodegen generate && xcodebuild -scheme MagnumOpus-macOS build
Or open in Xcode and build.
- Step 5: Commit
git add -A && git commit -m "extend auto-discovery, account setup for SMTP host/port/security"
Chunk 6: Compose Flow
Build the compose UI and ViewModel for new messages, replies, and forwards.
Task 16: ComposeViewModel
Files:
-
Create:
Apps/MagnumOpus/ViewModels/ComposeViewModel.swift -
Step 1: Create ComposeViewModel
Create Apps/MagnumOpus/ViewModels/ComposeViewModel.swift:
import Foundation
import Models
import MailStore
import SyncEngine
import SMTPClient
enum ComposeMode: Sendable {
case new
case reply(to: MessageSummary)
case replyAll(to: MessageSummary)
case forward(of: MessageSummary)
case draft(DraftRecord)
}
@Observable @MainActor
final class ComposeViewModel {
var to: String = ""
var cc: String = ""
var bcc: String = ""
var subject: String = ""
var bodyText: String = ""
var mode: ComposeMode = .new
var isSending = false
var errorMessage: String?
private var draftId: String?
private var savedTo: String = ""
private var savedCc: String = ""
private var savedBcc: String = ""
private var savedSubject: String = ""
private var savedBody: String = ""
private var autoSaveTask: Task<Void, Never>?
private let accountConfig: AccountConfig
private let store: MailStore
private let actionQueue: ActionQueue
var isDirty: Bool {
to != savedTo || cc != savedCc || bcc != savedBcc ||
subject != savedSubject || bodyText != savedBody
}
init(
mode: ComposeMode,
accountConfig: AccountConfig,
store: MailStore,
actionQueue: ActionQueue
) {
self.mode = mode
self.accountConfig = accountConfig
self.store = store
self.actionQueue = actionQueue
prefill(mode: mode)
startAutoSave()
}
deinit {
autoSaveTask?.cancel()
}
// MARK: - Prefill
private func prefill(mode: ComposeMode) {
switch mode {
case .new:
break
case .reply(let msg):
to = msg.from.address
subject = prefixSubject("Re:", msg.subject ?? "")
bodyText = quoteBody(msg)
// Threading handled at send time
case .replyAll(let msg):
to = msg.from.address
// Add other recipients minus self
let others = (msg.to + msg.cc)
.filter { $0.address.lowercased() != accountConfig.email.lowercased() }
.map { $0.address }
cc = others.joined(separator: ", ")
subject = prefixSubject("Re:", msg.subject ?? "")
bodyText = quoteBody(msg)
case .forward(let msg):
subject = prefixSubject("Fwd:", msg.subject ?? "")
bodyText = forwardBody(msg)
case .draft(let draft):
draftId = draft.id
to = decodeAddressField(draft.toAddresses) ?? ""
cc = decodeAddressField(draft.ccAddresses) ?? ""
bcc = decodeAddressField(draft.bccAddresses) ?? ""
subject = draft.subject ?? ""
bodyText = draft.bodyText ?? ""
}
// Save initial state for dirty tracking
savedTo = to
savedCc = cc
savedBcc = bcc
savedSubject = subject
savedBody = bodyText
}
// MARK: - Send
func send() async throws {
isSending = true
errorMessage = nil
do {
let fromAddr = EmailAddress(name: accountConfig.name, address: accountConfig.email)
let toAddrs = parseAddressList(to)
let ccAddrs = parseAddressList(cc)
let bccAddrs = parseAddressList(bcc)
guard !toAddrs.isEmpty else {
throw ComposeError.noRecipients
}
let domain = MessageFormatter.domainFromEmail(accountConfig.email)
let messageId = MessageFormatter.generateMessageId(domain: domain)
var inReplyTo: String?
var references: String?
switch mode {
case .reply(let msg), .replyAll(let msg):
inReplyTo = msg.messageId
if let existingRefs = msg.references {
references = existingRefs + " <\(msg.messageId ?? "")>"
} else if let msgId = msg.messageId {
references = "<\(msgId)>"
}
case .forward(let msg):
if let msgId = msg.messageId {
references = "<\(msgId)>"
}
default:
break
}
let outgoing = OutgoingMessage(
from: fromAddr,
to: toAddrs,
cc: ccAddrs,
bcc: bccAddrs,
subject: subject,
bodyText: bodyText,
inReplyTo: inReplyTo,
references: references,
messageId: messageId
)
// Format the message for Sent folder append
let formatted = MessageFormatter.format(outgoing)
// Enqueue send + append atomically
let sendAction = PendingAction(
accountId: accountConfig.id,
actionType: .send,
payload: .send(message: outgoing)
)
let appendAction = PendingAction(
accountId: accountConfig.id,
actionType: .append,
payload: .append(mailbox: "Sent", messageData: formatted, flags: ["\\Seen"])
)
try await actionQueue.enqueue([sendAction, appendAction])
// Clean up draft
if let draftId {
try store.deleteDraft(id: draftId)
}
isSending = false
} catch {
isSending = false
errorMessage = error.localizedDescription
throw error
}
}
// MARK: - Drafts
func saveDraft() throws {
let now = ISO8601DateFormatter().string(from: Date())
let id = draftId ?? UUID().uuidString
let draft = DraftRecord(
id: id,
accountId: accountConfig.id,
inReplyTo: replyMessageId,
forwardOf: forwardMessageId,
toAddresses: encodeAddressField(to),
ccAddresses: encodeAddressField(cc),
bccAddresses: encodeAddressField(bcc),
subject: subject,
bodyText: bodyText,
createdAt: draftId == nil ? now : now, // Keep original if updating
updatedAt: now
)
if draftId != nil {
try store.updateDraft(draft)
} else {
try store.insertDraft(draft)
draftId = id
}
savedTo = to
savedCc = cc
savedBcc = bcc
savedSubject = subject
savedBody = bodyText
}
func deleteDraft() throws {
if let draftId {
try store.deleteDraft(id: draftId)
}
}
// MARK: - Auto-Save
private func startAutoSave() {
autoSaveTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(10))
guard let self, !Task.isCancelled else { break }
if self.isDirty {
try? self.saveDraft()
}
}
}
}
// MARK: - Helpers
private var replyMessageId: String? {
switch mode {
case .reply(let msg), .replyAll(let msg): return msg.messageId
default: return nil
}
}
private var forwardMessageId: String? {
switch mode {
case .forward(let msg): return msg.messageId
default: return nil
}
}
private func prefixSubject(_ prefix: String, _ original: String) -> String {
let stripped = original
.replacingOccurrences(of: "^(Re:|Fwd:|Fw:)\\s*", with: "", options: .regularExpression)
return "\(prefix) \(stripped)"
}
private func quoteBody(_ msg: MessageSummary) -> String {
let dateStr = msg.date.map { "\($0)" } ?? "unknown date"
let sender = msg.from.displayName
let quoted = (msg.bodyText ?? "")
.split(separator: "\n", omittingEmptySubsequences: false)
.map { "> \($0)" }
.joined(separator: "\n")
return "\n\nOn \(dateStr), \(sender) wrote:\n\(quoted)"
}
private func forwardBody(_ msg: MessageSummary) -> String {
let sender = MessageFormatter.formatAddress(msg.from)
let dateStr = msg.date.map { "\($0)" } ?? ""
let toStr = msg.to.map(MessageFormatter.formatAddress).joined(separator: ", ")
return """
---------- Forwarded message ----------
From: \(sender)
Date: \(dateStr)
Subject: \(msg.subject ?? "")
To: \(toStr)
\(msg.bodyText ?? "")
"""
}
private func parseAddressList(_ field: String) -> [EmailAddress] {
field.split(separator: ",")
.map { String($0).trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
.map { EmailAddress.parse($0) }
}
private func encodeAddressField(_ field: String) -> String? {
let addrs = parseAddressList(field)
guard !addrs.isEmpty else { return nil }
guard let data = try? JSONEncoder().encode(addrs) else { return nil }
return String(data: data, encoding: .utf8)
}
private func decodeAddressField(_ json: String?) -> String? {
guard let json, let data = json.data(using: .utf8),
let addrs = try? JSONDecoder().decode([EmailAddress].self, from: data)
else { return nil }
return addrs.map(\.address).joined(separator: ", ")
}
}
enum ComposeError: Error, LocalizedError {
case noRecipients
var errorDescription: String? {
switch self {
case .noRecipients: return "At least one recipient is required"
}
}
}
- Step 2: Verify app compiles
Build the app target to ensure ComposeViewModel integrates cleanly.
- Step 3: Commit
git add -A && git commit -m "add ComposeViewModel: new, reply, reply-all, forward, draft auto-save"
Task 17: ComposeView
Files:
-
Create:
Apps/MagnumOpus/Views/ComposeView.swift -
Step 1: Create ComposeView
Create Apps/MagnumOpus/Views/ComposeView.swift:
import SwiftUI
import Models
struct ComposeView: View {
@Bindable var viewModel: ComposeViewModel
@Environment(\.dismiss) private var dismiss
@State private var showBcc = false
var body: some View {
VStack(spacing: 0) {
// Header fields
Form {
TextField("To:", text: $viewModel.to)
.textContentType(.emailAddress)
TextField("CC:", text: $viewModel.cc)
.textContentType(.emailAddress)
if showBcc {
TextField("BCC:", text: $viewModel.bcc)
.textContentType(.emailAddress)
}
TextField("Subject:", text: $viewModel.subject)
}
.formStyle(.grouped)
.frame(maxHeight: 200)
Divider()
// Body
TextEditor(text: $viewModel.bodyText)
.font(.body)
.padding(8)
if let error = viewModel.errorMessage {
Text(error)
.foregroundStyle(.red)
.font(.caption)
.padding(.horizontal)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Discard") {
try? viewModel.deleteDraft()
dismiss()
}
}
ToolbarItem {
Button(showBcc ? "Hide BCC" : "BCC") {
showBcc.toggle()
}
}
ToolbarItem(placement: .confirmationAction) {
Button {
Task {
try? await viewModel.send()
dismiss()
}
} label: {
if viewModel.isSending {
ProgressView()
.controlSize(.small)
} else {
Label("Send", systemImage: "paperplane")
}
}
.disabled(viewModel.to.isEmpty || viewModel.isSending)
.keyboardShortcut(.return, modifiers: .command)
}
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 400)
#endif
.onDisappear {
// Save draft if dirty and not sent
if viewModel.isDirty && !viewModel.isSending {
try? viewModel.saveDraft()
}
}
}
}
- Step 2: Verify app compiles
Build the app target.
- Step 3: Commit
git add -A && git commit -m "add ComposeView: to/cc/bcc/subject/body, send, discard, draft save"
Chunk 7: Triage UI
Add triage actions (archive, delete, flag, mark read, move) to the thread list.
Task 18: MailViewModel Triage Actions
Files:
-
Edit:
Apps/MagnumOpus/ViewModels/MailViewModel.swift -
Step 1: Add ActionQueue and triage methods to MailViewModel
Add to MailViewModel:
- An
actionQueueproperty, initialized insetup() - Triage action methods:
// MARK: - Triage Actions
func archiveSelectedThread() async {
guard let thread = selectedThread else { return }
guard let archiveMailbox = try? store?.mailboxWithRole("archive", accountId: thread.accountId),
let selectedMailbox
else { return }
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
for msg in messages ?? [] {
let action = PendingAction(
accountId: thread.accountId,
actionType: .move,
payload: .move(uid: msg.uid, from: selectedMailbox.name, to: archiveMailbox.name)
)
try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id)
try? await actionQueue?.enqueue(action)
}
autoAdvance()
}
func deleteSelectedThread() async {
guard let thread = selectedThread else { return }
guard let trashMailbox = try? store?.mailboxWithRole("trash", accountId: thread.accountId),
let selectedMailbox
else { return }
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
for msg in messages ?? [] {
if selectedMailbox.id == trashMailbox.id {
// Permanent delete
let action = PendingAction(
accountId: thread.accountId,
actionType: .delete,
payload: .delete(uid: msg.uid, mailbox: trashMailbox.name, trashMailbox: trashMailbox.name)
)
try? store?.deleteMessage(id: msg.id)
try? await actionQueue?.enqueue(action)
} else {
let action = PendingAction(
accountId: thread.accountId,
actionType: .move,
payload: .move(uid: msg.uid, from: selectedMailbox.name, to: trashMailbox.name)
)
try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: trashMailbox.id)
try? await actionQueue?.enqueue(action)
}
}
autoAdvance()
}
func toggleFlagSelectedThread() async {
guard let thread = selectedThread, let selectedMailbox else { return }
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
guard let firstMsg = messages?.first else { return }
let newFlagged = !firstMsg.isFlagged
for msg in messages ?? [] {
let action = PendingAction(
accountId: thread.accountId,
actionType: .setFlags,
payload: .setFlags(
uid: msg.uid,
mailbox: selectedMailbox.name,
add: newFlagged ? ["\\Flagged"] : [],
remove: newFlagged ? [] : ["\\Flagged"]
)
)
try? store?.updateFlags(messageId: msg.id, isRead: msg.isRead, isFlagged: newFlagged)
try? await actionQueue?.enqueue(action)
}
}
func toggleReadSelectedThread() async {
guard let thread = selectedThread, let selectedMailbox else { return }
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
guard let firstMsg = messages?.first else { return }
let newRead = !firstMsg.isRead
for msg in messages ?? [] {
let action = PendingAction(
accountId: thread.accountId,
actionType: .setFlags,
payload: .setFlags(
uid: msg.uid,
mailbox: selectedMailbox.name,
add: newRead ? ["\\Seen"] : [],
remove: newRead ? [] : ["\\Seen"]
)
)
try? store?.updateFlags(messageId: msg.id, isRead: newRead, isFlagged: msg.isFlagged)
try? await actionQueue?.enqueue(action)
}
}
func moveSelectedThread(to mailbox: MailboxInfo) async {
guard let thread = selectedThread, let selectedMailbox else { return }
guard let targetMailbox = try? store?.mailbox(id: mailbox.id) else { return }
let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
for msg in messages ?? [] {
let action = PendingAction(
accountId: thread.accountId,
actionType: .move,
payload: .move(uid: msg.uid, from: selectedMailbox.name, to: targetMailbox.name)
)
try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: targetMailbox.id)
try? await actionQueue?.enqueue(action)
}
autoAdvance()
}
private func autoAdvance() {
guard let current = selectedThread,
let idx = threads.firstIndex(where: { $0.id == current.id })
else {
selectedThread = nil
return
}
if idx + 1 < threads.count {
selectedThread = threads[idx + 1]
} else if idx > 0 {
selectedThread = threads[idx - 1]
} else {
selectedThread = nil
}
}
-
Step 2: Verify app compiles
-
Step 3: Commit
git add -A && git commit -m "add triage actions to MailViewModel: archive, delete, flag, read, move"
Task 19: ThreadListView Triage UI
Files:
-
Edit:
Apps/MagnumOpus/Views/ThreadListView.swift -
Create:
Apps/MagnumOpus/Views/MoveToSheet.swift -
Step 1: Add toolbar buttons to ThreadListView
Edit ThreadListView.swift to add triage toolbar:
.toolbar {
ToolbarItemGroup {
if viewModel.selectedThread != nil {
Button { Task { await viewModel.archiveSelectedThread() } } label: {
Label("Archive", systemImage: "archivebox")
}
.keyboardShortcut("e", modifiers: [])
Button { Task { await viewModel.deleteSelectedThread() } } label: {
Label("Delete", systemImage: "trash")
}
.keyboardShortcut(.delete, modifiers: [])
Button { Task { await viewModel.toggleFlagSelectedThread() } } label: {
Label("Flag", systemImage: "flag")
}
.keyboardShortcut("s", modifiers: [])
Button { Task { await viewModel.toggleReadSelectedThread() } } label: {
Label("Read/Unread", systemImage: "envelope.badge")
}
.keyboardShortcut("u", modifiers: [.shift, .command])
Button { showMoveSheet = true } label: {
Label("Move", systemImage: "folder")
}
.keyboardShortcut("m", modifiers: [.shift, .command])
}
}
}
- Step 2: Add swipe actions to thread rows
// On each thread row in the List:
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archiveSelectedThread() }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.green)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await viewModel.deleteSelectedThread() }
} label: {
Label("Delete", systemImage: "trash")
}
}
- Step 3: Create MoveToSheet
Create Apps/MagnumOpus/Views/MoveToSheet.swift:
import SwiftUI
import Models
struct MoveToSheet: View {
let mailboxes: [MailboxInfo]
let onSelect: (MailboxInfo) -> Void
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
var filteredMailboxes: [MailboxInfo] {
if searchText.isEmpty { return mailboxes }
return mailboxes.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
var body: some View {
NavigationStack {
List(filteredMailboxes, id: \.id) { mailbox in
Button {
onSelect(mailbox)
dismiss()
} label: {
Label(mailbox.name, systemImage: mailbox.systemImage)
}
}
.searchable(text: $searchText, prompt: "Search folders")
.navigationTitle("Move to…")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
#if os(macOS)
.frame(minWidth: 300, minHeight: 400)
#endif
}
}
-
Step 4: Verify app compiles
-
Step 5: Commit
git add -A && git commit -m "add triage ui: toolbar buttons, keyboard shortcuts, swipe actions, move sheet"
Task 20: Reply/Forward/Compose Buttons
Files:
-
Edit:
Apps/MagnumOpus/Views/ThreadDetailView.swift -
Edit:
Apps/MagnumOpus/Views/ContentView.swift(or wherever compose is triggered) -
Step 1: Add reply/forward buttons to ThreadDetailView toolbar
Add to the ThreadDetailView toolbar:
.toolbar {
ToolbarItemGroup {
Button { openCompose(.reply(to: lastMessage)) } label: {
Label("Reply", systemImage: "arrowshape.turn.up.left")
}
.keyboardShortcut("r", modifiers: .command)
Button { openCompose(.replyAll(to: lastMessage)) } label: {
Label("Reply All", systemImage: "arrowshape.turn.up.left.2")
}
.keyboardShortcut("r", modifiers: [.command, .shift])
Button { openCompose(.forward(of: lastMessage)) } label: {
Label("Forward", systemImage: "arrowshape.turn.up.right")
}
.keyboardShortcut("f", modifiers: .command)
}
}
- Step 2: Add compose new button to main toolbar
In the sidebar or main toolbar, add a "New Message" button:
Button { openCompose(.new) } label: {
Label("New Message", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
- Step 3: Wire compose presentation
Use a @State var composeMode: ComposeMode? and present ComposeView as a sheet (iOS) or new window (macOS):
.sheet(item: $composeMode) { mode in
NavigationStack {
ComposeView(viewModel: ComposeViewModel(
mode: mode,
accountConfig: accountConfig,
store: store,
actionQueue: actionQueue
))
}
}
For macOS, consider using openWindow if a separate compose window is desired:
#if os(macOS)
WindowGroup("Compose", for: ComposeMode.self) { $mode in
if let mode {
ComposeView(viewModel: ComposeViewModel(
mode: mode,
accountConfig: accountConfig,
store: store,
actionQueue: actionQueue
))
}
}
#endif
-
Step 4: Verify app compiles and runs
-
Step 5: Commit
git add -A && git commit -m "add reply, reply-all, forward, compose new buttons with keyboard shortcuts"
Chunk 8: Integration & Polish
Task 21: End-to-End Wiring
Files:
-
Edit:
Apps/MagnumOpus/ViewModels/MailViewModel.swift -
Edit:
Apps/MagnumOpus/ContentView.swift -
Step 1: Initialize ActionQueue and SMTPClient in MailViewModel.setup()
When setup(config:credentials:) is called, also create:
SMTPClient(if SMTP fields are configured)ActionQueuewith the IMAP and SMTP client providers- Pass
actionQueuetoSyncCoordinator
func setup(config: AccountConfig, credentials: Credentials) {
// ... existing store + IMAP client setup ...
let smtpClient: SMTPClient? = {
guard let host = config.smtpHost,
let port = config.smtpPort,
let security = config.smtpSecurity
else { return nil }
return SMTPClient(host: host, port: port, security: security, credentials: credentials)
}()
let queue = ActionQueue(
store: store,
accountId: config.id,
imapClientProvider: { IMAPClient(host: config.imapHost, port: config.imapPort, credentials: credentials) },
smtpClientProvider: smtpClient.map { client in { client } }
)
self.actionQueue = queue
let coordinator = SyncCoordinator(
accountConfig: config,
imapClient: imapClient,
store: store,
actionQueue: queue
)
self.coordinator = coordinator
}
- Step 2: Pass dependencies to ComposeViewModel from ContentView
Ensure ContentView or whatever view triggers compose has access to accountConfig, store, and actionQueue to create ComposeViewModel.
- Step 3: Verify full app compiles and basic flow works
Open the app, configure an account with SMTP, compose and send a test email. Verify:
-
Compose opens from toolbar button or keyboard shortcut
-
Reply prefills correctly
-
Send enqueues action
-
Sync flushes queue
-
Step 4: Commit
git add -A && git commit -m "wire end-to-end: ActionQueue, SMTPClient, ComposeViewModel in app"
Task 22: Body Fetch for Reply/Forward
Files:
-
Edit:
Apps/MagnumOpus/ViewModels/ComposeViewModel.swiftorMailViewModel.swift -
Step 1: Ensure body is available before opening compose in reply/forward mode
When the user taps reply/forward, check if the message body is populated. If not, fetch it:
func openCompose(_ mode: ComposeMode) {
switch mode {
case .reply(let msg), .replyAll(let msg), .forward(let msg):
if msg.bodyText == nil {
Task {
// Try to fetch the body
do {
let (text, html) = try await imapClient.fetchBody(uid: msg.uid)
if let text {
try store.storeBody(messageId: msg.id, text: text, html: html)
}
// Re-read and open compose with body
if let updated = try? store.message(id: msg.id) {
let updatedSummary = /* convert to MessageSummary with body */
self.composeMode = mode // with updated message
}
} catch {
// Open without body — offline fallback
self.composeMode = mode
}
}
return
}
self.composeMode = mode
default:
self.composeMode = mode
}
}
-
Step 2: Verify reply with body works
-
Step 3: Commit
git add -A && git commit -m "fetch message body before compose reply/forward, offline fallback"
Task 23: Final Integration Tests
Files:
-
Edit: existing test files as needed
-
Step 1: Run all existing tests
cd Packages/MagnumOpusCore && swift test
Fix any failures from the v0.3 changes.
- Step 2: Add SyncCoordinator test for flush-before-fetch
Add a test to SyncCoordinatorTests.swift that verifies the ActionQueue is flushed before IMAP fetch during sync.
- Step 3: Verify app builds for both macOS and iOS
cd Apps && xcodegen generate
xcodebuild -scheme MagnumOpus-macOS build
xcodebuild -scheme MagnumOpus-iOS -destination 'platform=iOS Simulator,name=iPhone 16' build
- Step 4: Commit
git add -A && git commit -m "fix test failures, verify builds for macOS and iOS"
Task 24: Version Bump & Cleanup
- Step 1: Bump CalVer
Update version to 2026.03.14 in whatever config tracks it (e.g., project.yml, Info.plist, or CLAUDE.md).
- Step 2: Remove any leftover placeholder files
find Packages -name "Placeholder.swift" -delete
- Step 3: Final commit
git add -A && git commit -m "bump calver to 2026.03.14, v0.3: compose, triage, smtp, action queue"
Summary
| Chunk | Tasks | Focus |
|---|---|---|
| 1: Schema & Models | 1–3 | New types, migrations, MailStore queries |
| 2: IMAP Write | 4–6 | Protocol extension, write implementation, folder roles |
| 3: SMTPClient | 7–10 | New module: connection, commands, formatting, tests |
| 4: ActionQueue | 11–14 | Action types, queue implementation, sync integration, tests |
| 5: Account Setup | 15 | AutoDiscovery SMTP, account setup UI |
| 6: Compose | 16–17 | ComposeViewModel, ComposeView |
| 7: Triage UI | 18–20 | Triage actions, toolbar/swipe/shortcuts, reply/forward buttons |
| 8: Integration | 21–24 | Wiring, body fetch, tests, version bump |
Parallelizable: Chunks 2 and 3 (IMAP write + SMTP) are independent and can be done concurrently. Chunk 4 depends on both. Chunks 5–7 depend on chunk 4. Chunk 8 depends on everything.