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

93 KiB
Raw Permalink Blame History

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: SyncEngineIMAPClient + MailStore + SMTPClientModels. App targets import all five.


Chunk 1: Schema & Models

Extend the data layer for SMTP, drafts, mailbox roles, and the action queue. No networking — pure schema and types.

Task 1: New Model Types

Files:

  • Create: Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift

  • Create: Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift

  • Edit: Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift

  • Step 1: Create SMTPSecurity enum

Create Packages/MagnumOpusCore/Sources/Models/SMTPSecurity.swift:

public enum SMTPSecurity: String, Sendable, Codable {
	case ssl       // implicit TLS, port 465
	case starttls  // upgrade after connect, port 587
}
  • Step 2: Create OutgoingMessage

Create Packages/MagnumOpusCore/Sources/Models/OutgoingMessage.swift:

public struct OutgoingMessage: Sendable, Codable, Equatable {
	public var from: EmailAddress
	public var to: [EmailAddress]
	public var cc: [EmailAddress]
	public var bcc: [EmailAddress]
	public var subject: String
	public var bodyText: String
	public var inReplyTo: String?
	public var references: String?
	public var messageId: String

	public init(
		from: EmailAddress,
		to: [EmailAddress],
		cc: [EmailAddress] = [],
		bcc: [EmailAddress] = [],
		subject: String,
		bodyText: String,
		inReplyTo: String? = nil,
		references: String? = nil,
		messageId: String
	) {
		self.from = from
		self.to = to
		self.cc = cc
		self.bcc = bcc
		self.subject = subject
		self.bodyText = bodyText
		self.inReplyTo = inReplyTo
		self.references = references
		self.messageId = messageId
	}
}
  • Step 3: Add SMTP fields to AccountConfig

Edit Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift — add optional SMTP fields:

public struct AccountConfig: Sendable, Codable, Equatable {
	public var id: String
	public var name: String
	public var email: String
	public var imapHost: String
	public var imapPort: Int
	public var smtpHost: String?
	public var smtpPort: Int?
	public var smtpSecurity: SMTPSecurity?

	public init(
		id: String,
		name: String,
		email: String,
		imapHost: String,
		imapPort: Int,
		smtpHost: String? = nil,
		smtpPort: Int? = nil,
		smtpSecurity: SMTPSecurity? = nil
	) {
		self.id = id
		self.name = name
		self.email = email
		self.imapHost = imapHost
		self.imapPort = imapPort
		self.smtpHost = smtpHost
		self.smtpPort = smtpPort
		self.smtpSecurity = smtpSecurity
	}
}
  • Step 4: Verify Models compile
cd Packages/MagnumOpusCore && swift build --target Models
  • Step 5: Commit
git add -A && git commit -m "add SMTP model types: SMTPSecurity, OutgoingMessage, extend AccountConfig"

Task 2: Database Migrations

Files:

  • Edit: Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift

  • Edit: Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift

  • Edit: Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift

  • Create: Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift

  • Create: Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift

  • Step 1: Add SMTP fields to AccountRecord

Edit Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift:

import GRDB

public struct AccountRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "account"

	public var id: String
	public var name: String
	public var email: String
	public var imapHost: String
	public var imapPort: Int
	public var smtpHost: String?
	public var smtpPort: Int?
	public var smtpSecurity: String?

	public init(
		id: String,
		name: String,
		email: String,
		imapHost: String,
		imapPort: Int,
		smtpHost: String? = nil,
		smtpPort: Int? = nil,
		smtpSecurity: String? = nil
	) {
		self.id = id
		self.name = name
		self.email = email
		self.imapHost = imapHost
		self.imapPort = imapPort
		self.smtpHost = smtpHost
		self.smtpPort = smtpPort
		self.smtpSecurity = smtpSecurity
	}
}
  • Step 2: Add role field to MailboxRecord

Edit Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift:

import GRDB

public struct MailboxRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "mailbox"

	public var id: String
	public var accountId: String
	public var name: String
	public var uidValidity: Int
	public var uidNext: Int
	public var role: String?  // "trash", "archive", "sent", "drafts", "junk", or nil

	public init(
		id: String,
		accountId: String,
		name: String,
		uidValidity: Int,
		uidNext: Int,
		role: String? = nil
	) {
		self.id = id
		self.accountId = accountId
		self.name = name
		self.uidValidity = uidValidity
		self.uidNext = uidNext
		self.role = role
	}
}
  • Step 3: Create DraftRecord

Create Packages/MagnumOpusCore/Sources/MailStore/Records/DraftRecord.swift:

import GRDB

public struct DraftRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "draft"

	public var id: String
	public var accountId: String
	public var inReplyTo: String?
	public var forwardOf: String?
	public var toAddresses: String?     // JSON array of {"name", "address"}
	public var ccAddresses: String?
	public var bccAddresses: String?
	public var subject: String?
	public var bodyText: String?
	public var createdAt: String
	public var updatedAt: String

	public init(
		id: String,
		accountId: String,
		inReplyTo: String? = nil,
		forwardOf: String? = nil,
		toAddresses: String? = nil,
		ccAddresses: String? = nil,
		bccAddresses: String? = nil,
		subject: String? = nil,
		bodyText: String? = nil,
		createdAt: String,
		updatedAt: String
	) {
		self.id = id
		self.accountId = accountId
		self.inReplyTo = inReplyTo
		self.forwardOf = forwardOf
		self.toAddresses = toAddresses
		self.ccAddresses = ccAddresses
		self.bccAddresses = bccAddresses
		self.subject = subject
		self.bodyText = bodyText
		self.createdAt = createdAt
		self.updatedAt = updatedAt
	}
}
  • Step 4: Create PendingActionRecord

Create Packages/MagnumOpusCore/Sources/MailStore/Records/PendingActionRecord.swift:

import GRDB

public struct PendingActionRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "pendingAction"

	public var id: String
	public var accountId: String
	public var actionType: String   // "setFlags", "move", "delete", "send", "append"
	public var payload: String      // JSON with action-specific data
	public var createdAt: String
	public var retryCount: Int
	public var lastError: String?

	public init(
		id: String,
		accountId: String,
		actionType: String,
		payload: String,
		createdAt: String,
		retryCount: Int = 0,
		lastError: String? = nil
	) {
		self.id = id
		self.accountId = accountId
		self.actionType = actionType
		self.payload = payload
		self.createdAt = createdAt
		self.retryCount = retryCount
		self.lastError = lastError
	}
}
  • Step 5: Add v2 migrations to DatabaseSetup

Edit Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift — add four new migrations after the existing v1_fts5:

migrator.registerMigration("v2_smtp") { db in
	try db.alter(table: "account") { t in
		t.add(column: "smtpHost", .text)
		t.add(column: "smtpPort", .integer)
		t.add(column: "smtpSecurity", .text)
	}
}

migrator.registerMigration("v2_mailboxRole") { db in
	try db.alter(table: "mailbox") { t in
		t.add(column: "role", .text)
	}
}

migrator.registerMigration("v2_draft") { db in
	try db.create(table: "draft") { t in
		t.primaryKey("id", .text)
		t.belongsTo("account", onDelete: .cascade).notNull()
		t.column("inReplyTo", .text)
		t.column("forwardOf", .text)
		t.column("toAddresses", .text)
		t.column("ccAddresses", .text)
		t.column("bccAddresses", .text)
		t.column("subject", .text)
		t.column("bodyText", .text)
		t.column("createdAt", .text).notNull()
		t.column("updatedAt", .text).notNull()
	}
}

migrator.registerMigration("v2_pendingAction") { db in
	try db.create(table: "pendingAction") { t in
		t.primaryKey("id", .text)
		t.belongsTo("account", onDelete: .cascade).notNull()
		t.column("actionType", .text).notNull()
		t.column("payload", .text).notNull()
		t.column("createdAt", .text).notNull()
		t.column("retryCount", .integer).notNull().defaults(to: 0)
		t.column("lastError", .text)
	}
	try db.create(index: "idx_pendingAction_createdAt", on: "pendingAction", columns: ["createdAt"])
}
  • Step 6: Add migration tests

Create Packages/MagnumOpusCore/Tests/MailStoreTests/MigrationTests.swift:

import Testing
@testable import MailStore

@Suite("Database Migrations")
struct MigrationTests {
	@Test("v2 migrations create expected tables and columns")
	func v2Migrations() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		// Verify account has SMTP columns
		try db.read { db in
			let columns = try db.columns(in: "account").map(\.name)
			#expect(columns.contains("smtpHost"))
			#expect(columns.contains("smtpPort"))
			#expect(columns.contains("smtpSecurity"))
		}
		// Verify mailbox has role column
		try db.read { db in
			let columns = try db.columns(in: "mailbox").map(\.name)
			#expect(columns.contains("role"))
		}
		// Verify draft table exists
		try db.read { db in
			let tables = try String.fetchAll(db, sql: "SELECT name FROM sqlite_master WHERE type='table'")
			#expect(tables.contains("draft"))
			#expect(tables.contains("pendingAction"))
		}
	}

	@Test("DraftRecord round-trip")
	func draftRoundTrip() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		let now = "2026-03-14T10:00:00Z"
		let draft = DraftRecord(
			id: "d1",
			accountId: "a1",
			toAddresses: "[{\"address\":\"test@example.com\"}]",
			subject: "Test",
			bodyText: "Hello",
			createdAt: now,
			updatedAt: now
		)
		// Need account first (FK constraint)
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
			try draft.insert(db)
		}
		let loaded = try db.read { db in
			try DraftRecord.fetchOne(db, key: "d1")
		}
		#expect(loaded?.subject == "Test")
	}

	@Test("PendingActionRecord round-trip")
	func actionRoundTrip() throws {
		let db = try DatabaseSetup.openInMemoryDatabase()
		try db.write { db in
			try AccountRecord(id: "a1", name: "Test", email: "t@t.com", imapHost: "imap.t.com", imapPort: 993).insert(db)
			try PendingActionRecord(
				id: "pa1",
				accountId: "a1",
				actionType: "setFlags",
				payload: "{\"setFlags\":{\"uid\":42,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Seen\"],\"remove\":[]}}",
				createdAt: "2026-03-14T10:00:00Z"
			).insert(db)
		}
		let loaded = try db.read { db in
			try PendingActionRecord.fetchOne(db, key: "pa1")
		}
		#expect(loaded?.actionType == "setFlags")
		#expect(loaded?.retryCount == 0)
	}
}
  • Step 7: Verify MailStore compiles and migration tests pass
cd Packages/MagnumOpusCore && swift test --filter MigrationTests
  • Step 8: Commit
git add -A && git commit -m "add v2 schema migrations: smtp fields, mailbox role, draft, pendingAction"

Task 3: MailStore Query Extensions

Files:

  • Edit: Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift

  • Edit: Packages/MagnumOpusCore/Sources/MailStore/Queries.swift

  • Step 1: Add draft CRUD to MailStore

Add to MailStore.swift:

// MARK: - Drafts

public func insertDraft(_ draft: DraftRecord) throws {
	try dbWriter.write { db in
		try draft.insert(db)
	}
}

public func updateDraft(_ draft: DraftRecord) throws {
	try dbWriter.write { db in
		try draft.update(db)
	}
}

public func deleteDraft(id: String) throws {
	_ = try dbWriter.write { db in
		try DraftRecord.deleteOne(db, key: id)
	}
}

public func draft(id: String) throws -> DraftRecord? {
	try dbWriter.read { db in
		try DraftRecord.fetchOne(db, key: id)
	}
}

public func drafts(accountId: String) throws -> [DraftRecord] {
	try dbWriter.read { db in
		try DraftRecord
			.filter(Column("accountId") == accountId)
			.order(Column("updatedAt").desc)
			.fetchAll(db)
	}
}
  • Step 2: Add pending action CRUD to MailStore

Add to MailStore.swift:

// MARK: - Pending Actions

public func insertPendingAction(_ action: PendingActionRecord) throws {
	try dbWriter.write { db in
		try action.insert(db)
	}
}

public func insertPendingActions(_ actions: [PendingActionRecord]) throws {
	try dbWriter.write { db in
		for action in actions {
			try action.insert(db)
		}
	}
}

public func pendingActions(accountId: String) throws -> [PendingActionRecord] {
	try dbWriter.read { db in
		try PendingActionRecord
			.filter(Column("accountId") == accountId)
			.order(Column("createdAt").asc)
			.fetchAll(db)
	}
}

public func deletePendingAction(id: String) throws {
	_ = try dbWriter.write { db in
		try PendingActionRecord.deleteOne(db, key: id)
	}
}

public func updatePendingAction(_ action: PendingActionRecord) throws {
	try dbWriter.write { db in
		try action.update(db)
	}
}

public func pendingActionCount(accountId: String) throws -> Int {
	try dbWriter.read { db in
		try PendingActionRecord
			.filter(Column("accountId") == accountId)
			.fetchCount(db)
	}
}
  • Step 3: Add mailbox role queries

Add to MailStore.swift:

// MARK: - Mailbox Roles

public func mailboxWithRole(_ role: String, accountId: String) throws -> MailboxRecord? {
	try dbWriter.read { db in
		try MailboxRecord
			.filter(Column("accountId") == accountId)
			.filter(Column("role") == role)
			.fetchOne(db)
	}
}

public func updateMailboxRole(id: String, role: String?) throws {
	try dbWriter.write { db in
		try db.execute(
			sql: "UPDATE mailbox SET role = ? WHERE id = ?",
			arguments: [role, id]
		)
	}
}
  • Step 4: Add message flag/mailbox update methods

Add to MailStore.swift:

// MARK: - Message Mutations

public func updateMessageMailbox(messageId: String, newMailboxId: String) throws {
	try dbWriter.write { db in
		try db.execute(
			sql: "UPDATE message SET mailboxId = ? WHERE id = ?",
			arguments: [newMailboxId, messageId]
		)
	}
}

public func deleteMessage(id: String) throws {
	_ = try dbWriter.write { db in
		try MessageRecord.deleteOne(db, key: id)
	}
}

public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] {
	try dbWriter.read { db in
		try MessageRecord
			.joining(required: MessageRecord.hasOne(ThreadMessageRecord.self, using: ForeignKey(["messageId"])))
			.filter(sql: "threadMessage.threadId = ?", arguments: [threadId])
			.filter(Column("mailboxId") == mailboxId)
			.fetchAll(db)
	}
}

Note: The exact GRDB join syntax may need adjustment based on existing association definitions. If MessageRecord doesn't have an association defined, use raw SQL instead:

public func messagesInThread(threadId: String, mailboxId: String) throws -> [MessageRecord] {
	try dbWriter.read { db in
		try MessageRecord.fetchAll(db, sql: """
			SELECT message.* FROM message
			JOIN threadMessage ON threadMessage.messageId = message.id
			WHERE threadMessage.threadId = ? AND message.mailboxId = ?
			""",
			arguments: [threadId, mailboxId]
		)
	}
}
  • Step 5: Verify MailStore compiles and existing tests pass
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests
  • Step 6: Commit
git add -A && git commit -m "add MailStore queries: drafts, pending actions, mailbox roles, message mutations"

Chunk 2: IMAP Write Operations

Extend the existing IMAPClient with write capabilities (flags, move, copy, append, expunge, capabilities).

Task 4: IMAPClient Protocol Extension

Files:

  • Edit: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift

  • Edit: Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift

  • Step 1: Add write methods to IMAPClientProtocol

Edit Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift:

public protocol IMAPClientProtocol: Sendable {
	// existing v0.2 read methods
	func connect() async throws
	func disconnect() async throws
	func listMailboxes() async throws -> [IMAPMailboxInfo]
	func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus
	func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope]
	func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair]
	func fetchBody(uid: Int) async throws -> (text: String?, html: String?)

	// v0.3 write operations
	func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws
	func moveMessage(uid: Int, from: String, to: String) async throws
	func copyMessage(uid: Int, from: String, to: String) async throws
	func expunge(mailbox: String) async throws
	func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws
	func capabilities() async throws -> Set<String>
}
  • Step 2: Update MockIMAPClient with write methods

Edit Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift:

import Foundation
import IMAPClient
import Models

final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
	var mailboxes: [IMAPMailboxInfo] = []
	var mailboxStatuses: [String: IMAPMailboxStatus] = [:]
	var envelopes: [FetchedEnvelope] = []
	var mailboxEnvelopes: [String: [FetchedEnvelope]] = [:]
	var flagUpdates: [UIDFlagsPair] = []
	var bodies: [Int: (text: String?, html: String?)] = [:]

	var connectCalled = false
	var disconnectCalled = false
	var selectedMailbox: String?

	// v0.3 tracking
	var storedFlags: [(uid: Int, mailbox: String, add: [String], remove: [String])] = []
	var movedMessages: [(uid: Int, from: String, to: String)] = []
	var copiedMessages: [(uid: Int, from: String, to: String)] = []
	var expungedMailboxes: [String] = []
	var appendedMessages: [(mailbox: String, message: Data, flags: [String])] = []
	var serverCapabilities: Set<String> = ["IMAP4rev1", "MOVE"]

	func connect() async throws { connectCalled = true }
	func disconnect() async throws { disconnectCalled = true }

	func listMailboxes() async throws -> [IMAPMailboxInfo] { mailboxes }

	func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
		selectedMailbox = name
		guard let status = mailboxStatuses[name] else {
			throw MockIMAPError.mailboxNotFound(name)
		}
		return status
	}

	func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
		let source: [FetchedEnvelope]
		if let mailbox = selectedMailbox, let perMailbox = mailboxEnvelopes[mailbox] {
			source = perMailbox
		} else {
			source = envelopes
		}
		return source.filter { $0.uid > uid }
	}

	func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
		flagUpdates.filter { uids.contains($0.uid) }
	}

	func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
		bodies[uid] ?? (nil, nil)
	}

	// v0.3 write operations
	func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
		storedFlags.append((uid: uid, mailbox: mailbox, add: add, remove: remove))
	}

	func moveMessage(uid: Int, from: String, to: String) async throws {
		movedMessages.append((uid: uid, from: from, to: to))
	}

	func copyMessage(uid: Int, from: String, to: String) async throws {
		copiedMessages.append((uid: uid, from: from, to: to))
	}

	func expunge(mailbox: String) async throws {
		expungedMailboxes.append(mailbox)
	}

	func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
		appendedMessages.append((mailbox: mailbox, message: message, flags: flags))
	}

	func capabilities() async throws -> Set<String> {
		serverCapabilities
	}
}

enum MockIMAPError: Error {
	case mailboxNotFound(String)
}
  • Step 3: Verify tests still compile and pass
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
  • Step 4: Commit
git add -A && git commit -m "extend IMAPClientProtocol with write operations, update mock"

Task 5: IMAPClient Write Implementation

Files:

  • Edit: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift

  • Edit: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift (if needed)

  • Step 1: Implement storeFlags

Add to the IMAPClient actor implementation. Uses IMAP UID STORE command:

public func storeFlags(uid: Int, mailbox: String, add: [String], remove: [String]) async throws {
	try await ensureConnected()
	_ = try await runner.selectMailbox(mailbox)
	if !add.isEmpty {
		let flags = add.joined(separator: " ")
		try await runner.sendCommand("UID STORE \(uid) +FLAGS (\(flags))")
	}
	if !remove.isEmpty {
		let flags = remove.joined(separator: " ")
		try await runner.sendCommand("UID STORE \(uid) -FLAGS (\(flags))")
	}
}

Note: The actual implementation depends on how IMAPCommandRunner works with the swift-nio-imap library. The existing codebase uses NIOIMAP types, so the implementation must use the library's command types rather than raw strings. Read the existing IMAPClient.swift implementation patterns and follow them — this step describes the logic but the exact swift-nio-imap API calls need to match what's already in use.

  • Step 2: Implement moveMessage with MOVE/COPY+DELETE fallback
public func moveMessage(uid: Int, from: String, to: String) async throws {
	try await ensureConnected()
	let caps = try await capabilities()
	_ = try await runner.selectMailbox(from)
	if caps.contains("MOVE") {
		try await runner.sendCommand("UID MOVE \(uid) \(to)")
	} else {
		try await copyMessage(uid: uid, from: from, to: to)
		try await storeFlags(uid: uid, mailbox: from, add: ["\\Deleted"], remove: [])
		try await expunge(mailbox: from)
	}
}
  • Step 3: Implement copyMessage, expunge, appendMessage, capabilities
public func copyMessage(uid: Int, from: String, to: String) async throws {
	try await ensureConnected()
	_ = try await runner.selectMailbox(from)
	try await runner.sendCommand("UID COPY \(uid) \(to)")
}

public func expunge(mailbox: String) async throws {
	try await ensureConnected()
	_ = try await runner.selectMailbox(mailbox)
	try await runner.sendCommand("EXPUNGE")
}

public func appendMessage(to mailbox: String, message: Data, flags: [String]) async throws {
	try await ensureConnected()
	let flagStr = flags.isEmpty ? "" : " (\(flags.joined(separator: " ")))"
	try await runner.sendAppend(mailbox: mailbox, flags: flagStr, message: message)
}

public func capabilities() async throws -> Set<String> {
	try await ensureConnected()
	return try await runner.fetchCapabilities()
}

Important: These pseudo-implementations show the logic. The real code must use swift-nio-imap's typed command system (e.g., Command.uidStore, Command.uidMove, Command.uidCopy, Command.append). Read the existing IMAPClient.swift to match the established patterns for sending commands and handling responses.

  • Step 4: Verify IMAPClient compiles
cd Packages/MagnumOpusCore && swift build --target IMAPClient
  • Step 5: Commit
git add -A && git commit -m "implement IMAP write operations: flags, move, copy, append, expunge"

Task 6: Special Folder Detection

Files:

  • Edit: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift (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:

public struct IMAPMailboxInfo: Sendable {
	public var name: String
	public var attributes: [String]  // e.g., ["\\HasNoChildren", "\\Trash"]

	public init(name: String, attributes: [String] = []) {
		self.name = name
		self.attributes = attributes
	}
}
  • Step 2: Add role detection helper

Add a static helper (could be on MailboxRecord or a free function in SyncEngine):

/// Detect the well-known role from IMAP LIST attributes, with name-based fallback.
func detectMailboxRole(name: String, attributes: [String]) -> String? {
	let attrSet = Set(attributes.map { $0.lowercased() })
	if attrSet.contains("\\trash") { return "trash" }
	if attrSet.contains("\\archive") || attrSet.contains("\\all") { return "archive" }
	if attrSet.contains("\\sent") { return "sent" }
	if attrSet.contains("\\drafts") { return "drafts" }
	if attrSet.contains("\\junk") { return "junk" }

	// Name-based fallback
	switch name.lowercased() {
	case "trash", "deleted messages", "bin": return "trash"
	case "archive", "all mail": return "archive"
	case "sent", "sent messages", "sent mail": return "sent"
	case "drafts": return "drafts"
	case "junk", "spam": return "junk"
	default: return nil
	}
}
  • Step 3: Store role during sync

Edit SyncCoordinator.syncMailbox() to pass attributes through and store the detected role when upserting/updating mailbox records:

// In syncMailbox, after creating or finding the mailbox:
let role = detectMailboxRole(name: remoteMailbox.name, attributes: remoteMailbox.attributes)
try store.updateMailboxRole(id: mailboxId, role: role)
  • Step 4: Verify sync + role detection works
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
  • Step 5: Commit
git add -A && git commit -m "detect special folder roles from LIST attributes with name fallback"

Chunk 3: SMTPClient Module

Build the SMTP sending module from scratch using SwiftNIO + TLS.

Task 7: Package.swift Update

Files:

  • Edit: Packages/MagnumOpusCore/Package.swift

  • Step 1: Add SMTPClient target and test target

Add a new library product, target, and test target:

// In products:
.library(name: "SMTPClient", targets: ["SMTPClient"]),

// In targets:
.target(
	name: "SMTPClient",
	dependencies: [
		"Models",
		.product(name: "NIO", package: "swift-nio"),
		.product(name: "NIOSSL", package: "swift-nio-ssl"),
	]
),

// In test targets:
.testTarget(name: "SMTPClientTests", dependencies: ["SMTPClient"]),

Also add "SMTPClient" to the SyncEngine target's dependencies:

.target(
	name: "SyncEngine",
	dependencies: ["Models", "IMAPClient", "MailStore", "SMTPClient"]
),

Note: SwiftNIO is already a transitive dependency via swift-nio-imap, but for SMTPClient we need it directly. Add swift-nio as an explicit dependency if not already present:

// In dependencies (check if already present — swift-nio-imap pulls it transitively):
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
  • Step 2: Create placeholder file
mkdir -p Packages/MagnumOpusCore/Sources/SMTPClient
mkdir -p Packages/MagnumOpusCore/Tests/SMTPClientTests
echo 'enum SMTPClientPlaceholder {}' > Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift
  • Step 3: Verify package resolves
cd Packages/MagnumOpusCore && swift package resolve && swift build
  • Step 4: Commit
git add -A && git commit -m "add SMTPClient target to Package.swift"

Task 8: SMTP Connection Layer

Files:

  • Create: Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift

  • Create: Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift

  • Create: Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift

  • Create: Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift

  • Delete: Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift

  • Step 1: Create SMTPError

Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPError.swift:

public enum SMTPError: Error, Sendable {
	case notConnected
	case connectionFailed(String)
	case authenticationFailed(String)
	case recipientRejected(String)
	case sendFailed(String)
	case unexpectedResponse(code: Int, message: String)
	case tlsUpgradeFailed
}
  • Step 2: Create SMTPResponseHandler

Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPResponseHandler.swift.

SMTP responses are line-based: <3-digit code><separator><text>. Separator is - for continuation, space for final line. The handler buffers lines and delivers the complete response.

import NIOCore

struct SMTPResponse: Sendable {
	var code: Int
	var lines: [String]

	var isSuccess: Bool { code >= 200 && code < 400 }
	var message: String { lines.joined(separator: "\n") }
}

final class SMTPResponseHandler: ChannelInboundHandler, RemovableChannelHandler {
	typealias InboundIn = ByteBuffer
	typealias InboundOut = SMTPResponse

	private var buffer = ""
	private var responseLines: [String] = []
	private var continuation: CheckedContinuation<SMTPResponse, Error>?

	func channelRead(context: ChannelHandlerContext, data: NIOAny) {
		var buf = unwrapInboundIn(data)
		guard let str = buf.readString(length: buf.readableBytes) else { return }
		buffer += str

		while let newlineRange = buffer.range(of: "\r\n") {
			let line = String(buffer[buffer.startIndex..<newlineRange.lowerBound])
			buffer = String(buffer[newlineRange.upperBound...])
			processLine(line)
		}
	}

	private func processLine(_ line: String) {
		guard line.count >= 3,
			  let code = Int(line.prefix(3))
		else { return }

		let separator = line.count > 3 ? line[line.index(line.startIndex, offsetBy: 3)] : " "
		let text = line.count > 4 ? String(line.dropFirst(4)) : ""
		responseLines.append(text)

		if separator == " " {
			// Final line — deliver complete response
			let response = SMTPResponse(code: code, lines: responseLines)
			responseLines = []
			continuation?.resume(returning: response)
			continuation = nil
		}
		// separator == "-" means more lines coming
	}

	func waitForResponse() async throws -> SMTPResponse {
		try await withCheckedThrowingContinuation { cont in
			self.continuation = cont
		}
	}

	func errorCaught(context: ChannelHandlerContext, error: Error) {
		continuation?.resume(throwing: error)
		continuation = nil
		context.close(promise: nil)
	}
}

Note: This is a simplified sketch. The real implementation needs careful NIO lifecycle management — the waitForResponse() continuation must be set before channel reads arrive. Use the same patterns as IMAPResponseHandler in the existing codebase.

  • Step 3: Create SMTPConnection

Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPConnection.swift.

Actor managing the NIO channel. Handles SSL (port 465) and STARTTLS (port 587):

import NIOCore
import NIOPosix
import NIOSSL
import Models

actor SMTPConnection {
	private let host: String
	private let port: Int
	private let security: SMTPSecurity
	private var channel: Channel?
	private var responseHandler: SMTPResponseHandler?
	private let eventLoopGroup: EventLoopGroup

	init(host: String, port: Int, security: SMTPSecurity) {
		self.host = host
		self.port = port
		self.security = security
		self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
	}

	func connect() async throws {
		let handler = SMTPResponseHandler()
		self.responseHandler = handler

		var tlsConfig = TLSConfiguration.makeClientConfiguration()
		tlsConfig.certificateVerification = .fullVerification

		let bootstrap = ClientBootstrap(group: eventLoopGroup)
			.channelOption(.socketOption(.so_reuseaddr), value: 1)
			.channelInitializer { channel in
				var handlers: [ChannelHandler] = []
				if self.security == .ssl {
					let sslContext = try! NIOSSLContext(configuration: tlsConfig)
					let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: self.host)
					handlers.append(sslHandler)
				}
				handlers.append(ByteToMessageHandler(LineBasedFrameDecoder()))
				handlers.append(handler)
				return channel.pipeline.addHandlers(handlers)
			}

		channel = try await bootstrap.connect(host: host, port: port).get()
		// Read server greeting
		let greeting = try await handler.waitForResponse()
		guard greeting.isSuccess else {
			throw SMTPError.connectionFailed(greeting.message)
		}
	}

	func sendCommand(_ command: String) async throws -> SMTPResponse {
		guard let channel, let handler = responseHandler else {
			throw SMTPError.notConnected
		}
		var buf = channel.allocator.buffer(capacity: command.utf8.count + 2)
		buf.writeString(command + "\r\n")
		try await channel.writeAndFlush(buf)
		return try await handler.waitForResponse()
	}

	func upgradeToTLS() async throws {
		guard let channel else { throw SMTPError.notConnected }
		var tlsConfig = TLSConfiguration.makeClientConfiguration()
		let sslContext = try NIOSSLContext(configuration: tlsConfig)
		let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host)
		try await channel.pipeline.addHandler(sslHandler, position: .first)
	}

	func sendData(_ data: Data) async throws {
		guard let channel else { throw SMTPError.notConnected }
		var buf = channel.allocator.buffer(capacity: data.count)
		buf.writeBytes(data)
		try await channel.writeAndFlush(buf)
	}

	func disconnect() async throws {
		try await channel?.close()
		channel = nil
	}

	deinit {
		try? eventLoopGroup.syncShutdownGracefully()
	}
}

Note: This is a structural sketch. The NIO bootstrap patterns should mirror IMAPConnection.swift from the existing codebase. Pay attention to how the existing code sets up TLS, event loop groups, and channel pipelines.

  • Step 4: Create SMTPCommandRunner

Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPCommandRunner.swift.

Orchestrates the SMTP command sequence:

import Foundation
import Models

actor SMTPCommandRunner {
	private let connection: SMTPConnection
	private let credentials: Credentials

	init(connection: SMTPConnection, credentials: Credentials) {
		self.connection = connection
		self.credentials = credentials
	}

	func ehlo(hostname: String) async throws -> Set<String> {
		let response = try await connection.sendCommand("EHLO \(hostname)")
		guard response.isSuccess else {
			throw SMTPError.unexpectedResponse(code: response.code, message: response.message)
		}
		// Parse capabilities from response lines
		return Set(response.lines.map { $0.split(separator: " ").first.map(String.init) ?? $0 })
	}

	func startTLS() async throws {
		let response = try await connection.sendCommand("STARTTLS")
		guard response.code == 220 else {
			throw SMTPError.tlsUpgradeFailed
		}
		try await connection.upgradeToTLS()
	}

	func authenticate() async throws {
		// Try AUTH PLAIN first
		let credentials = "\0\(self.credentials.username)\0\(self.credentials.password)"
		let base64 = Data(credentials.utf8).base64EncodedString()
		let response = try await connection.sendCommand("AUTH PLAIN \(base64)")
		if response.isSuccess { return }

		// Fallback: AUTH LOGIN
		let loginResponse = try await connection.sendCommand("AUTH LOGIN")
		guard loginResponse.code == 334 else {
			throw SMTPError.authenticationFailed(response.message)
		}
		let userResp = try await connection.sendCommand(Data(self.credentials.username.utf8).base64EncodedString())
		guard userResp.code == 334 else {
			throw SMTPError.authenticationFailed(userResp.message)
		}
		let passResp = try await connection.sendCommand(Data(self.credentials.password.utf8).base64EncodedString())
		guard passResp.isSuccess else {
			throw SMTPError.authenticationFailed(passResp.message)
		}
	}

	func mailFrom(_ address: String) async throws {
		let response = try await connection.sendCommand("MAIL FROM:<\(address)>")
		guard response.isSuccess else {
			throw SMTPError.sendFailed("MAIL FROM rejected: \(response.message)")
		}
	}

	func rcptTo(_ address: String) async throws {
		let response = try await connection.sendCommand("RCPT TO:<\(address)>")
		guard response.isSuccess else {
			throw SMTPError.recipientRejected(address)
		}
	}

	func data(_ messageContent: String) async throws {
		let response = try await connection.sendCommand("DATA")
		guard response.code == 354 else {
			throw SMTPError.sendFailed("DATA rejected: \(response.message)")
		}
		// Send message content, dot-stuffed, ending with \r\n.\r\n
		let dotStuffed = messageContent
			.split(separator: "\n", omittingEmptySubsequences: false)
			.map { line in
				let l = String(line)
				return l.hasPrefix(".") ? "." + l : l
			}
			.joined(separator: "\r\n")
		let endResponse = try await connection.sendCommand(dotStuffed + "\r\n.")
		guard endResponse.isSuccess else {
			throw SMTPError.sendFailed("Message rejected: \(endResponse.message)")
		}
	}

	func quit() async throws {
		_ = try? await connection.sendCommand("QUIT")
		try await connection.disconnect()
	}
}
  • Step 5: Delete placeholder and verify build
rm Packages/MagnumOpusCore/Sources/SMTPClient/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target SMTPClient
  • Step 6: Commit
git add -A && git commit -m "implement SMTP connection layer: connection, response handler, command runner"

Task 9: Message Formatter & SMTPClient Public API

Files:

  • Create: Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift

  • Create: Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift

  • Step 1: Create MessageFormatter

Create Packages/MagnumOpusCore/Sources/SMTPClient/MessageFormatter.swift.

Builds RFC 5322 formatted messages:

import Foundation
import Models

public enum MessageFormatter {
	/// Formats an OutgoingMessage into an RFC 5322 message string
	public static func format(_ message: OutgoingMessage) -> String {
		var headers: [(String, String)] = []

		headers.append(("From", formatAddress(message.from)))
		if !message.to.isEmpty {
			headers.append(("To", message.to.map(formatAddress).joined(separator: ", ")))
		}
		if !message.cc.isEmpty {
			headers.append(("Cc", message.cc.map(formatAddress).joined(separator: ", ")))
		}
		// BCC is intentionally omitted from headers
		headers.append(("Subject", message.subject))
		headers.append(("Date", formatRFC2822Date(Date())))
		headers.append(("Message-ID", "<\(message.messageId)>"))

		if let inReplyTo = message.inReplyTo {
			headers.append(("In-Reply-To", "<\(inReplyTo)>"))
		}
		if let references = message.references {
			headers.append(("References", references))
		}

		headers.append(("MIME-Version", "1.0"))
		headers.append(("Content-Type", "text/plain; charset=utf-8"))
		headers.append(("Content-Transfer-Encoding", "quoted-printable"))

		var result = headers.map { "\($0.0): \($0.1)" }.joined(separator: "\r\n")
		result += "\r\n\r\n"
		result += quotedPrintableEncode(message.bodyText)

		return result
	}

	/// Generates a Message-ID: UUID@domain
	public static func generateMessageId(domain: String) -> String {
		"\(UUID().uuidString.lowercased())@\(domain)"
	}

	/// Extracts domain from email address
	public static func domainFromEmail(_ email: String) -> String {
		email.split(separator: "@").last.map(String.init) ?? "localhost"
	}

	static func formatAddress(_ addr: EmailAddress) -> String {
		if let name = addr.name, !name.isEmpty {
			return "\"\(name)\" <\(addr.address)>"
		}
		return addr.address
	}

	static func formatRFC2822Date(_ date: Date) -> String {
		let formatter = DateFormatter()
		formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
		formatter.locale = Locale(identifier: "en_US_POSIX")
		return formatter.string(from: date)
	}

	static func quotedPrintableEncode(_ text: String) -> String {
		var result = ""
		let data = Array(text.utf8)
		var lineLength = 0

		for byte in data {
			let char: String
			if byte == 0x0A {
				// Newline — emit as-is
				result += "\r\n"
				lineLength = 0
				continue
			} else if byte == 0x0D {
				// CR — skip (we add CRLF for newlines)
				continue
			} else if (byte >= 33 && byte <= 126 && byte != 61) || byte == 9 || byte == 32 {
				// Printable ASCII (except =) or tab/space
				char = String(UnicodeScalar(byte))
			} else {
				char = String(format: "=%02X", byte)
			}

			if lineLength + char.count > 75 {
				result += "=\r\n"
				lineLength = 0
			}
			result += char
			lineLength += char.count
		}

		return result
	}
}
  • Step 2: Create SMTPClient

Create Packages/MagnumOpusCore/Sources/SMTPClient/SMTPClient.swift:

import Foundation
import Models

public actor SMTPClient: Sendable {
	private let host: String
	private let port: Int
	private let security: SMTPSecurity
	private let credentials: Credentials

	public init(host: String, port: Int, security: SMTPSecurity, credentials: Credentials) {
		self.host = host
		self.port = port
		self.security = security
		self.credentials = credentials
	}

	public func send(message: OutgoingMessage) async throws {
		let connection = SMTPConnection(host: host, port: port, security: security)
		let runner = SMTPCommandRunner(connection: connection, credentials: credentials)

		try await connection.connect()

		do {
			let hostname = MessageFormatter.domainFromEmail(message.from.address)
			let caps = try await runner.ehlo(hostname: hostname)

			if security == .starttls {
				try await runner.startTLS()
				_ = try await runner.ehlo(hostname: hostname)
			}

			try await runner.authenticate()
			try await runner.mailFrom(message.from.address)

			let allRecipients = message.to + message.cc + message.bcc
			for recipient in allRecipients {
				try await runner.rcptTo(recipient.address)
			}

			let formatted = MessageFormatter.format(message)
			try await runner.data(formatted)
			try await runner.quit()
		} catch {
			try? await runner.quit()
			throw error
		}
	}

	public func testConnection() async throws {
		let connection = SMTPConnection(host: host, port: port, security: security)
		let runner = SMTPCommandRunner(connection: connection, credentials: credentials)

		try await connection.connect()

		let hostname = "localhost"
		let caps = try await runner.ehlo(hostname: hostname)

		if security == .starttls {
			try await runner.startTLS()
			_ = try await runner.ehlo(hostname: hostname)
		}

		try await runner.authenticate()
		try await runner.quit()
	}
}
  • Step 3: Verify SMTPClient compiles
cd Packages/MagnumOpusCore && swift build --target SMTPClient
  • Step 4: Commit
git add -A && git commit -m "implement SMTPClient: message formatter, public send/testConnection API"

Task 10: SMTPClient Tests

Files:

  • Create: Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift

  • Delete: Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift

  • Step 1: Create MessageFormatterTests

Create Packages/MagnumOpusCore/Tests/SMTPClientTests/MessageFormatterTests.swift:

import Testing
import Models
@testable import SMTPClient

@Suite("MessageFormatter")
struct MessageFormatterTests {
	@Test("formats basic message with required headers")
	func basicMessage() {
		let message = OutgoingMessage(
			from: EmailAddress(name: "Alice", address: "alice@example.com"),
			to: [EmailAddress(name: "Bob", address: "bob@example.com")],
			subject: "Hello",
			bodyText: "Hi Bob!",
			messageId: "test-123@example.com"
		)
		let result = MessageFormatter.format(message)

		#expect(result.contains("From: \"Alice\" <alice@example.com>"))
		#expect(result.contains("To: \"Bob\" <bob@example.com>"))
		#expect(result.contains("Subject: Hello"))
		#expect(result.contains("Message-ID: <test-123@example.com>"))
		#expect(result.contains("MIME-Version: 1.0"))
		#expect(result.contains("Content-Type: text/plain; charset=utf-8"))
		#expect(result.contains("Content-Transfer-Encoding: quoted-printable"))
		#expect(result.contains("Hi Bob!"))
	}

	@Test("includes reply headers when set")
	func replyHeaders() {
		let message = OutgoingMessage(
			from: EmailAddress(address: "a@example.com"),
			to: [EmailAddress(address: "b@example.com")],
			subject: "Re: Hello",
			bodyText: "Reply text",
			inReplyTo: "original-123@example.com",
			references: "<original-123@example.com>",
			messageId: "reply-456@example.com"
		)
		let result = MessageFormatter.format(message)

		#expect(result.contains("In-Reply-To: <original-123@example.com>"))
		#expect(result.contains("References: <original-123@example.com>"))
	}

	@Test("omits BCC from formatted headers")
	func bccOmitted() {
		let message = OutgoingMessage(
			from: EmailAddress(address: "a@example.com"),
			to: [EmailAddress(address: "b@example.com")],
			bcc: [EmailAddress(address: "secret@example.com")],
			subject: "Test",
			bodyText: "Body",
			messageId: "test@example.com"
		)
		let result = MessageFormatter.format(message)

		#expect(!result.contains("secret@example.com"))
		#expect(!result.contains("Bcc"))
	}

	@Test("formats CC with multiple recipients")
	func multipleCC() {
		let message = OutgoingMessage(
			from: EmailAddress(address: "a@example.com"),
			to: [EmailAddress(address: "b@example.com")],
			cc: [
				EmailAddress(name: "Carol", address: "c@example.com"),
				EmailAddress(address: "d@example.com"),
			],
			subject: "Test",
			bodyText: "Body",
			messageId: "test@example.com"
		)
		let result = MessageFormatter.format(message)

		#expect(result.contains("Cc: \"Carol\" <c@example.com>, d@example.com"))
	}

	@Test("quoted-printable encodes non-ASCII")
	func quotedPrintableNonAscii() {
		let encoded = MessageFormatter.quotedPrintableEncode("Grüße")
		#expect(encoded.contains("="))
		// ü is 0xC3 0xBC in UTF-8
		#expect(encoded.contains("=C3=BC"))
	}

	@Test("domain extraction from email")
	func domainExtraction() {
		#expect(MessageFormatter.domainFromEmail("alice@example.com") == "example.com")
		#expect(MessageFormatter.domainFromEmail("noat") == "localhost")
	}

	@Test("message-id generation uses domain")
	func messageIdGeneration() {
		let id = MessageFormatter.generateMessageId(domain: "example.com")
		#expect(id.hasSuffix("@example.com"))
		#expect(id.count > 20) // UUID + @ + domain
	}

	@Test("formats address without name")
	func addressWithoutName() {
		let addr = EmailAddress(address: "plain@example.com")
		#expect(MessageFormatter.formatAddress(addr) == "plain@example.com")
	}
}
  • Step 2: Delete placeholder and run tests
rm Packages/MagnumOpusCore/Tests/SMTPClientTests/Placeholder.swift
cd Packages/MagnumOpusCore && swift test --filter SMTPClientTests
  • Step 3: Commit
git add -A && git commit -m "add MessageFormatter tests: headers, reply, bcc, quoted-printable, message-id"

Chunk 4: ActionQueue

Build the offline-safe action queue that dispatches write operations.

Task 11: Action Types

Files:

  • Create: Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift

  • Step 1: Create PendingAction and ActionPayload types

Create Packages/MagnumOpusCore/Sources/SyncEngine/ActionTypes.swift:

import Foundation
import Models

public struct PendingAction: Sendable, Codable {
	public var id: String
	public var accountId: String
	public var actionType: ActionType
	public var payload: ActionPayload
	public var createdAt: Date

	public init(
		id: String = UUID().uuidString,
		accountId: String,
		actionType: ActionType,
		payload: ActionPayload,
		createdAt: Date = Date()
	) {
		self.id = id
		self.accountId = accountId
		self.actionType = actionType
		self.payload = payload
		self.createdAt = createdAt
	}
}

public enum ActionType: String, Sendable, Codable {
	case setFlags
	case move
	case delete
	case send
	case append
}

public enum ActionPayload: Sendable, Codable {
	case setFlags(uid: Int, mailbox: String, add: [String], remove: [String])
	case move(uid: Int, from: String, to: String)
	case delete(uid: Int, mailbox: String, trashMailbox: String)
	case send(message: OutgoingMessage)
	case append(mailbox: String, messageData: String, flags: [String])

	public var actionType: ActionType {
		switch self {
		case .setFlags: return .setFlags
		case .move: return .move
		case .delete: return .delete
		case .send: return .send
		case .append: return .append
		}
	}
}
  • Step 2: Verify build
cd Packages/MagnumOpusCore && swift build --target SyncEngine
  • Step 3: Commit
git add -A && git commit -m "add action queue types: PendingAction, ActionType, ActionPayload"

Task 12: ActionQueue Implementation

Files:

  • Create: Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift

  • Step 1: Implement ActionQueue

Create Packages/MagnumOpusCore/Sources/SyncEngine/ActionQueue.swift:

import Foundation
import Models
import IMAPClient
import SMTPClient
import MailStore

public actor ActionQueue {
	private let store: MailStore
	private let imapClientProvider: () -> any IMAPClientProtocol
	private let smtpClientProvider: (() -> SMTPClient)?
	private let accountId: String

	private static let maxRetries = 5

	public init(
		store: MailStore,
		accountId: String,
		imapClientProvider: @escaping () -> any IMAPClientProtocol,
		smtpClientProvider: (() -> SMTPClient)? = nil
	) {
		self.store = store
		self.accountId = accountId
		self.imapClientProvider = imapClientProvider
		self.smtpClientProvider = smtpClientProvider
	}

	// MARK: - Enqueue

	/// Enqueue a single action. Applies local change first, then attempts remote dispatch.
	public func enqueue(_ action: PendingAction) throws {
		// Phase 1: Apply local change + persist action
		try applyLocally(action)
		try persistAction(action)

		// Phase 2: Attempt immediate remote dispatch (fire-and-forget)
		Task { [weak self] in
			await self?.dispatchSingle(action)
		}
	}

	/// Enqueue multiple actions in a single transaction (e.g., send + append).
	public func enqueue(_ actions: [PendingAction]) throws {
		for action in actions {
			try applyLocally(action)
			try persistAction(action)
		}

		Task { [weak self] in
			for action in actions {
				await self?.dispatchSingle(action)
			}
		}
	}

	// MARK: - Flush

	/// Flush all pending actions. Called by SyncCoordinator before fetch.
	public func flush() async {
		guard let actions = try? store.pendingActions(accountId: accountId),
			  !actions.isEmpty
		else { return }

		for record in actions {
			guard let action = decodeAction(record) else {
				// Corrupt action — remove it
				try? store.deletePendingAction(id: record.id)
				continue
			}

			do {
				try await dispatch(action)
				try store.deletePendingAction(id: record.id)
			} catch {
				var updated = record
				updated.retryCount += 1
				updated.lastError = error.localizedDescription
				if updated.retryCount >= Self.maxRetries {
					// Exceeded retries — mark failed, remove from queue
					try? store.deletePendingAction(id: record.id)
					// TODO: Surface to user via notification
				} else {
					try? store.updatePendingAction(updated)
				}
			}
		}
	}

	public var pendingCount: Int {
		(try? store.pendingActionCount(accountId: accountId)) ?? 0
	}

	// MARK: - Local Application

	private func applyLocally(_ action: PendingAction) throws {
		switch action.payload {
		case .setFlags(let uid, let mailbox, let add, let remove):
			// Find the message by uid + mailbox and update flags
			if let messages = try? store.messages(mailboxId: mailbox) {
				if let msg = messages.first(where: { $0.uid == uid }) {
					var isRead = msg.isRead
					var isFlagged = msg.isFlagged
					if add.contains("\\Seen") { isRead = true }
					if remove.contains("\\Seen") { isRead = false }
					if add.contains("\\Flagged") { isFlagged = true }
					if remove.contains("\\Flagged") { isFlagged = false }
					try store.updateFlags(messageId: msg.id, isRead: isRead, isFlagged: isFlagged)
				}
			}

		case .move(_, _, let to):
			// Local move: update mailboxId
			// The MailStore query by uid+mailbox is needed to find the message
			break // Handled by caller via MailStore directly

		case .delete(_, _, _):
			// Local delete: handled by caller
			break

		case .send(_), .append(_, _, _):
			// No local change for send/append
			break
		}
	}

	// MARK: - Remote Dispatch

	private func dispatchSingle(_ action: PendingAction) async {
		do {
			try await dispatch(action)
			try store.deletePendingAction(id: action.id)
		} catch {
			// Failed — leave in queue for flush
			if var record = try? store.pendingActions(accountId: accountId).first(where: { $0.id == action.id }) {
				record.retryCount += 1
				record.lastError = error.localizedDescription
				try? store.updatePendingAction(record)
			}
		}
	}

	private func dispatch(_ action: PendingAction) async throws {
		switch action.payload {
		case .setFlags(let uid, let mailbox, let add, let remove):
			let imap = imapClientProvider()
			try await imap.connect()
			try await imap.storeFlags(uid: uid, mailbox: mailbox, add: add, remove: remove)
			try await imap.disconnect()

		case .move(let uid, let from, let to):
			let imap = imapClientProvider()
			try await imap.connect()
			try await imap.moveMessage(uid: uid, from: from, to: to)
			try await imap.disconnect()

		case .delete(let uid, let mailbox, let trashMailbox):
			let imap = imapClientProvider()
			try await imap.connect()
			if mailbox == trashMailbox {
				// Already in trash — permanent delete
				_ = try await imap.selectMailbox(mailbox)
				try await imap.storeFlags(uid: uid, mailbox: mailbox, add: ["\\Deleted"], remove: [])
				try await imap.expunge(mailbox: mailbox)
			} else {
				try await imap.moveMessage(uid: uid, from: mailbox, to: trashMailbox)
			}
			try await imap.disconnect()

		case .send(let message):
			guard let smtpProvider = smtpClientProvider else {
				throw SMTPError.notConnected
			}
			let smtp = smtpProvider()
			try await smtp.send(message: message)

		case .append(let mailbox, let messageData, let flags):
			let imap = imapClientProvider()
			try await imap.connect()
			guard let data = messageData.data(using: .utf8) else {
				throw SMTPError.sendFailed("Could not encode message data")
			}
			try await imap.appendMessage(to: mailbox, message: data, flags: flags)
			try await imap.disconnect()
		}
	}

	// MARK: - Serialization

	private func persistAction(_ action: PendingAction) throws {
		let payloadData = try JSONEncoder().encode(action.payload)
		let payloadJson = String(data: payloadData, encoding: .utf8) ?? "{}"
		let isoFormatter = ISO8601DateFormatter()

		try store.insertPendingAction(PendingActionRecord(
			id: action.id,
			accountId: action.accountId,
			actionType: action.actionType.rawValue,
			payload: payloadJson,
			createdAt: isoFormatter.string(from: action.createdAt)
		))
	}

	private func decodeAction(_ record: PendingActionRecord) -> PendingAction? {
		guard let data = record.payload.data(using: .utf8),
			  let payload = try? JSONDecoder().decode(ActionPayload.self, from: data),
			  let actionType = ActionType(rawValue: record.actionType)
		else { return nil }

		let isoFormatter = ISO8601DateFormatter()
		return PendingAction(
			id: record.id,
			accountId: record.accountId,
			actionType: actionType,
			payload: payload,
			createdAt: isoFormatter.date(from: record.createdAt) ?? Date()
		)
	}
}
  • Step 2: Verify build
cd Packages/MagnumOpusCore && swift build --target SyncEngine
  • Step 3: Commit
git add -A && git commit -m "implement ActionQueue: two-phase enqueue, flush, retry, dispatch"

Task 13: SyncCoordinator Integration

Files:

  • Edit: Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift

  • Step 1: Add ActionQueue to SyncCoordinator

Edit SyncCoordinator.swift to:

  1. Accept and store an optional ActionQueue
  2. Call actionQueue.flush() at the start of performSync(), before connecting to IMAP
// Add property:
private let actionQueue: ActionQueue?

// Extend init:
public init(
	accountConfig: AccountConfig,
	imapClient: any IMAPClientProtocol,
	store: MailStore,
	actionQueue: ActionQueue? = nil
) {
	self.accountConfig = accountConfig
	self.imapClient = imapClient
	self.store = store
	self.actionQueue = actionQueue
}

// In performSync(), before connecting to IMAP:
private func performSync() async throws {
	// Flush pending actions before fetching new state
	if let queue = actionQueue {
		await queue.flush()
	}

	// ... existing sync code ...
}
  • Step 2: Verify existing tests still pass
cd Packages/MagnumOpusCore && swift test --filter SyncEngineTests
  • Step 3: Commit
git add -A && git commit -m "integrate ActionQueue into SyncCoordinator: flush before fetch"

Task 14: ActionQueue Tests

Files:

  • Create: Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift

  • Step 1: Create ActionQueueTests

Create Packages/MagnumOpusCore/Tests/SyncEngineTests/ActionQueueTests.swift:

import Testing
import Foundation
@testable import SyncEngine
@testable import MailStore
@testable import IMAPClient
import Models

@Suite("ActionQueue")
struct ActionQueueTests {
	func makeStore() throws -> MailStore {
		let db = try DatabaseSetup.openInMemoryDatabase()
		return MailStore(dbWriter: db)
	}

	func seedAccount(_ store: MailStore, id: String = "a1") throws {
		try store.insertAccount(AccountRecord(
			id: id, name: "Test", email: "test@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
	}

	@Test("enqueue persists action to database")
	func enqueuePersists() async throws {
		let store = try makeStore()
		try seedAccount(store)
		let mock = MockIMAPClient()
		let queue = ActionQueue(
			store: store,
			accountId: "a1",
			imapClientProvider: { mock }
		)

		let action = PendingAction(
			accountId: "a1",
			actionType: .setFlags,
			payload: .setFlags(uid: 1, mailbox: "INBOX", add: ["\\Seen"], remove: [])
		)
		try await queue.enqueue(action)

		// Give dispatch time to attempt
		try await Task.sleep(for: .milliseconds(100))

		// If dispatch succeeded, action is removed. If not, it's still there.
		// With mock, dispatch will succeed → action removed
		let remaining = try store.pendingActions(accountId: "a1")
		// MockIMAPClient methods succeed, so action should be dispatched and removed
		// But the mock doesn't actually connect, so it may throw
		// The exact behavior depends on implementation details
	}

	@Test("flush dispatches actions in order")
	func flushOrder() async throws {
		let store = try makeStore()
		try seedAccount(store)
		let mock = MockIMAPClient()
		mock.mailboxStatuses["INBOX"] = IMAPMailboxStatus(
			name: "INBOX", uidValidity: 1, uidNext: 10, messageCount: 5, recentCount: 0
		)
		let queue = ActionQueue(
			store: store,
			accountId: "a1",
			imapClientProvider: { mock }
		)

		// Persist actions directly (bypassing immediate dispatch)
		let now = ISO8601DateFormatter().string(from: Date())
		try store.insertPendingActions([
			PendingActionRecord(
				id: "pa1", accountId: "a1", actionType: "setFlags",
				payload: "{\"setFlags\":{\"uid\":1,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Seen\"],\"remove\":[]}}",
				createdAt: now
			),
			PendingActionRecord(
				id: "pa2", accountId: "a1", actionType: "setFlags",
				payload: "{\"setFlags\":{\"uid\":2,\"mailbox\":\"INBOX\",\"add\":[\"\\\\Flagged\"],\"remove\":[]}}",
				createdAt: now
			),
		])

		await queue.flush()

		// Verify actions were dispatched
		#expect(mock.storedFlags.count == 2)
	}

	@Test("pending count reflects queue state")
	func pendingCountReflects() async throws {
		let store = try makeStore()
		try seedAccount(store)
		let mock = MockIMAPClient()
		let queue = ActionQueue(
			store: store,
			accountId: "a1",
			imapClientProvider: { mock }
		)

		#expect(await queue.pendingCount == 0)

		let now = ISO8601DateFormatter().string(from: Date())
		try store.insertPendingAction(PendingActionRecord(
			id: "pa1", accountId: "a1", actionType: "send",
			payload: "{\"send\":{\"message\":{}}}",
			createdAt: now
		))

		#expect(await queue.pendingCount == 1)
	}
}
  • Step 2: Run tests
cd Packages/MagnumOpusCore && swift test --filter ActionQueueTests
  • Step 3: Commit
git add -A && git commit -m "add ActionQueue tests: enqueue, flush, pending count"

Chunk 5: Account Setup & AutoDiscovery

Extend auto-discovery and account setup for SMTP.

Task 15: AutoDiscovery SMTP Extension

Files:

  • Edit: Apps/MagnumOpus/Services/AutoDiscovery.swift

  • Step 1: Extend AutoDiscovery for SMTP

Rename discoverIMAP(for:) to discover(for:) and return both IMAP and SMTP settings:

struct DiscoveredConfig: Sendable {
	var imap: DiscoveredServer?
	var smtp: DiscoveredServer?
}

enum AutoDiscovery {
	static func discover(for email: String) async -> DiscoveredConfig {
		guard let domain = email.split(separator: "@").last.map(String.init) else {
			return DiscoveredConfig(imap: nil, smtp: nil)
		}

		if let config = await queryISPDB(domain: domain) {
			return config
		}

		// Fallback: probe common hostnames
		let imap = await probeIMAP(domain: domain)
		let smtp = await probeSMTP(domain: domain)
		return DiscoveredConfig(imap: imap, smtp: smtp)
	}

	// Keep the old method as a convenience wrapper for backwards compatibility
	static func discoverIMAP(for email: String) async -> DiscoveredServer? {
		await discover(for: email).imap
	}

	private static func queryISPDB(domain: String) async -> DiscoveredConfig? {
		let url = URL(string: "https://autoconfig.thunderbird.net/v1.1/\(domain)")!
		guard let (data, response) = try? await URLSession.shared.data(from: url),
			  let httpResponse = response as? HTTPURLResponse,
			  httpResponse.statusCode == 200,
			  let xml = String(data: data, encoding: .utf8)
		else { return nil }

		let imap = parseISPDBXML(xml, serverType: "imap", tag: "incomingServer")
		let smtp = parseISPDBXML(xml, serverType: "smtp", tag: "outgoingServer")
		guard imap != nil || smtp != nil else { return nil }
		return DiscoveredConfig(imap: imap, smtp: smtp)
	}

	private static func parseISPDBXML(_ xml: String, serverType: String, tag: String) -> DiscoveredServer? {
		guard let startRange = xml.range(of: "<\(tag) type=\"\(serverType)\">"),
			  let endRange = xml.range(of: "</\(tag)>", range: startRange.upperBound..<xml.endIndex)
		else { return nil }

		let section = String(xml[startRange.upperBound..<endRange.lowerBound])

		func extractTag(_ name: String) -> String? {
			guard let start = section.range(of: "<\(name)>"),
				  let end = section.range(of: "</\(name)>", range: start.upperBound..<section.endIndex)
			else { return nil }
			return String(section[start.upperBound..<end.lowerBound])
		}

		guard let hostname = extractTag("hostname"),
			  let portStr = extractTag("port"),
			  let port = Int(portStr)
		else { return nil }

		let socketType = extractTag("socketType") ?? "SSL"
		return DiscoveredServer(hostname: hostname, port: port, socketType: socketType)
	}

	private static func probeIMAP(domain: String) async -> DiscoveredServer? {
		for candidate in ["imap.\(domain)", "mail.\(domain)"] {
			if await testConnection(host: candidate, port: 993) {
				return DiscoveredServer(hostname: candidate, port: 993, socketType: "SSL")
			}
		}
		return nil
	}

	private static func probeSMTP(domain: String) async -> DiscoveredServer? {
		// Try submission port (587 STARTTLS) first, then 465 SSL
		for candidate in ["smtp.\(domain)", "mail.\(domain)"] {
			if await testConnection(host: candidate, port: 587) {
				return DiscoveredServer(hostname: candidate, port: 587, socketType: "STARTTLS")
			}
			if await testConnection(host: candidate, port: 465) {
				return DiscoveredServer(hostname: candidate, port: 465, socketType: "SSL")
			}
		}
		return nil
	}

	private static func testConnection(host: String, port: Int) async -> Bool {
		// ... existing implementation unchanged ...
	}
}
  • Step 2: Update AccountSetupViewModel for SMTP

Edit Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift:

  • Add smtpHost, smtpPort, 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

cd Apps && xcodegen generate && xcodebuild -scheme MagnumOpus-macOS build

Or open in Xcode and build.

  • Step 5: Commit
git add -A && git commit -m "extend auto-discovery, account setup for SMTP host/port/security"

Chunk 6: Compose Flow

Build the compose UI and ViewModel for new messages, replies, and forwards.

Task 16: ComposeViewModel

Files:

  • Create: Apps/MagnumOpus/ViewModels/ComposeViewModel.swift

  • Step 1: Create ComposeViewModel

Create Apps/MagnumOpus/ViewModels/ComposeViewModel.swift:

import Foundation
import Models
import MailStore
import SyncEngine
import SMTPClient

enum ComposeMode: Sendable {
	case new
	case reply(to: MessageSummary)
	case replyAll(to: MessageSummary)
	case forward(of: MessageSummary)
	case draft(DraftRecord)
}

@Observable @MainActor
final class ComposeViewModel {
	var to: String = ""
	var cc: String = ""
	var bcc: String = ""
	var subject: String = ""
	var bodyText: String = ""
	var mode: ComposeMode = .new
	var isSending = false
	var errorMessage: String?

	private var draftId: String?
	private var savedTo: String = ""
	private var savedCc: String = ""
	private var savedBcc: String = ""
	private var savedSubject: String = ""
	private var savedBody: String = ""
	private var autoSaveTask: Task<Void, Never>?

	private let accountConfig: AccountConfig
	private let store: MailStore
	private let actionQueue: ActionQueue

	var isDirty: Bool {
		to != savedTo || cc != savedCc || bcc != savedBcc ||
		subject != savedSubject || bodyText != savedBody
	}

	init(
		mode: ComposeMode,
		accountConfig: AccountConfig,
		store: MailStore,
		actionQueue: ActionQueue
	) {
		self.mode = mode
		self.accountConfig = accountConfig
		self.store = store
		self.actionQueue = actionQueue
		prefill(mode: mode)
		startAutoSave()
	}

	deinit {
		autoSaveTask?.cancel()
	}

	// MARK: - Prefill

	private func prefill(mode: ComposeMode) {
		switch mode {
		case .new:
			break

		case .reply(let msg):
			to = msg.from.address
			subject = prefixSubject("Re:", msg.subject ?? "")
			bodyText = quoteBody(msg)
			// Threading handled at send time

		case .replyAll(let msg):
			to = msg.from.address
			// Add other recipients minus self
			let others = (msg.to + msg.cc)
				.filter { $0.address.lowercased() != accountConfig.email.lowercased() }
				.map { $0.address }
			cc = others.joined(separator: ", ")
			subject = prefixSubject("Re:", msg.subject ?? "")
			bodyText = quoteBody(msg)

		case .forward(let msg):
			subject = prefixSubject("Fwd:", msg.subject ?? "")
			bodyText = forwardBody(msg)

		case .draft(let draft):
			draftId = draft.id
			to = decodeAddressField(draft.toAddresses) ?? ""
			cc = decodeAddressField(draft.ccAddresses) ?? ""
			bcc = decodeAddressField(draft.bccAddresses) ?? ""
			subject = draft.subject ?? ""
			bodyText = draft.bodyText ?? ""
		}

		// Save initial state for dirty tracking
		savedTo = to
		savedCc = cc
		savedBcc = bcc
		savedSubject = subject
		savedBody = bodyText
	}

	// MARK: - Send

	func send() async throws {
		isSending = true
		errorMessage = nil

		do {
			let fromAddr = EmailAddress(name: accountConfig.name, address: accountConfig.email)
			let toAddrs = parseAddressList(to)
			let ccAddrs = parseAddressList(cc)
			let bccAddrs = parseAddressList(bcc)

			guard !toAddrs.isEmpty else {
				throw ComposeError.noRecipients
			}

			let domain = MessageFormatter.domainFromEmail(accountConfig.email)
			let messageId = MessageFormatter.generateMessageId(domain: domain)

			var inReplyTo: String?
			var references: String?

			switch mode {
			case .reply(let msg), .replyAll(let msg):
				inReplyTo = msg.messageId
				if let existingRefs = msg.references {
					references = existingRefs + " <\(msg.messageId ?? "")>"
				} else if let msgId = msg.messageId {
					references = "<\(msgId)>"
				}
			case .forward(let msg):
				if let msgId = msg.messageId {
					references = "<\(msgId)>"
				}
			default:
				break
			}

			let outgoing = OutgoingMessage(
				from: fromAddr,
				to: toAddrs,
				cc: ccAddrs,
				bcc: bccAddrs,
				subject: subject,
				bodyText: bodyText,
				inReplyTo: inReplyTo,
				references: references,
				messageId: messageId
			)

			// Format the message for Sent folder append
			let formatted = MessageFormatter.format(outgoing)

			// Enqueue send + append atomically
			let sendAction = PendingAction(
				accountId: accountConfig.id,
				actionType: .send,
				payload: .send(message: outgoing)
			)
			let appendAction = PendingAction(
				accountId: accountConfig.id,
				actionType: .append,
				payload: .append(mailbox: "Sent", messageData: formatted, flags: ["\\Seen"])
			)

			try await actionQueue.enqueue([sendAction, appendAction])

			// Clean up draft
			if let draftId {
				try store.deleteDraft(id: draftId)
			}

			isSending = false
		} catch {
			isSending = false
			errorMessage = error.localizedDescription
			throw error
		}
	}

	// MARK: - Drafts

	func saveDraft() throws {
		let now = ISO8601DateFormatter().string(from: Date())
		let id = draftId ?? UUID().uuidString

		let draft = DraftRecord(
			id: id,
			accountId: accountConfig.id,
			inReplyTo: replyMessageId,
			forwardOf: forwardMessageId,
			toAddresses: encodeAddressField(to),
			ccAddresses: encodeAddressField(cc),
			bccAddresses: encodeAddressField(bcc),
			subject: subject,
			bodyText: bodyText,
			createdAt: draftId == nil ? now : now, // Keep original if updating
			updatedAt: now
		)

		if draftId != nil {
			try store.updateDraft(draft)
		} else {
			try store.insertDraft(draft)
			draftId = id
		}

		savedTo = to
		savedCc = cc
		savedBcc = bcc
		savedSubject = subject
		savedBody = bodyText
	}

	func deleteDraft() throws {
		if let draftId {
			try store.deleteDraft(id: draftId)
		}
	}

	// MARK: - Auto-Save

	private func startAutoSave() {
		autoSaveTask = Task { [weak self] in
			while !Task.isCancelled {
				try? await Task.sleep(for: .seconds(10))
				guard let self, !Task.isCancelled else { break }
				if self.isDirty {
					try? self.saveDraft()
				}
			}
		}
	}

	// MARK: - Helpers

	private var replyMessageId: String? {
		switch mode {
		case .reply(let msg), .replyAll(let msg): return msg.messageId
		default: return nil
		}
	}

	private var forwardMessageId: String? {
		switch mode {
		case .forward(let msg): return msg.messageId
		default: return nil
		}
	}

	private func prefixSubject(_ prefix: String, _ original: String) -> String {
		let stripped = original
			.replacingOccurrences(of: "^(Re:|Fwd:|Fw:)\\s*", with: "", options: .regularExpression)
		return "\(prefix) \(stripped)"
	}

	private func quoteBody(_ msg: MessageSummary) -> String {
		let dateStr = msg.date.map { "\($0)" } ?? "unknown date"
		let sender = msg.from.displayName
		let quoted = (msg.bodyText ?? "")
			.split(separator: "\n", omittingEmptySubsequences: false)
			.map { "> \($0)" }
			.joined(separator: "\n")
		return "\n\nOn \(dateStr), \(sender) wrote:\n\(quoted)"
	}

	private func forwardBody(_ msg: MessageSummary) -> String {
		let sender = MessageFormatter.formatAddress(msg.from)
		let dateStr = msg.date.map { "\($0)" } ?? ""
		let toStr = msg.to.map(MessageFormatter.formatAddress).joined(separator: ", ")
		return """

		---------- Forwarded message ----------
		From: \(sender)
		Date: \(dateStr)
		Subject: \(msg.subject ?? "")
		To: \(toStr)

		\(msg.bodyText ?? "")
		"""
	}

	private func parseAddressList(_ field: String) -> [EmailAddress] {
		field.split(separator: ",")
			.map { String($0).trimmingCharacters(in: .whitespaces) }
			.filter { !$0.isEmpty }
			.map { EmailAddress.parse($0) }
	}

	private func encodeAddressField(_ field: String) -> String? {
		let addrs = parseAddressList(field)
		guard !addrs.isEmpty else { return nil }
		guard let data = try? JSONEncoder().encode(addrs) else { return nil }
		return String(data: data, encoding: .utf8)
	}

	private func decodeAddressField(_ json: String?) -> String? {
		guard let json, let data = json.data(using: .utf8),
			  let addrs = try? JSONDecoder().decode([EmailAddress].self, from: data)
		else { return nil }
		return addrs.map(\.address).joined(separator: ", ")
	}
}

enum ComposeError: Error, LocalizedError {
	case noRecipients

	var errorDescription: String? {
		switch self {
		case .noRecipients: return "At least one recipient is required"
		}
	}
}
  • Step 2: Verify app compiles

Build the app target to ensure ComposeViewModel integrates cleanly.

  • Step 3: Commit
git add -A && git commit -m "add ComposeViewModel: new, reply, reply-all, forward, draft auto-save"

Task 17: ComposeView

Files:

  • Create: Apps/MagnumOpus/Views/ComposeView.swift

  • Step 1: Create ComposeView

Create Apps/MagnumOpus/Views/ComposeView.swift:

import SwiftUI
import Models

struct ComposeView: View {
	@Bindable var viewModel: ComposeViewModel
	@Environment(\.dismiss) private var dismiss
	@State private var showBcc = false

	var body: some View {
		VStack(spacing: 0) {
			// Header fields
			Form {
				TextField("To:", text: $viewModel.to)
					.textContentType(.emailAddress)
				TextField("CC:", text: $viewModel.cc)
					.textContentType(.emailAddress)
				if showBcc {
					TextField("BCC:", text: $viewModel.bcc)
						.textContentType(.emailAddress)
				}
				TextField("Subject:", text: $viewModel.subject)
			}
			.formStyle(.grouped)
			.frame(maxHeight: 200)

			Divider()

			// Body
			TextEditor(text: $viewModel.bodyText)
				.font(.body)
				.padding(8)

			if let error = viewModel.errorMessage {
				Text(error)
					.foregroundStyle(.red)
					.font(.caption)
					.padding(.horizontal)
			}
		}
		.toolbar {
			ToolbarItem(placement: .cancellationAction) {
				Button("Discard") {
					try? viewModel.deleteDraft()
					dismiss()
				}
			}
			ToolbarItem {
				Button(showBcc ? "Hide BCC" : "BCC") {
					showBcc.toggle()
				}
			}
			ToolbarItem(placement: .confirmationAction) {
				Button {
					Task {
						try? await viewModel.send()
						dismiss()
					}
				} label: {
					if viewModel.isSending {
						ProgressView()
							.controlSize(.small)
					} else {
						Label("Send", systemImage: "paperplane")
					}
				}
				.disabled(viewModel.to.isEmpty || viewModel.isSending)
				.keyboardShortcut(.return, modifiers: .command)
			}
		}
		#if os(macOS)
		.frame(minWidth: 500, minHeight: 400)
		#endif
		.onDisappear {
			// Save draft if dirty and not sent
			if viewModel.isDirty && !viewModel.isSending {
				try? viewModel.saveDraft()
			}
		}
	}
}
  • Step 2: Verify app compiles

Build the app target.

  • Step 3: Commit
git add -A && git commit -m "add ComposeView: to/cc/bcc/subject/body, send, discard, draft save"

Chunk 7: Triage UI

Add triage actions (archive, delete, flag, mark read, move) to the thread list.

Task 18: MailViewModel Triage Actions

Files:

  • Edit: Apps/MagnumOpus/ViewModels/MailViewModel.swift

  • Step 1: Add ActionQueue and triage methods to MailViewModel

Add to MailViewModel:

  • An actionQueue property, initialized in setup()
  • Triage action methods:
// MARK: - Triage Actions

func archiveSelectedThread() async {
	guard let thread = selectedThread else { return }
	guard let archiveMailbox = try? store?.mailboxWithRole("archive", accountId: thread.accountId),
		  let selectedMailbox
	else { return }

	let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
	for msg in messages ?? [] {
		let action = PendingAction(
			accountId: thread.accountId,
			actionType: .move,
			payload: .move(uid: msg.uid, from: selectedMailbox.name, to: archiveMailbox.name)
		)
		try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: archiveMailbox.id)
		try? await actionQueue?.enqueue(action)
	}
	autoAdvance()
}

func deleteSelectedThread() async {
	guard let thread = selectedThread else { return }
	guard let trashMailbox = try? store?.mailboxWithRole("trash", accountId: thread.accountId),
		  let selectedMailbox
	else { return }

	let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
	for msg in messages ?? [] {
		if selectedMailbox.id == trashMailbox.id {
			// Permanent delete
			let action = PendingAction(
				accountId: thread.accountId,
				actionType: .delete,
				payload: .delete(uid: msg.uid, mailbox: trashMailbox.name, trashMailbox: trashMailbox.name)
			)
			try? store?.deleteMessage(id: msg.id)
			try? await actionQueue?.enqueue(action)
		} else {
			let action = PendingAction(
				accountId: thread.accountId,
				actionType: .move,
				payload: .move(uid: msg.uid, from: selectedMailbox.name, to: trashMailbox.name)
			)
			try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: trashMailbox.id)
			try? await actionQueue?.enqueue(action)
		}
	}
	autoAdvance()
}

func toggleFlagSelectedThread() async {
	guard let thread = selectedThread, let selectedMailbox else { return }
	let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
	guard let firstMsg = messages?.first else { return }

	let newFlagged = !firstMsg.isFlagged
	for msg in messages ?? [] {
		let action = PendingAction(
			accountId: thread.accountId,
			actionType: .setFlags,
			payload: .setFlags(
				uid: msg.uid,
				mailbox: selectedMailbox.name,
				add: newFlagged ? ["\\Flagged"] : [],
				remove: newFlagged ? [] : ["\\Flagged"]
			)
		)
		try? store?.updateFlags(messageId: msg.id, isRead: msg.isRead, isFlagged: newFlagged)
		try? await actionQueue?.enqueue(action)
	}
}

func toggleReadSelectedThread() async {
	guard let thread = selectedThread, let selectedMailbox else { return }
	let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
	guard let firstMsg = messages?.first else { return }

	let newRead = !firstMsg.isRead
	for msg in messages ?? [] {
		let action = PendingAction(
			accountId: thread.accountId,
			actionType: .setFlags,
			payload: .setFlags(
				uid: msg.uid,
				mailbox: selectedMailbox.name,
				add: newRead ? ["\\Seen"] : [],
				remove: newRead ? [] : ["\\Seen"]
			)
		)
		try? store?.updateFlags(messageId: msg.id, isRead: newRead, isFlagged: msg.isFlagged)
		try? await actionQueue?.enqueue(action)
	}
}

func moveSelectedThread(to mailbox: MailboxInfo) async {
	guard let thread = selectedThread, let selectedMailbox else { return }
	guard let targetMailbox = try? store?.mailbox(id: mailbox.id) else { return }

	let messages = try? store?.messagesInThread(threadId: thread.id, mailboxId: selectedMailbox.id)
	for msg in messages ?? [] {
		let action = PendingAction(
			accountId: thread.accountId,
			actionType: .move,
			payload: .move(uid: msg.uid, from: selectedMailbox.name, to: targetMailbox.name)
		)
		try? store?.updateMessageMailbox(messageId: msg.id, newMailboxId: targetMailbox.id)
		try? await actionQueue?.enqueue(action)
	}
	autoAdvance()
}

private func autoAdvance() {
	guard let current = selectedThread,
		  let idx = threads.firstIndex(where: { $0.id == current.id })
	else {
		selectedThread = nil
		return
	}
	if idx + 1 < threads.count {
		selectedThread = threads[idx + 1]
	} else if idx > 0 {
		selectedThread = threads[idx - 1]
	} else {
		selectedThread = nil
	}
}
  • Step 2: Verify app compiles

  • Step 3: Commit

git add -A && git commit -m "add triage actions to MailViewModel: archive, delete, flag, read, move"

Task 19: ThreadListView Triage UI

Files:

  • Edit: Apps/MagnumOpus/Views/ThreadListView.swift

  • Create: Apps/MagnumOpus/Views/MoveToSheet.swift

  • Step 1: Add toolbar buttons to ThreadListView

Edit ThreadListView.swift to add triage toolbar:

.toolbar {
	ToolbarItemGroup {
		if viewModel.selectedThread != nil {
			Button { Task { await viewModel.archiveSelectedThread() } } label: {
				Label("Archive", systemImage: "archivebox")
			}
			.keyboardShortcut("e", modifiers: [])

			Button { Task { await viewModel.deleteSelectedThread() } } label: {
				Label("Delete", systemImage: "trash")
			}
			.keyboardShortcut(.delete, modifiers: [])

			Button { Task { await viewModel.toggleFlagSelectedThread() } } label: {
				Label("Flag", systemImage: "flag")
			}
			.keyboardShortcut("s", modifiers: [])

			Button { Task { await viewModel.toggleReadSelectedThread() } } label: {
				Label("Read/Unread", systemImage: "envelope.badge")
			}
			.keyboardShortcut("u", modifiers: [.shift, .command])

			Button { showMoveSheet = true } label: {
				Label("Move", systemImage: "folder")
			}
			.keyboardShortcut("m", modifiers: [.shift, .command])
		}
	}
}
  • Step 2: Add swipe actions to thread rows
// On each thread row in the List:
.swipeActions(edge: .leading) {
	Button {
		Task { await viewModel.archiveSelectedThread() }
	} label: {
		Label("Archive", systemImage: "archivebox")
	}
	.tint(.green)
}
.swipeActions(edge: .trailing) {
	Button(role: .destructive) {
		Task { await viewModel.deleteSelectedThread() }
	} label: {
		Label("Delete", systemImage: "trash")
	}
}
  • Step 3: Create MoveToSheet

Create Apps/MagnumOpus/Views/MoveToSheet.swift:

import SwiftUI
import Models

struct MoveToSheet: View {
	let mailboxes: [MailboxInfo]
	let onSelect: (MailboxInfo) -> Void
	@Environment(\.dismiss) private var dismiss
	@State private var searchText = ""

	var filteredMailboxes: [MailboxInfo] {
		if searchText.isEmpty { return mailboxes }
		return mailboxes.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
	}

	var body: some View {
		NavigationStack {
			List(filteredMailboxes, id: \.id) { mailbox in
				Button {
					onSelect(mailbox)
					dismiss()
				} label: {
					Label(mailbox.name, systemImage: mailbox.systemImage)
				}
			}
			.searchable(text: $searchText, prompt: "Search folders")
			.navigationTitle("Move to…")
			#if os(iOS)
			.navigationBarTitleDisplayMode(.inline)
			#endif
			.toolbar {
				ToolbarItem(placement: .cancellationAction) {
					Button("Cancel") { dismiss() }
				}
			}
		}
		#if os(macOS)
		.frame(minWidth: 300, minHeight: 400)
		#endif
	}
}
  • Step 4: Verify app compiles

  • Step 5: Commit

git add -A && git commit -m "add triage ui: toolbar buttons, keyboard shortcuts, swipe actions, move sheet"

Task 20: Reply/Forward/Compose Buttons

Files:

  • Edit: Apps/MagnumOpus/Views/ThreadDetailView.swift

  • Edit: Apps/MagnumOpus/Views/ContentView.swift (or wherever compose is triggered)

  • Step 1: Add reply/forward buttons to ThreadDetailView toolbar

Add to the ThreadDetailView toolbar:

.toolbar {
	ToolbarItemGroup {
		Button { openCompose(.reply(to: lastMessage)) } label: {
			Label("Reply", systemImage: "arrowshape.turn.up.left")
		}
		.keyboardShortcut("r", modifiers: .command)

		Button { openCompose(.replyAll(to: lastMessage)) } label: {
			Label("Reply All", systemImage: "arrowshape.turn.up.left.2")
		}
		.keyboardShortcut("r", modifiers: [.command, .shift])

		Button { openCompose(.forward(of: lastMessage)) } label: {
			Label("Forward", systemImage: "arrowshape.turn.up.right")
		}
		.keyboardShortcut("f", modifiers: .command)
	}
}
  • Step 2: Add compose new button to main toolbar

In the sidebar or main toolbar, add a "New Message" button:

Button { openCompose(.new) } label: {
	Label("New Message", systemImage: "square.and.pencil")
}
.keyboardShortcut("n", modifiers: .command)
  • Step 3: Wire compose presentation

Use a @State var composeMode: ComposeMode? and present ComposeView as a sheet (iOS) or new window (macOS):

.sheet(item: $composeMode) { mode in
	NavigationStack {
		ComposeView(viewModel: ComposeViewModel(
			mode: mode,
			accountConfig: accountConfig,
			store: store,
			actionQueue: actionQueue
		))
	}
}

For macOS, consider using openWindow if a separate compose window is desired:

#if os(macOS)
WindowGroup("Compose", for: ComposeMode.self) { $mode in
	if let mode {
		ComposeView(viewModel: ComposeViewModel(
			mode: mode,
			accountConfig: accountConfig,
			store: store,
			actionQueue: actionQueue
		))
	}
}
#endif
  • Step 4: Verify app compiles and runs

  • Step 5: Commit

git add -A && git commit -m "add reply, reply-all, forward, compose new buttons with keyboard shortcuts"

Chunk 8: Integration & Polish

Task 21: End-to-End Wiring

Files:

  • Edit: Apps/MagnumOpus/ViewModels/MailViewModel.swift

  • Edit: Apps/MagnumOpus/ContentView.swift

  • Step 1: Initialize ActionQueue and SMTPClient in MailViewModel.setup()

When setup(config:credentials:) is called, also create:

  • SMTPClient (if SMTP fields are configured)
  • ActionQueue with the IMAP and SMTP client providers
  • Pass actionQueue to SyncCoordinator
func setup(config: AccountConfig, credentials: Credentials) {
	// ... existing store + IMAP client setup ...

	let smtpClient: SMTPClient? = {
		guard let host = config.smtpHost,
			  let port = config.smtpPort,
			  let security = config.smtpSecurity
		else { return nil }
		return SMTPClient(host: host, port: port, security: security, credentials: credentials)
	}()

	let queue = ActionQueue(
		store: store,
		accountId: config.id,
		imapClientProvider: { IMAPClient(host: config.imapHost, port: config.imapPort, credentials: credentials) },
		smtpClientProvider: smtpClient.map { client in { client } }
	)
	self.actionQueue = queue

	let coordinator = SyncCoordinator(
		accountConfig: config,
		imapClient: imapClient,
		store: store,
		actionQueue: queue
	)
	self.coordinator = coordinator
}
  • Step 2: Pass dependencies to ComposeViewModel from ContentView

Ensure ContentView or whatever view triggers compose has access to accountConfig, store, and actionQueue to create ComposeViewModel.

  • Step 3: Verify full app compiles and basic flow works

Open the app, configure an account with SMTP, compose and send a test email. Verify:

  • Compose opens from toolbar button or keyboard shortcut

  • Reply prefills correctly

  • Send enqueues action

  • Sync flushes queue

  • Step 4: Commit

git add -A && git commit -m "wire end-to-end: ActionQueue, SMTPClient, ComposeViewModel in app"

Task 22: Body Fetch for Reply/Forward

Files:

  • Edit: Apps/MagnumOpus/ViewModels/ComposeViewModel.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:

func openCompose(_ mode: ComposeMode) {
	switch mode {
	case .reply(let msg), .replyAll(let msg), .forward(let msg):
		if msg.bodyText == nil {
			Task {
				// Try to fetch the body
				do {
					let (text, html) = try await imapClient.fetchBody(uid: msg.uid)
					if let text {
						try store.storeBody(messageId: msg.id, text: text, html: html)
					}
					// Re-read and open compose with body
					if let updated = try? store.message(id: msg.id) {
						let updatedSummary = /* convert to MessageSummary with body */
						self.composeMode = mode // with updated message
					}
				} catch {
					// Open without body — offline fallback
					self.composeMode = mode
				}
			}
			return
		}
		self.composeMode = mode

	default:
		self.composeMode = mode
	}
}
  • Step 2: Verify reply with body works

  • Step 3: Commit

git add -A && git commit -m "fetch message body before compose reply/forward, offline fallback"

Task 23: Final Integration Tests

Files:

  • Edit: existing test files as needed

  • Step 1: Run all existing tests

cd Packages/MagnumOpusCore && swift test

Fix any failures from the v0.3 changes.

  • Step 2: Add SyncCoordinator test for flush-before-fetch

Add a test to SyncCoordinatorTests.swift that verifies the ActionQueue is flushed before IMAP fetch during sync.

  • Step 3: Verify app builds for both macOS and iOS
cd Apps && xcodegen generate
xcodebuild -scheme MagnumOpus-macOS build
xcodebuild -scheme MagnumOpus-iOS -destination 'platform=iOS Simulator,name=iPhone 16' build
  • Step 4: Commit
git add -A && git commit -m "fix test failures, verify builds for macOS and iOS"

Task 24: Version Bump & Cleanup

  • Step 1: Bump CalVer

Update version to 2026.03.14 in whatever config tracks it (e.g., project.yml, Info.plist, or CLAUDE.md).

  • Step 2: Remove any leftover placeholder files
find Packages -name "Placeholder.swift" -delete
  • Step 3: Final commit
git add -A && git commit -m "bump calver to 2026.03.14, v0.3: compose, triage, smtp, action queue"

Summary

Chunk Tasks Focus
1: Schema & Models 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.