Files
MagnumOpus/docs/plans/2026-03-14-v0.3-implementation-plan.md
Felix Förtsch f28b44d445 move v0.1 artifacts to DELETE/, fix xcode build, bump calver to 2026.03.14
- 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>
2026-03-14 10:40:41 +01:00

3345 lines
93 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`:
```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**
```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<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**
```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><text>`. 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<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):
```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<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**
```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\" <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**
```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: "</\(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`, `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<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**
```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 | 13 | New types, migrations, MailStore queries |
| 2: IMAP Write | 46 | Protocol extension, write implementation, folder roles |
| 3: SMTPClient | 710 | New module: connection, commands, formatting, tests |
| 4: ActionQueue | 1114 | Action types, queue implementation, sync integration, tests |
| 5: Account Setup | 15 | AutoDiscovery SMTP, account setup UI |
| 6: Compose | 1617 | ComposeViewModel, ComposeView |
| 7: Triage UI | 1820 | Triage actions, toolbar/swipe/shortcuts, reply/forward buttons |
| 8: Integration | 2124 | 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 57 depend on chunk 4. Chunk 8 depends on everything.