# 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`: ```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`: ```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: ```swift 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** ```bash cd Packages/MagnumOpusCore && swift build --target Models ``` - [ ] **Step 5: Commit** ```bash 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`: ```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`: ```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`: ```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`: ```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`: ```swift 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`: ```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** ```bash cd Packages/MagnumOpusCore && swift test --filter MigrationTests ``` - [ ] **Step 8: Commit** ```bash 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`: ```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`: ```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`: ```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`: ```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: ```swift 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** ```bash cd Packages/MagnumOpusCore && swift test --filter MailStoreTests ``` - [ ] **Step 6: Commit** ```bash 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`: ```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) 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 } ``` - [ ] **Step 2: Update MockIMAPClient with write methods** Edit `Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift`: ```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 = ["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) 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 { serverCapabilities } } enum MockIMAPError: Error { case mailboxNotFound(String) } ``` - [ ] **Step 3: Verify tests still compile and pass** ```bash cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests ``` - [ ] **Step 4: Commit** ```bash 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: ```swift 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** ```swift 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** ```swift 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 { 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** ```bash cd Packages/MagnumOpusCore && swift build --target IMAPClient ``` - [ ] **Step 5: Commit** ```bash 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` (or `IMAPTypes.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: ```swift 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): ```swift /// 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: ```swift // 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** ```bash cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests ``` - [ ] **Step 5: Commit** ```bash 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: ```swift // 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: ```swift .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: ```swift // 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** ```bash 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** ```bash cd Packages/MagnumOpusCore && swift package resolve && swift build ``` - [ ] **Step 4: Commit** ```bash 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`: ```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 is `-` for continuation, space for final line. The handler buffers lines and delivers the complete response. ```swift 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? 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..= 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): ```swift 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: ```swift 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 { 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** ```bash rm Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift cd Packages/MagnumOpusCore && swift build --target SMTPClient ``` - [ ] **Step 6: Commit** ```bash 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: ```swift 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`: ```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** ```bash cd Packages/MagnumOpusCore && swift build --target SMTPClient ``` - [ ] **Step 4: Commit** ```bash 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`: ```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\" ")) #expect(result.contains("To: \"Bob\" ")) #expect(result.contains("Subject: Hello")) #expect(result.contains("Message-ID: ")) #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: "", messageId: "reply-456@example.com" ) let result = MessageFormatter.format(message) #expect(result.contains("In-Reply-To: ")) #expect(result.contains("References: ")) } @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\" , 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** ```bash rm Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift cd Packages/MagnumOpusCore && swift test --filter SMTPClientTests ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash cd Packages/MagnumOpusCore && swift build --target SyncEngine ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash cd Packages/MagnumOpusCore && swift build --target SyncEngine ``` - [ ] **Step 3: Commit** ```bash 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: 1. Accept and store an optional `ActionQueue` 2. Call `actionQueue.flush()` at the start of `performSync()`, before connecting to IMAP ```swift // 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** ```bash cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash cd Packages/MagnumOpusCore && swift test --filter ActionQueueTests ``` - [ ] **Step 3: Commit** ```bash 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: ```swift 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: "", range: startRange.upperBound.. String? { guard let start = section.range(of: "<\(name)>"), let end = section.range(of: "", range: start.upperBound.. 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`, `smtpSecurity` properties - Update `autoDiscover()` to use `AutoDiscovery.discover(for:)` and fill SMTP fields - Update `buildConfig()` to include SMTP fields in `AccountConfig` - [ ] **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** ```bash cd Apps && xcodegen generate && xcodebuild -scheme MagnumOpus-macOS build ``` Or open in Xcode and build. - [ ] **Step 5: Commit** ```bash 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`: ```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? 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** ```bash 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`: ```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** ```bash 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 `actionQueue` property, initialized in `setup()` - Triage action methods: ```swift // 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** ```bash 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: ```swift .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** ```swift // 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`: ```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** ```bash 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: ```swift .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: ```swift 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): ```swift .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: ```swift #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** ```bash 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) - `ActionQueue` with the IMAP and SMTP client providers - Pass `actionQueue` to `SyncCoordinator` ```swift 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** ```bash 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.swift` or `MailViewModel.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: ```swift 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** ```bash 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** ```bash 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** ```bash cd Apps && xcodegen generate xcodebuild -scheme MagnumOpus-macOS build xcodebuild -scheme MagnumOpus-iOS -destination 'platform=iOS Simulator,name=iPhone 16' build ``` - [ ] **Step 4: Commit** ```bash 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** ```bash find Packages -name "Placeholder.swift" -delete ``` - [ ] **Step 3: Final commit** ```bash 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.