Files
MagnumOpus/docs/plans/2026-03-13-v0.2-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

122 KiB

Magnum Opus v0.2 — 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: Build a read-only native Swift email client (macOS + iOS) that syncs via IMAP directly on-device, stores in SQLite/GRDB, and displays threaded email in a three-column SwiftUI layout with full-text search.

Architecture: Pure on-device — no remote server. IMAPClient (swift-nio-imap) syncs mail → SyncCoordinator writes to MailStore (GRDB/SQLite/FTS5) → GRDB ValueObservation pushes updates to @Observable ViewModels → SwiftUI renders. All modules live in a local Swift Package (MagnumOpusCore), consumed by macOS and iOS app targets.

Tech Stack: Swift 6 (strict concurrency), SwiftUI, GRDB.swift, swift-nio-imap, swift-nio-ssl, Keychain Services

Design Document: docs/plans/2026-03-13-v0.2-native-email-client-design.md

Branch: Create feature/v0.2-native-swift from current branch (preserves docs from v0.1 work).


File Structure

MagnumOpus/
├── Packages/
│   └── MagnumOpusCore/
│       ├── Package.swift
│       ├── Sources/
│       │   ├── Models/
│       │   │   ├── AccountConfig.swift        ← IMAP connection info (no DB dependency)
│       │   │   ├── Credentials.swift          ← username + password value type
│       │   │   ├── EmailAddress.swift         ← name + address pair
│       │   │   ├── SyncState.swift            ← .idle / .syncing / .error enum
│       │   │   ├── SyncEvent.swift            ← .newMessages / .flagsChanged / etc.
│       │   │   ├── ThreadSummary.swift        ← UI-facing thread display type
│       │   │   ├── MessageSummary.swift       ← UI-facing message display type
│       │   │   └── MailboxInfo.swift          ← UI-facing mailbox display type
│       │   │
│       │   ├── MailStore/
│       │   │   ├── MailStore.swift            ← public API: queries, inserts, streams
│       │   │   ├── DatabaseSetup.swift        ← DatabaseMigrator + schema
│       │   │   ├── Records/
│       │   │   │   ├── AccountRecord.swift
│       │   │   │   ├── MailboxRecord.swift
│       │   │   │   ├── MessageRecord.swift
│       │   │   │   ├── ThreadRecord.swift
│       │   │   │   ├── ThreadMessageRecord.swift
│       │   │   │   └── AttachmentRecord.swift
│       │   │   ├── ThreadReconstructor.swift  ← simplified JWZ algorithm
│       │   │   └── Queries.swift              ← complex joins, FTS5 search
│       │   │
│       │   ├── IMAPClient/
│       │   │   ├── IMAPClientProtocol.swift   ← protocol for testability
│       │   │   ├── IMAPClient.swift           ← actor: real NIO implementation
│       │   │   ├── IMAPConnection.swift       ← NIO bootstrap + TLS + channel
│       │   │   ├── IMAPResponseHandler.swift  ← ChannelInboundHandler
│       │   │   ├── IMAPCommandRunner.swift    ← tagged command send + response collect
│       │   │   ├── FetchedEnvelope.swift      ← parsed IMAP envelope data
│       │   │   └── IMAPTypes.swift            ← MailboxStatus, UIDRange, FetchFields
│       │   │
│       │   └── SyncEngine/
│       │       └── SyncCoordinator.swift      ← orchestrates IMAP → MailStore
│       │
│       └── Tests/
│           ├── ModelsTests/
│           │   └── EmailAddressTests.swift
│           ├── MailStoreTests/
│           │   ├── MailStoreTests.swift
│           │   ├── ThreadReconstructorTests.swift
│           │   └── SearchTests.swift
│           ├── IMAPClientTests/
│           │   └── IMAPResponseParsingTests.swift
│           └── SyncEngineTests/
│               ├── SyncCoordinatorTests.swift
│               └── MockIMAPClient.swift
│
├── Apps/
│   ├── project.yml                            ← XcodeGen: macOS + iOS targets
│   ├── MagnumOpus/
│   │   ├── MagnumOpusApp.swift
│   │   ├── ContentView.swift
│   │   ├── Services/
│   │   │   ├── KeychainService.swift
│   │   │   └── AutoDiscovery.swift
│   │   ├── ViewModels/
│   │   │   ├── MailViewModel.swift
│   │   │   └── AccountSetupViewModel.swift
│   │   └── Views/
│   │       ├── AccountSetupView.swift
│   │       ├── SidebarView.swift
│   │       ├── ThreadListView.swift
│   │       ├── ThreadDetailView.swift
│   │       └── MessageWebView.swift
│   └── MagnumOpusTests/
│       └── ViewModelTests.swift
│
├── docs/
├── Ideas/
└── scripts/

Dependency graph: SyncEngineIMAPClient + MailStoreModels. App targets import all four.


Chunk 1: Foundation

Task 1: Swift Package Scaffolding

Files:

  • Create: Packages/MagnumOpusCore/Package.swift

  • Create: placeholder .swift files in each module (SPM requires at least one .swift file per target)

  • Step 1: Create Package.swift

Create Packages/MagnumOpusCore/Package.swift:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
	name: "MagnumOpusCore",
	// macOS 15+ / iOS 18+ required for Swift 6 strict concurrency + latest SwiftUI APIs
	platforms: [
		.macOS(.v15),
		.iOS(.v18),
	],
	products: [
		.library(name: "Models", targets: ["Models"]),
		.library(name: "MailStore", targets: ["MailStore"]),
		.library(name: "IMAPClient", targets: ["IMAPClient"]),
		.library(name: "SyncEngine", targets: ["SyncEngine"]),
	],
	dependencies: [
		.package(url: "https://github.com/apple/swift-nio-imap.git", from: "0.1.0"),
		.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"),
		.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"),
	],
	targets: [
		.target(name: "Models"),
		.target(
			name: "MailStore",
			dependencies: [
				"Models",
				.product(name: "GRDB", package: "GRDB.swift"),
			]
		),
		.target(
			name: "IMAPClient",
			dependencies: [
				"Models",
				.product(name: "NIOIMAPCore", package: "swift-nio-imap"),
				.product(name: "NIOIMAP", package: "swift-nio-imap"),
				.product(name: "NIOSSL", package: "swift-nio-ssl"),
			]
		),
		.target(
			name: "SyncEngine",
			dependencies: ["Models", "IMAPClient", "MailStore"]
		),
		.testTarget(name: "ModelsTests", dependencies: ["Models"]),
		.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
		.testTarget(
			name: "IMAPClientTests",
			dependencies: [
				"IMAPClient",
				.product(name: "NIOIMAPCore", package: "swift-nio-imap"),
			]
		),
		.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]),
	]
)
  • Step 2: Create directory structure with placeholder Swift files

SPM requires at least one .swift file per declared target. Create empty enum placeholders so the package compiles before real sources are added in later tasks.

cd /Users/felixfoertsch/Developer/MagnumOpus
mkdir -p Packages/MagnumOpusCore/Sources/{Models,MailStore,IMAPClient,SyncEngine}
mkdir -p Packages/MagnumOpusCore/Tests/{ModelsTests,MailStoreTests,IMAPClientTests,SyncEngineTests}

# Placeholder files (replaced by real sources in later tasks)
echo 'enum MailStorePlaceholder {}' > Packages/MagnumOpusCore/Sources/MailStore/Placeholder.swift
echo 'enum IMAPClientPlaceholder {}' > Packages/MagnumOpusCore/Sources/IMAPClient/Placeholder.swift
echo 'enum SyncEnginePlaceholder {}' > Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/ModelsTests/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/MailStoreTests/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/IMAPClientTests/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/SyncEngineTests/Placeholder.swift

Note: Dependency resolution may take several minutes on first run as swift-nio-imap pulls substantial transitive dependencies.

  • Step 3: Verify package resolves
cd Packages/MagnumOpusCore && swift package resolve
# Expected: dependencies download successfully
  • Step 4: Commit
git add Packages/
git commit -m "scaffold MagnumOpusCore swift package with four modules"

Task 2: Models Module

All types in this module are pure Swift — no GRDB, no NIO. These are the shared vocabulary used across modules and by app targets.

Files:

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

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

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

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

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

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

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

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

  • Create: Packages/MagnumOpusCore/Tests/ModelsTests/EmailAddressTests.swift

  • Step 1: Create AccountConfig

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

public struct AccountConfig: Sendable, Codable, Equatable {
	public var id: String
	public var name: String
	public var email: String
	public var imapHost: String
	public var imapPort: Int

	public init(id: String, name: String, email: String, imapHost: String, imapPort: Int) {
		self.id = id
		self.name = name
		self.email = email
		self.imapHost = imapHost
		self.imapPort = imapPort
	}
}
  • Step 2: Create Credentials

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

public struct Credentials: Sendable {
	public var username: String
	public var password: String

	public init(username: String, password: String) {
		self.username = username
		self.password = password
	}
}
  • Step 3: Create EmailAddress

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

public struct EmailAddress: Sendable, Codable, Equatable, Hashable {
	public var name: String?
	public var address: String

	public init(name: String? = nil, address: String) {
		self.name = name
		self.address = address
	}

	public var displayName: String {
		name ?? address
	}

	/// Parses "Alice <alice@example.com>" or bare "alice@example.com"
	public static func parse(_ raw: String) -> EmailAddress {
		let trimmed = raw.trimmingCharacters(in: .whitespaces)
		guard let openAngle = trimmed.lastIndex(of: "<"),
			  let closeAngle = trimmed.lastIndex(of: ">"),
			  openAngle < closeAngle
		else {
			return EmailAddress(address: trimmed)
		}
		let addr = String(trimmed[trimmed.index(after: openAngle)..<closeAngle])
		let namepart = String(trimmed[..<openAngle]).trimmingCharacters(in: .whitespaces)
		return EmailAddress(
			name: namepart.isEmpty ? nil : namepart.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
			address: addr
		)
	}
}
  • Step 4: Create SyncState and SyncEvent

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

public enum SyncState: Sendable, Equatable {
	case idle
	case syncing(mailbox: String?)
	case error(String)
}

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

/// Note: spec defines syncFailed(Error), but Error is not Sendable.
/// Using String instead for Swift 6 strict concurrency compliance.
public enum SyncEvent: Sendable {
	case syncStarted
	case newMessages(count: Int, mailbox: String)
	case flagsChanged(messageIds: [String])
	case syncCompleted
	case syncFailed(String)
}
  • Step 5: Create UI-facing display types

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

import Foundation

public struct ThreadSummary: Sendable, Identifiable, Equatable {
	public var id: String
	public var accountId: String
	public var subject: String?
	public var lastDate: Date
	public var messageCount: Int
	public var unreadCount: Int
	public var senders: String
	public var snippet: String?

	public init(
		id: String, accountId: String, subject: String?, lastDate: Date,
		messageCount: Int, unreadCount: Int, senders: String, snippet: String?
	) {
		self.id = id
		self.accountId = accountId
		self.subject = subject
		self.lastDate = lastDate
		self.messageCount = messageCount
		self.unreadCount = unreadCount
		self.senders = senders
		self.snippet = snippet
	}
}

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

import Foundation

public struct MessageSummary: Sendable, Identifiable, Equatable {
	public var id: String
	public var messageId: String?
	public var threadId: String?
	public var from: EmailAddress?
	public var to: [EmailAddress]
	public var cc: [EmailAddress]
	public var subject: String?
	public var date: Date
	public var snippet: String?
	public var bodyText: String?
	public var bodyHtml: String?
	public var isRead: Bool
	public var isFlagged: Bool
	public var hasAttachments: Bool

	public init(
		id: String, messageId: String?, threadId: String?,
		from: EmailAddress?, to: [EmailAddress], cc: [EmailAddress],
		subject: String?, date: Date, snippet: String?,
		bodyText: String?, bodyHtml: String?,
		isRead: Bool, isFlagged: Bool, hasAttachments: Bool
	) {
		self.id = id
		self.messageId = messageId
		self.threadId = threadId
		self.from = from
		self.to = to
		self.cc = cc
		self.subject = subject
		self.date = date
		self.snippet = snippet
		self.bodyText = bodyText
		self.bodyHtml = bodyHtml
		self.isRead = isRead
		self.isFlagged = isFlagged
		self.hasAttachments = hasAttachments
	}
}

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

public struct MailboxInfo: Sendable, Identifiable, Equatable {
	public var id: String
	public var accountId: String
	public var name: String
	public var unreadCount: Int
	public var totalCount: Int

	public init(id: String, accountId: String, name: String, unreadCount: Int, totalCount: Int) {
		self.id = id
		self.accountId = accountId
		self.name = name
		self.unreadCount = unreadCount
		self.totalCount = totalCount
	}

	public var systemImage: String {
		switch name.lowercased() {
		case "inbox": "tray"
		case "sent", "sent messages": "paperplane"
		case "drafts": "doc"
		case "trash", "deleted messages": "trash"
		case "archive", "all mail": "archivebox"
		case "junk", "spam": "xmark.bin"
		default: "folder"
		}
	}
}
  • Step 6: Write EmailAddress tests

Create Packages/MagnumOpusCore/Tests/ModelsTests/EmailAddressTests.swift:

import Testing
@testable import Models

@Suite("EmailAddress")
struct EmailAddressTests {
	@Test("parses name and address from angle-bracket format")
	func parsesNameAndAddress() {
		let addr = EmailAddress.parse("Alice <alice@example.com>")
		#expect(addr.name == "Alice")
		#expect(addr.address == "alice@example.com")
		#expect(addr.displayName == "Alice")
	}

	@Test("parses bare email address")
	func parsesBareAddress() {
		let addr = EmailAddress.parse("bob@example.com")
		#expect(addr.name == nil)
		#expect(addr.address == "bob@example.com")
		#expect(addr.displayName == "bob@example.com")
	}

	@Test("parses quoted name with angle brackets")
	func parsesQuotedName() {
		let addr = EmailAddress.parse("\"Bob Smith\" <bob@example.com>")
		#expect(addr.name == "Bob Smith")
		#expect(addr.address == "bob@example.com")
	}

	@Test("handles empty string gracefully")
	func handlesEmpty() {
		let addr = EmailAddress.parse("")
		#expect(addr.address == "")
	}
}
  • Step 7: Run tests
cd Packages/MagnumOpusCore && swift test --filter ModelsTests
# Expected: all tests pass
  • Step 8: Commit
git add Packages/MagnumOpusCore/Sources/Models/ Packages/MagnumOpusCore/Tests/ModelsTests/
git commit -m "add models module: shared types for accounts, messages, threads, sync"

Chunk 2: MailStore

Task 3: Database Schema and Migrations

Files:

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

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

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

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

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

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

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

  • Step 1: Create GRDB record types

Create 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 init(id: String, name: String, email: String, imapHost: String, imapPort: Int) {
		self.id = id
		self.name = name
		self.email = email
		self.imapHost = imapHost
		self.imapPort = imapPort
	}
}

Create 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 init(id: String, accountId: String, name: String, uidValidity: Int, uidNext: Int) {
		self.id = id
		self.accountId = accountId
		self.name = name
		self.uidValidity = uidValidity
		self.uidNext = uidNext
	}
}

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

import GRDB

public struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "message"

	public var id: String
	public var accountId: String
	public var mailboxId: String
	public var uid: Int
	public var messageId: String?
	public var inReplyTo: String?
	public var refs: String?
	public var subject: String?
	public var fromAddress: String?
	public var fromName: String?
	public var toAddresses: String?
	public var ccAddresses: String?
	public var date: String
	public var snippet: String?
	public var bodyText: String?
	public var bodyHtml: String?
	public var isRead: Bool
	public var isFlagged: Bool
	public var size: Int

	public init(
		id: String, accountId: String, mailboxId: String, uid: Int,
		messageId: String?, inReplyTo: String?, refs: String?,
		subject: String?, fromAddress: String?, fromName: String?,
		toAddresses: String?, ccAddresses: String?,
		date: String, snippet: String?, bodyText: String?, bodyHtml: String?,
		isRead: Bool, isFlagged: Bool, size: Int
	) {
		self.id = id
		self.accountId = accountId
		self.mailboxId = mailboxId
		self.uid = uid
		self.messageId = messageId
		self.inReplyTo = inReplyTo
		self.refs = refs
		self.subject = subject
		self.fromAddress = fromAddress
		self.fromName = fromName
		self.toAddresses = toAddresses
		self.ccAddresses = ccAddresses
		self.date = date
		self.snippet = snippet
		self.bodyText = bodyText
		self.bodyHtml = bodyHtml
		self.isRead = isRead
		self.isFlagged = isFlagged
		self.size = size
	}
}

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

import GRDB

public struct ThreadRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "thread"

	public var id: String
	public var accountId: String
	public var subject: String?
	public var lastDate: String
	public var messageCount: Int

	public init(id: String, accountId: String, subject: String?, lastDate: String, messageCount: Int) {
		self.id = id
		self.accountId = accountId
		self.subject = subject
		self.lastDate = lastDate
		self.messageCount = messageCount
	}
}

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

import GRDB

public struct ThreadMessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "threadMessage"

	public var threadId: String
	public var messageId: String

	public init(threadId: String, messageId: String) {
		self.threadId = threadId
		self.messageId = messageId
	}
}

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

import GRDB

public struct AttachmentRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
	public static let databaseTableName = "attachment"

	public var id: String
	public var messageId: String
	public var filename: String?
	public var mimeType: String
	public var size: Int
	public var contentId: String?
	public var cachePath: String?

	public init(
		id: String, messageId: String, filename: String?, mimeType: String,
		size: Int, contentId: String?, cachePath: String?
	) {
		self.id = id
		self.messageId = messageId
		self.filename = filename
		self.mimeType = mimeType
		self.size = size
		self.contentId = contentId
		self.cachePath = cachePath
	}
}
  • Step 2: Create database schema with DatabaseMigrator

Create Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift:

import GRDB

public enum DatabaseSetup {
	public static func migrator() -> DatabaseMigrator {
		var migrator = DatabaseMigrator()

		migrator.registerMigration("v1_initial") { db in
			try db.create(table: "account") { t in
				t.primaryKey("id", .text)
				t.column("name", .text).notNull()
				t.column("email", .text).notNull()
				t.column("imapHost", .text).notNull()
				t.column("imapPort", .integer).notNull()
			}

			try db.create(table: "mailbox") { t in
				t.primaryKey("id", .text)
				t.belongsTo("account", onDelete: .cascade).notNull()
				t.column("name", .text).notNull()
				t.column("uidValidity", .integer).notNull()
				t.column("uidNext", .integer).notNull()
			}

			try db.create(table: "message") { t in
				t.primaryKey("id", .text)
				t.belongsTo("account", onDelete: .cascade).notNull()
				t.belongsTo("mailbox", onDelete: .cascade).notNull()
				t.column("uid", .integer).notNull()
				t.column("messageId", .text)
				t.column("inReplyTo", .text)
				t.column("refs", .text)
				t.column("subject", .text)
				t.column("fromAddress", .text)
				t.column("fromName", .text)
				t.column("toAddresses", .text)
				t.column("ccAddresses", .text)
				t.column("date", .text).notNull()
				t.column("snippet", .text)
				t.column("bodyText", .text)
				t.column("bodyHtml", .text)
				t.column("isRead", .boolean).notNull().defaults(to: false)
				t.column("isFlagged", .boolean).notNull().defaults(to: false)
				t.column("size", .integer).notNull().defaults(to: 0)
				t.uniqueKey(["mailboxId", "uid"])
			}

			try db.create(table: "thread") { t in
				t.primaryKey("id", .text)
				t.belongsTo("account", onDelete: .cascade).notNull()
				t.column("subject", .text)
				t.column("lastDate", .text).notNull()
				t.column("messageCount", .integer).notNull().defaults(to: 0)
			}

			try db.create(table: "threadMessage") { t in
				t.belongsTo("thread", onDelete: .cascade).notNull()
				t.belongsTo("message", onDelete: .cascade).notNull()
				t.primaryKey(["threadId", "messageId"])
			}

			try db.create(table: "attachment") { t in
				t.primaryKey("id", .text)
				t.belongsTo("message", onDelete: .cascade).notNull()
				t.column("filename", .text)
				t.column("mimeType", .text).notNull()
				t.column("size", .integer).notNull().defaults(to: 0)
				t.column("contentId", .text)
				t.column("cachePath", .text)
			}

			try db.create(index: "idx_message_mailbox_uid", on: "message", columns: ["mailboxId", "uid"])
			try db.create(index: "idx_message_messageId", on: "message", columns: ["messageId"])
			try db.create(index: "idx_thread_lastDate", on: "thread", columns: ["lastDate"])
		}

		migrator.registerMigration("v1_fts5") { db in
			try db.create(virtualTable: "messageFts", using: FTS5()) { t in
				t.synchronize(withTable: "message")
				t.tokenizer = .porter(wrapping: .unicode61())
				t.column("subject")
				t.column("fromName")
				t.column("fromAddress")
				t.column("bodyText")
			}
		}

		return migrator
	}

	public static func openDatabase(atPath path: String) throws -> DatabasePool {
		let pool = try DatabasePool(path: path)
		try migrator().migrate(pool)
		return pool
	}

	public static func openInMemoryDatabase() throws -> DatabaseQueue {
		let queue = try DatabaseQueue()
		try migrator().migrate(queue)
		return queue
	}
}
  • Step 3: Verify schema compiles and creates tables

Remove the placeholder file and verify real sources compile:

rm -f Packages/MagnumOpusCore/Sources/MailStore/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target MailStore
# Expected: builds successfully
  • Step 4: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/
git commit -m "add mailstore schema: accounts, mailboxes, messages, threads, FTS5"

Task 4: MailStore CRUD Operations

Files:

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

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

  • Step 1: Write failing tests for basic CRUD

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

import Testing
import GRDB
@testable import MailStore
@testable import Models

@Suite("MailStore CRUD")
struct MailStoreTests {
	func makeStore() throws -> MailStore {
		try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
	}

	@Test("insert and retrieve account")
	func accountCRUD() throws {
		let store = try makeStore()
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "user@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		let accounts = try store.accounts()
		#expect(accounts.count == 1)
		#expect(accounts[0].name == "Personal")
	}

	@Test("insert and retrieve mailbox")
	func mailboxCRUD() throws {
		let store = try makeStore()
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "user@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
		let mailboxes = try store.mailboxes(accountId: "acc1")
		#expect(mailboxes.count == 1)
		#expect(mailboxes[0].name == "INBOX")
	}

	@Test("insert and retrieve messages")
	func messageCRUD() throws {
		let store = try makeStore()
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "user@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
		try store.insertMessages([
			MessageRecord(
				id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
				messageId: "msg001@example.com", inReplyTo: nil, refs: nil,
				subject: "Hello", fromAddress: "alice@example.com", fromName: "Alice",
				toAddresses: "[{\"address\":\"user@example.com\"}]", ccAddresses: nil,
				date: "2024-03-08T10:15:32Z", snippet: "Hi there",
				bodyText: nil, bodyHtml: nil,
				isRead: false, isFlagged: false, size: 1024
			),
		])
		let messages = try store.messages(mailboxId: "mb1")
		#expect(messages.count == 1)
		#expect(messages[0].subject == "Hello")
	}

	@Test("update mailbox uidNext")
	func updateMailboxUidNext() throws {
		let store = try makeStore()
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "user@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
		try store.updateMailboxSync(id: "mb1", uidValidity: 1, uidNext: 150)
		let mailboxes = try store.mailboxes(accountId: "acc1")
		#expect(mailboxes[0].uidNext == 150)
	}

	@Test("update message flags")
	func updateFlags() throws {
		let store = try makeStore()
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "user@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
		try store.insertMessages([
			MessageRecord(
				id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
				messageId: nil, inReplyTo: nil, refs: nil,
				subject: "Test", fromAddress: nil, fromName: nil,
				toAddresses: nil, ccAddresses: nil,
				date: "2024-03-08T10:15:32Z", snippet: nil,
				bodyText: nil, bodyHtml: nil,
				isRead: false, isFlagged: false, size: 0
			),
		])
		try store.updateFlags(messageId: "m1", isRead: true, isFlagged: true)
		let messages = try store.messages(mailboxId: "mb1")
		#expect(messages[0].isRead == true)
		#expect(messages[0].isFlagged == true)
	}

	@Test("store body text and html")
	func storeBody() throws {
		let store = try makeStore()
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "user@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
		try store.insertMessages([
			MessageRecord(
				id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
				messageId: nil, inReplyTo: nil, refs: nil,
				subject: "Test", fromAddress: nil, fromName: nil,
				toAddresses: nil, ccAddresses: nil,
				date: "2024-03-08T10:15:32Z", snippet: nil,
				bodyText: nil, bodyHtml: nil,
				isRead: false, isFlagged: false, size: 0
			),
		])
		try store.storeBody(messageId: "m1", text: "Plain text body", html: "<p>HTML body</p>")
		let msg = try store.message(id: "m1")
		#expect(msg?.bodyText == "Plain text body")
		#expect(msg?.bodyHtml == "<p>HTML body</p>")
	}
}
  • Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests 2>&1 | head -20
# Expected: FAIL — MailStore type not defined
  • Step 3: Write MailStore implementation

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

import GRDB
import Models

public final class MailStore: Sendable {
	private let dbWriter: any DatabaseWriter

	public init(dbWriter: any DatabaseWriter) {
		self.dbWriter = dbWriter
	}

	// MARK: - Accounts

	public func insertAccount(_ account: AccountRecord) throws {
		try dbWriter.write { db in
			try account.insert(db)
		}
	}

	public func accounts() throws -> [AccountRecord] {
		try dbWriter.read { db in
			try AccountRecord.fetchAll(db)
		}
	}

	// MARK: - Mailboxes

	public func upsertMailbox(_ mailbox: MailboxRecord) throws {
		try dbWriter.write { db in
			try mailbox.save(db)
		}
	}

	public func mailboxes(accountId: String) throws -> [MailboxRecord] {
		try dbWriter.read { db in
			try MailboxRecord
				.filter(Column("accountId") == accountId)
				.order(Column("name"))
				.fetchAll(db)
		}
	}

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

	public func updateMailboxSync(id: String, uidValidity: Int, uidNext: Int) throws {
		try dbWriter.write { db in
			try db.execute(
				sql: "UPDATE mailbox SET uidValidity = ?, uidNext = ? WHERE id = ?",
				arguments: [uidValidity, uidNext, id]
			)
		}
	}

	// MARK: - Messages

	public func insertMessages(_ messages: [MessageRecord]) throws {
		try dbWriter.write { db in
			for message in messages {
				try message.save(db)
			}
		}
	}

	public func messages(mailboxId: String) throws -> [MessageRecord] {
		try dbWriter.read { db in
			try MessageRecord
				.filter(Column("mailboxId") == mailboxId)
				.order(Column("date").desc)
				.fetchAll(db)
		}
	}

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

	public func messagesForThread(threadId: String) throws -> [MessageRecord] {
		try dbWriter.read { db in
			try MessageRecord
				.joining(required: MessageRecord.hasOne(
					ThreadMessageRecord.self,
					key: "threadMessage",
					using: ForeignKey(["messageId"])
				).filter(Column("threadId") == threadId))
				.order(Column("date").asc)
				.fetchAll(db)
		}
	}

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

	public func storeBody(messageId: String, text: String?, html: String?) throws {
		try dbWriter.write { db in
			try db.execute(
				sql: "UPDATE message SET bodyText = ?, bodyHtml = ? WHERE id = ?",
				arguments: [text, html, messageId]
			)
		}
	}

	// MARK: - Threads

	public func threads(accountId: String) throws -> [ThreadRecord] {
		try dbWriter.read { db in
			try ThreadRecord
				.filter(Column("accountId") == accountId)
				.order(Column("lastDate").desc)
				.fetchAll(db)
		}
	}

	public func insertThread(_ thread: ThreadRecord) throws {
		try dbWriter.write { db in
			try thread.save(db)
		}
	}

	public func linkMessageToThread(threadId: String, messageId: String) throws {
		try dbWriter.write { db in
			try ThreadMessageRecord(threadId: threadId, messageId: messageId).save(db)
		}
	}

	public func updateThread(id: String, lastDate: String, messageCount: Int, subject: String?) throws {
		try dbWriter.write { db in
			try db.execute(
				sql: "UPDATE thread SET lastDate = ?, messageCount = ?, subject = COALESCE(?, subject) WHERE id = ?",
				arguments: [lastDate, messageCount, subject, id]
			)
		}
	}

	/// Returns all message IDs linked to a thread
	public func threadMessageIds(threadId: String) throws -> [String] {
		try dbWriter.read { db in
			try String.fetchAll(
				db,
				sql: "SELECT messageId FROM threadMessage WHERE threadId = ?",
				arguments: [threadId]
			)
		}
	}

	/// Finds thread IDs that contain any of the given message IDs (by RFC 5322 Message-ID)
	public func findThreadsByMessageIds(_ messageIds: Set<String>) throws -> [String] {
		guard !messageIds.isEmpty else { return [] }
		return try dbWriter.read { db in
			let placeholders = databaseQuestionMarks(count: messageIds.count)
			let sql = """
				SELECT DISTINCT tm.threadId
				FROM threadMessage tm
				JOIN message m ON m.id = tm.messageId
				WHERE m.messageId IN (\(placeholders))
				"""
			return try String.fetchAll(db, sql: sql, arguments: StatementArguments(Array(messageIds)))
		}
	}

	/// Merges multiple threads into one, keeping the first thread ID
	public func mergeThreads(_ threadIds: [String]) throws {
		guard threadIds.count > 1 else { return }
		let keepId = threadIds[0]
		let mergeIds = Array(threadIds.dropFirst())
		try dbWriter.write { db in
			for mergeId in mergeIds {
				try db.execute(
					sql: "UPDATE threadMessage SET threadId = ? WHERE threadId = ?",
					arguments: [keepId, mergeId]
				)
				try db.execute(
					sql: "DELETE FROM thread WHERE id = ?",
					arguments: [mergeId]
				)
			}
		}
	}

	/// Access the underlying database writer (for ValueObservation)
	public var databaseReader: any DatabaseReader {
		dbWriter
	}
}
  • Step 4: Run tests to verify they pass
rm -f Packages/MagnumOpusCore/Tests/MailStoreTests/.gitkeep
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests
# Expected: all tests pass
  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/ Packages/MagnumOpusCore/Tests/MailStoreTests/
git commit -m "add mailstore CRUD: accounts, mailboxes, messages, threads, flags, body"

Task 5: Thread Reconstruction

Simplified JWZ algorithm: link messages by Message-ID / In-Reply-To / References. No subject-based fallback.

Files:

  • Create: Packages/MagnumOpusCore/Sources/MailStore/ThreadReconstructor.swift

  • Create: Packages/MagnumOpusCore/Tests/MailStoreTests/ThreadReconstructorTests.swift

  • Step 1: Write failing tests

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

import Testing
import GRDB
@testable import MailStore

@Suite("ThreadReconstructor")
struct ThreadReconstructorTests {
	func makeStore() throws -> MailStore {
		try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
	}

	func seedAccount(_ store: MailStore) throws {
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Test", email: "me@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
	}

	func makeMessage(
		id: String, messageId: String?, inReplyTo: String? = nil,
		refs: String? = nil, subject: String = "Test", date: String = "2024-03-08T10:00:00Z"
	) -> MessageRecord {
		MessageRecord(
			id: id, accountId: "acc1", mailboxId: "mb1", uid: Int.random(in: 1...99999),
			messageId: messageId, inReplyTo: inReplyTo, refs: refs,
			subject: subject, fromAddress: "alice@example.com", fromName: "Alice",
			toAddresses: nil, ccAddresses: nil,
			date: date, snippet: nil, bodyText: nil, bodyHtml: nil,
			isRead: false, isFlagged: false, size: 100
		)
	}

	@Test("creates new thread for standalone message")
	func standaloneMessage() throws {
		let store = try makeStore()
		try seedAccount(store)
		let msg = makeMessage(id: "m1", messageId: "msg001@example.com")
		try store.insertMessages([msg])
		let reconstructor = ThreadReconstructor(store: store)
		try reconstructor.processMessages([msg])
		let threads = try store.threads(accountId: "acc1")
		#expect(threads.count == 1)
		#expect(threads[0].messageCount == 1)
	}

	@Test("groups reply into same thread via In-Reply-To")
	func replyByInReplyTo() throws {
		let store = try makeStore()
		try seedAccount(store)
		let msg1 = makeMessage(id: "m1", messageId: "msg001@example.com", date: "2024-03-08T10:00:00Z")
		let msg2 = makeMessage(
			id: "m2", messageId: "msg002@example.com",
			inReplyTo: "msg001@example.com",
			subject: "Re: Test", date: "2024-03-08T11:00:00Z"
		)
		try store.insertMessages([msg1, msg2])
		let reconstructor = ThreadReconstructor(store: store)
		try reconstructor.processMessages([msg1])
		try reconstructor.processMessages([msg2])
		let threads = try store.threads(accountId: "acc1")
		#expect(threads.count == 1)
		#expect(threads[0].messageCount == 2)
	}

	@Test("groups reply into same thread via References")
	func replyByReferences() throws {
		let store = try makeStore()
		try seedAccount(store)
		let msg1 = makeMessage(id: "m1", messageId: "msg001@example.com", date: "2024-03-08T10:00:00Z")
		let msg2 = makeMessage(
			id: "m2", messageId: "msg003@example.com",
			refs: "msg001@example.com msg002@example.com",
			date: "2024-03-08T12:00:00Z"
		)
		try store.insertMessages([msg1, msg2])
		let reconstructor = ThreadReconstructor(store: store)
		try reconstructor.processMessages([msg1])
		try reconstructor.processMessages([msg2])
		let threads = try store.threads(accountId: "acc1")
		#expect(threads.count == 1)
	}

	@Test("merges threads when new message connects them")
	func mergeThreads() throws {
		let store = try makeStore()
		try seedAccount(store)
		let msg1 = makeMessage(id: "m1", messageId: "msg001@example.com", date: "2024-03-08T10:00:00Z")
		let msg2 = makeMessage(id: "m2", messageId: "msg002@example.com", date: "2024-03-08T11:00:00Z")
		try store.insertMessages([msg1, msg2])
		let reconstructor = ThreadReconstructor(store: store)
		try reconstructor.processMessages([msg1])
		try reconstructor.processMessages([msg2])
		// two separate threads
		#expect(try store.threads(accountId: "acc1").count == 2)
		// msg3 references both, merging the threads
		let msg3 = makeMessage(
			id: "m3", messageId: "msg003@example.com",
			refs: "msg001@example.com msg002@example.com",
			date: "2024-03-08T12:00:00Z"
		)
		try store.insertMessages([msg3])
		try reconstructor.processMessages([msg3])
		#expect(try store.threads(accountId: "acc1").count == 1)
		#expect(try store.threads(accountId: "acc1")[0].messageCount == 3)
	}

	@Test("message without messageId gets its own thread")
	func noMessageId() throws {
		let store = try makeStore()
		try seedAccount(store)
		let msg = makeMessage(id: "m1", messageId: nil)
		try store.insertMessages([msg])
		let reconstructor = ThreadReconstructor(store: store)
		try reconstructor.processMessages([msg])
		let threads = try store.threads(accountId: "acc1")
		#expect(threads.count == 1)
		#expect(threads[0].messageCount == 1)
	}
}
  • Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter ThreadReconstructorTests 2>&1 | head -10
# Expected: FAIL — ThreadReconstructor not defined
  • Step 3: Write ThreadReconstructor implementation

Create Packages/MagnumOpusCore/Sources/MailStore/ThreadReconstructor.swift:

import Foundation
import GRDB

/// Simplified JWZ thread reconstruction.
/// Links messages by Message-ID, In-Reply-To, and References headers.
/// No subject-based fallback (produces false matches).
public struct ThreadReconstructor: Sendable {
	private let store: MailStore

	public init(store: MailStore) {
		self.store = store
	}

	/// Process newly inserted messages and assign them to threads.
	public func processMessages(_ messages: [MessageRecord]) throws {
		for message in messages {
			try processOneMessage(message)
		}
	}

	private func processOneMessage(_ message: MessageRecord) throws {
		// Collect all related Message-IDs from In-Reply-To and References
		var relatedIds = Set<String>()
		if let inReplyTo = message.inReplyTo, !inReplyTo.isEmpty {
			relatedIds.insert(inReplyTo)
		}
		if let refs = message.refs, !refs.isEmpty {
			for ref in refs.split(separator: " ") {
				let trimmed = ref.trimmingCharacters(in: .whitespaces)
				if !trimmed.isEmpty {
					relatedIds.insert(trimmed)
				}
			}
		}
		if let mid = message.messageId, !mid.isEmpty {
			relatedIds.insert(mid)
		}

		// Find existing threads that contain any of these Message-IDs
		let matchingThreadIds = try store.findThreadsByMessageIds(relatedIds)

		let threadId: String
		if matchingThreadIds.isEmpty {
			// No existing thread — create a new one
			threadId = UUID().uuidString
			let subject = stripReplyPrefix(message.subject)
			try store.insertThread(ThreadRecord(
				id: threadId,
				accountId: message.accountId,
				subject: subject,
				lastDate: message.date,
				messageCount: 1
			))
		} else if matchingThreadIds.count == 1 {
			// Exactly one matching thread — add to it
			threadId = matchingThreadIds[0]
			try updateThreadMetadata(threadId: threadId, newMessage: message)
		} else {
			// Multiple matching threads — merge them, then add message
			try store.mergeThreads(matchingThreadIds)
			threadId = matchingThreadIds[0]
			try updateThreadMetadata(threadId: threadId, newMessage: message)
		}

		// Link message to thread
		try store.linkMessageToThread(threadId: threadId, messageId: message.id)
	}

	private func updateThreadMetadata(threadId: String, newMessage: MessageRecord) throws {
		let existingMessageIds = try store.threadMessageIds(threadId: threadId)
		let newCount = existingMessageIds.count + 1
		let threads = try store.threads(accountId: newMessage.accountId)
		let currentThread = threads.first { $0.id == threadId }
		let lastDate = max(currentThread?.lastDate ?? "", newMessage.date)
		try store.updateThread(
			id: threadId,
			lastDate: lastDate,
			messageCount: newCount,
			subject: nil
		)
	}

	/// Strip Re:, Fwd:, and similar prefixes for thread subject normalization
	private func stripReplyPrefix(_ subject: String?) -> String? {
		guard var s = subject else { return nil }
		let prefixes = ["re:", "fwd:", "fw:"]
		var changed = true
		while changed {
			changed = false
			let trimmed = s.trimmingCharacters(in: .whitespaces)
			for prefix in prefixes {
				if trimmed.lowercased().hasPrefix(prefix) {
					s = String(trimmed.dropFirst(prefix.count))
					changed = true
					break
				}
			}
		}
		return s.trimmingCharacters(in: .whitespaces)
	}
}
  • Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore && swift test --filter ThreadReconstructorTests
# Expected: all tests pass
  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/ThreadReconstructor.swift Packages/MagnumOpusCore/Tests/MailStoreTests/ThreadReconstructorTests.swift
git commit -m "add thread reconstruction: simplified JWZ with merge support"

Task 6: FTS5 Search and ValueObservation Streams

Files:

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

  • Create: Packages/MagnumOpusCore/Tests/MailStoreTests/SearchTests.swift

  • Step 1: Write failing tests

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

import Testing
import GRDB
@testable import MailStore
@testable import Models

@Suite("MailStore Search & Queries")
struct SearchTests {
	func makeStore() throws -> MailStore {
		try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
	}

	func seedData(_ store: MailStore) throws {
		try store.insertAccount(AccountRecord(
			id: "acc1", name: "Personal", email: "me@example.com",
			imapHost: "imap.example.com", imapPort: 993
		))
		try store.upsertMailbox(MailboxRecord(
			id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
		))
		try store.insertMessages([
			MessageRecord(
				id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
				messageId: "msg001@example.com", inReplyTo: nil, refs: nil,
				subject: "Quarterly planning meeting", fromAddress: "alice@example.com",
				fromName: "Alice Johnson", toAddresses: nil, ccAddresses: nil,
				date: "2024-03-08T10:00:00Z", snippet: "Let's discuss Q2 goals",
				bodyText: "Let's discuss Q2 goals and roadmap priorities.", bodyHtml: nil,
				isRead: false, isFlagged: false, size: 1024
			),
			MessageRecord(
				id: "m2", accountId: "acc1", mailboxId: "mb1", uid: 2,
				messageId: "msg002@example.com", inReplyTo: nil, refs: nil,
				subject: "Invoice #4521", fromAddress: "billing@vendor.com",
				fromName: "Billing Dept", toAddresses: nil, ccAddresses: nil,
				date: "2024-03-07T09:00:00Z", snippet: "Please find attached",
				bodyText: "Your invoice for March is attached.", bodyHtml: nil,
				isRead: true, isFlagged: false, size: 2048
			),
		])
	}

	@Test("FTS5 search finds messages by subject")
	func searchBySubject() throws {
		let store = try makeStore()
		try seedData(store)
		let results = try store.search(query: "quarterly")
		#expect(results.count == 1)
		#expect(results[0].id == "m1")
	}

	@Test("FTS5 search finds messages by body text")
	func searchByBody() throws {
		let store = try makeStore()
		try seedData(store)
		let results = try store.search(query: "roadmap")
		#expect(results.count == 1)
		#expect(results[0].id == "m1")
	}

	@Test("FTS5 search finds messages by sender name")
	func searchBySender() throws {
		let store = try makeStore()
		try seedData(store)
		let results = try store.search(query: "alice")
		#expect(results.count == 1)
		#expect(results[0].fromName == "Alice Johnson")
	}

	@Test("FTS5 search returns empty for no matches")
	func searchNoMatch() throws {
		let store = try makeStore()
		try seedData(store)
		let results = try store.search(query: "nonexistent")
		#expect(results.isEmpty)
	}

	@Test("thread summaries include unread count and senders")
	func threadSummaries() throws {
		let store = try makeStore()
		try seedData(store)
		let reconstructor = ThreadReconstructor(store: store)
		let messages = try store.messages(mailboxId: "mb1")
		try reconstructor.processMessages(messages)
		let summaries = try store.threadSummaries(accountId: "acc1")
		#expect(summaries.count == 2)
		// First thread (most recent) should be "Quarterly planning meeting"
		#expect(summaries[0].subject == "Quarterly planning meeting")
		#expect(summaries[0].unreadCount == 1)
	}
}
  • Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter SearchTests 2>&1 | head -10
# Expected: FAIL — search method not defined
  • Step 3: Write Queries implementation

Create Packages/MagnumOpusCore/Sources/MailStore/Queries.swift:

import GRDB
import Models

extension MailStore {
	/// Full-text search across subject, sender, and body via FTS5
	public func search(query: String) throws -> [MessageRecord] {
		try dbWriter.read { db in
			guard let pattern = FTS5Pattern(matchingAllPrefixesIn: query) else { return [] }
			return try MessageRecord.fetchAll(db, sql: """
				SELECT message.* FROM message
				JOIN messageFts ON messageFts.rowid = message.rowid
				WHERE messageFts MATCH ?
				""", arguments: [pattern.rawPattern])
		}
	}

	/// Thread summaries with unread count and latest sender info, ordered by lastDate DESC
	public func threadSummaries(accountId: String) throws -> [ThreadSummary] {
		try dbWriter.read { db in
			try Self.threadSummariesFromDB(db, accountId: accountId)
		}
	}

	/// Observe thread summaries reactively — UI updates automatically on DB change.
	/// Uses `any DatabaseWriter` so it works with both DatabasePool (production) and DatabaseQueue (tests).
	public func observeThreadSummaries(accountId: String) -> AsyncThrowingStream<[ThreadSummary], Error> {
		let dbWriter = self.dbWriter
		let observation = ValueObservation.tracking { db -> [ThreadSummary] in
			try Self.threadSummariesFromDB(db, accountId: accountId)
		}
		return AsyncThrowingStream { continuation in
			let cancellable = observation.start(in: dbWriter, onError: { error in
				continuation.finish(throwing: error)
			}, onChange: { summaries in
				continuation.yield(summaries)
			})
			continuation.onTermination = { _ in cancellable.cancel() }
		}
	}

	/// Observe messages in a thread reactively
	public func observeMessages(threadId: String) -> AsyncThrowingStream<[MessageSummary], Error> {
		let dbWriter = self.dbWriter
		let observation = ValueObservation.tracking { db -> [MessageRecord] in
			try MessageRecord.fetchAll(db, sql: """
				SELECT m.* FROM message m
				JOIN threadMessage tm ON tm.messageId = m.id
				WHERE tm.threadId = ?
				ORDER BY m.date ASC
				""", arguments: [threadId])
		}
		return AsyncThrowingStream { continuation in
			let cancellable = observation.start(in: dbWriter, onError: { error in
				continuation.finish(throwing: error)
			}, onChange: { records in
				continuation.yield(records.map(Self.toMessageSummary))
			})
			continuation.onTermination = { _ in cancellable.cancel() }
		}
	}

	// MARK: - Internal helpers

	static func threadSummariesFromDB(_ db: Database, accountId: String) throws -> [ThreadSummary] {
		let sql = """
			SELECT
				t.id, t.accountId, t.subject, t.lastDate, t.messageCount,
				(SELECT COUNT(*) FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.isRead = 0) as unreadCount,
				(SELECT GROUP_CONCAT(DISTINCT m.fromName, ', ') FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.fromName IS NOT NULL) as senders,
				(SELECT m.snippet FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id ORDER BY m.date DESC LIMIT 1) as snippet
			FROM thread t
			WHERE t.accountId = ?
			ORDER BY t.lastDate DESC
			"""
		let rows = try Row.fetchAll(db, sql: sql, arguments: [accountId])
		return rows.map { row in
			ThreadSummary(
				id: row["id"], accountId: row["accountId"], subject: row["subject"],
				lastDate: Self.parseDate(row["lastDate"] as String? ?? "") ?? Date.distantPast,
				messageCount: row["messageCount"], unreadCount: row["unreadCount"],
				senders: row["senders"] ?? "", snippet: row["snippet"]
			)
		}
	}

	private static let isoFormatterWithFractional: ISO8601DateFormatter = {
		let f = ISO8601DateFormatter()
		f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
		return f
	}()

	private static let isoFormatter: ISO8601DateFormatter = {
		ISO8601DateFormatter()
	}()

	static func parseDate(_ iso: String) -> Date? {
		isoFormatterWithFractional.date(from: iso) ?? isoFormatter.date(from: iso)
	}

	static func toMessageSummary(_ record: MessageRecord) -> MessageSummary {
		MessageSummary(
			id: record.id,
			messageId: record.messageId,
			threadId: nil,
			from: record.fromAddress.map { EmailAddress.parse($0) },
			to: parseAddressList(record.toAddresses),
			cc: parseAddressList(record.ccAddresses),
			subject: record.subject,
			date: parseDate(record.date) ?? Date.distantPast,
			snippet: record.snippet,
			bodyText: record.bodyText,
			bodyHtml: record.bodyHtml,
			isRead: record.isRead,
			isFlagged: record.isFlagged,
			hasAttachments: false
		)
	}

	static func parseAddressList(_ json: String?) -> [EmailAddress] {
		guard let json, let data = json.data(using: .utf8) else { return [] }
		struct Addr: Codable { var name: String?; var address: String }
		guard let addrs = try? JSONDecoder().decode([Addr].self, from: data) else { return [] }
		return addrs.map { EmailAddress(name: $0.name, address: $0.address) }
	}
}

Note: The FTS5 search uses raw SQL to join message with the synchronized messageFts virtual table. GRDB's FTS5Pattern(matchingAllPrefixesIn:) sanitizes user input for safe FTS5 queries. The implementing agent should verify the exact GRDB FTS5 pattern API and adjust if needed.

  • Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore && swift test --filter SearchTests
# Expected: all tests pass

If GRDB FTS5 API differs from plan, adjust search() to use the raw SQL fallback shown above.

  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/Queries.swift Packages/MagnumOpusCore/Tests/MailStoreTests/SearchTests.swift
git commit -m "add FTS5 search, thread summaries, reactive observation streams"

Chunk 3: Sync Pipeline (Mock IMAP + SyncCoordinator)

Build the full sync pipeline with a mock IMAPClient first. This validates the SyncCoordinator → MailStore flow before tackling real NIO networking.

Task 7: IMAPClient Protocol, Types, and Mock

Files:

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

  • Create: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPTypes.swift

  • Create: Packages/MagnumOpusCore/Sources/IMAPClient/FetchedEnvelope.swift

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

  • Step 1: Create IMAP response types

Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPTypes.swift:

import Models

public struct IMAPMailboxStatus: Sendable {
	public var name: String
	public var uidValidity: Int
	public var uidNext: Int
	public var messageCount: Int
	public var recentCount: Int

	public init(name: String, uidValidity: Int, uidNext: Int, messageCount: Int, recentCount: Int) {
		self.name = name
		self.uidValidity = uidValidity
		self.uidNext = uidNext
		self.messageCount = messageCount
		self.recentCount = recentCount
	}
}

public struct IMAPMailboxInfo: Sendable {
	public var name: String
	public var attributes: Set<String>

	public init(name: String, attributes: Set<String> = []) {
		self.name = name
		self.attributes = attributes
	}
}

public struct UIDFlagsPair: Sendable {
	public var uid: Int
	public var isRead: Bool
	public var isFlagged: Bool

	public init(uid: Int, isRead: Bool, isFlagged: Bool) {
		self.uid = uid
		self.isRead = isRead
		self.isFlagged = isFlagged
	}
}

Create Packages/MagnumOpusCore/Sources/IMAPClient/FetchedEnvelope.swift:

import Models

/// Parsed IMAP envelope — the data we extract from a FETCH response.
public struct FetchedEnvelope: Sendable {
	public var uid: Int
	public var messageId: String?
	public var inReplyTo: String?
	public var references: String?
	public var subject: String?
	public var from: EmailAddress?
	public var to: [EmailAddress]
	public var cc: [EmailAddress]
	public var date: String
	public var snippet: String?
	public var bodyText: String?
	public var bodyHtml: String?
	public var isRead: Bool
	public var isFlagged: Bool
	public var size: Int

	public init(
		uid: Int, messageId: String?, inReplyTo: String?, references: String?,
		subject: String?, from: EmailAddress?, to: [EmailAddress], cc: [EmailAddress],
		date: String, snippet: String?, bodyText: String?, bodyHtml: String?,
		isRead: Bool, isFlagged: Bool, size: Int
	) {
		self.uid = uid
		self.messageId = messageId
		self.inReplyTo = inReplyTo
		self.references = references
		self.subject = subject
		self.from = from
		self.to = to
		self.cc = cc
		self.date = date
		self.snippet = snippet
		self.bodyText = bodyText
		self.bodyHtml = bodyHtml
		self.isRead = isRead
		self.isFlagged = isFlagged
		self.size = size
	}
}
  • Step 2: Create IMAPClientProtocol

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

public protocol IMAPClientProtocol: Sendable {
	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?)
}
  • Step 3: Create MockIMAPClient for testing

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

import IMAPClient
import Models

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

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

	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] {
		envelopes.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)
	}
}

enum MockIMAPError: Error {
	case mailboxNotFound(String)
}
  • Step 4: Remove placeholder files, verify build
rm -f Packages/MagnumOpusCore/Sources/IMAPClient/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target IMAPClient
# Expected: builds successfully
  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/IMAPClient/ Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift
git commit -m "add imap client protocol, types, mock for testing"

Task 8: SyncCoordinator

Orchestrates the full sync flow: connect → list mailboxes → select each → fetch new messages → store in MailStore → reconstruct threads → disconnect. Uses IMAPClientProtocol so it's testable with the mock.

Files:

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

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

  • Step 1: Write failing tests

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

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

@Suite("SyncCoordinator")
@MainActor
struct SyncCoordinatorTests {
	func makeStore() throws -> MailStore {
		try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
	}

	func makeMock() -> MockIMAPClient {
		let mock = MockIMAPClient()
		mock.mailboxes = [
			IMAPMailboxInfo(name: "INBOX"),
			IMAPMailboxInfo(name: "Sent"),
		]
		mock.mailboxStatuses = [
			"INBOX": IMAPMailboxStatus(name: "INBOX", uidValidity: 1, uidNext: 3, messageCount: 2, recentCount: 0),
			"Sent": IMAPMailboxStatus(name: "Sent", uidValidity: 1, uidNext: 1, messageCount: 0, recentCount: 0),
		]
		mock.envelopes = [
			FetchedEnvelope(
				uid: 1, messageId: "msg001@example.com", inReplyTo: nil, references: nil,
				subject: "Hello", from: EmailAddress(name: "Alice", address: "alice@example.com"),
				to: [EmailAddress(address: "me@example.com")], cc: [],
				date: "2024-03-08T10:00:00Z", snippet: "Hi there",
				bodyText: nil, bodyHtml: nil, isRead: false, isFlagged: false, size: 1024
			),
			FetchedEnvelope(
				uid: 2, messageId: "msg002@example.com", inReplyTo: "msg001@example.com",
				references: "msg001@example.com",
				subject: "Re: Hello", from: EmailAddress(name: "Bob", address: "bob@example.com"),
				to: [EmailAddress(address: "alice@example.com")], cc: [],
				date: "2024-03-08T11:00:00Z", snippet: "Hey!",
				bodyText: nil, bodyHtml: nil, isRead: true, isFlagged: false, size: 512
			),
		]
		return mock
	}

	@Test("full sync creates account, mailboxes, messages, and threads")
	func fullSync() async throws {
		let store = try makeStore()
		let mock = makeMock()
		let coordinator = SyncCoordinator(
			accountConfig: AccountConfig(
				id: "acc1", name: "Personal", email: "me@example.com",
				imapHost: "imap.example.com", imapPort: 993
			),
			imapClient: mock,
			store: store
		)

		try await coordinator.syncNow()

		// Account created
		let accounts = try store.accounts()
		#expect(accounts.count == 1)

		// Mailboxes created
		let mailboxes = try store.mailboxes(accountId: "acc1")
		#expect(mailboxes.count == 2)

		// Messages stored
		let inboxMb = mailboxes.first { $0.name == "INBOX" }!
		let messages = try store.messages(mailboxId: inboxMb.id)
		#expect(messages.count == 2)

		// Threads created (msg002 replies to msg001, so 1 thread)
		let threads = try store.threads(accountId: "acc1")
		#expect(threads.count == 1)
		#expect(threads[0].messageCount == 2)

		// uidNext updated
		let updatedMb = try store.mailbox(id: inboxMb.id)
		#expect(updatedMb?.uidNext == 3)

		// IMAP client was connected and disconnected
		#expect(mock.connectCalled)
		#expect(mock.disconnectCalled)
	}

	@Test("delta sync only fetches new messages")
	func deltaSync() async throws {
		let store = try makeStore()
		let mock = makeMock()
		let config = AccountConfig(
			id: "acc1", name: "Personal", email: "me@example.com",
			imapHost: "imap.example.com", imapPort: 993
		)
		let coordinator = SyncCoordinator(accountConfig: config, imapClient: mock, store: store)

		// First sync
		try await coordinator.syncNow()

		// Add a new message for delta sync
		mock.envelopes.append(FetchedEnvelope(
			uid: 3, messageId: "msg003@example.com", inReplyTo: nil, references: nil,
			subject: "New message", from: EmailAddress(name: "Charlie", address: "charlie@example.com"),
			to: [EmailAddress(address: "me@example.com")], cc: [],
			date: "2024-03-09T10:00:00Z", snippet: "Something new",
			bodyText: nil, bodyHtml: nil, isRead: false, isFlagged: false, size: 256
		))
		mock.mailboxStatuses["INBOX"] = IMAPMailboxStatus(
			name: "INBOX", uidValidity: 1, uidNext: 4, messageCount: 3, recentCount: 1
		)

		// Second sync — should only fetch uid > 2
		try await coordinator.syncNow()

		let inboxMb = try store.mailboxes(accountId: "acc1").first { $0.name == "INBOX" }!
		let messages = try store.messages(mailboxId: inboxMb.id)
		#expect(messages.count == 3)
	}

	@Test("sync state transitions through syncing to idle")
	func syncStateTransitions() async throws {
		let store = try makeStore()
		let mock = makeMock()
		let coordinator = SyncCoordinator(
			accountConfig: AccountConfig(
				id: "acc1", name: "Personal", email: "me@example.com",
				imapHost: "imap.example.com", imapPort: 993
			),
			imapClient: mock,
			store: store
		)

		#expect(coordinator.syncState == .idle)
		try await coordinator.syncNow()
		#expect(coordinator.syncState == .idle)
	}
}
  • Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter SyncCoordinatorTests 2>&1 | head -10
# Expected: FAIL — SyncCoordinator not defined
  • Step 3: Write SyncCoordinator implementation

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

import Foundation
import Models
import IMAPClient
import MailStore

@Observable
@MainActor
public final class SyncCoordinator {
	private let accountConfig: AccountConfig
	private let imapClient: any IMAPClientProtocol
	private let store: MailStore
	private var syncTask: Task<Void, Never>?

	public private(set) var syncState: SyncState = .idle
	private var eventHandlers: [(SyncEvent) -> Void] = []

	public init(accountConfig: AccountConfig, imapClient: any IMAPClientProtocol, store: MailStore) {
		self.accountConfig = accountConfig
		self.imapClient = imapClient
		self.store = store
	}

	public func onEvent(_ handler: @escaping (SyncEvent) -> Void) {
		eventHandlers.append(handler)
	}

	private func emit(_ event: SyncEvent) {
		for handler in eventHandlers {
			handler(event)
		}
	}

	// MARK: - Sync

	public func syncNow() async throws {
		syncState = .syncing(mailbox: nil)
		emit(.syncStarted)

		do {
			try await performSync()
			syncState = .idle
			emit(.syncCompleted)
		} catch {
			syncState = .error(error.localizedDescription)
			emit(.syncFailed(error.localizedDescription))
			throw error
		}
	}

	private func performSync() async throws {
		// Ensure account exists in DB
		let existingAccounts = try store.accounts()
		if !existingAccounts.contains(where: { $0.id == accountConfig.id }) {
			try store.insertAccount(AccountRecord(
				id: accountConfig.id,
				name: accountConfig.name,
				email: accountConfig.email,
				imapHost: accountConfig.imapHost,
				imapPort: accountConfig.imapPort
			))
		}

		try await imapClient.connect()
		do {
			try await syncAllMailboxes()
		} catch {
			try? await imapClient.disconnect()
			throw error
		}
		try? await imapClient.disconnect()
	}

	private func syncAllMailboxes() async throws {
		// List and sync each mailbox
		let remoteMailboxes = try await imapClient.listMailboxes()
		for remoteMailbox in remoteMailboxes {
			syncState = .syncing(mailbox: remoteMailbox.name)
			try await syncMailbox(remoteMailbox)
		}
	}

	private func syncMailbox(_ remoteMailbox: IMAPMailboxInfo) async throws {
		let status = try await imapClient.selectMailbox(remoteMailbox.name)

		// Find or create local mailbox
		let localMailboxes = try store.mailboxes(accountId: accountConfig.id)
		let localMailbox = localMailboxes.first { $0.name == remoteMailbox.name }

		let mailboxId: String
		let lastUid: Int

		if let local = localMailbox {
			mailboxId = local.id
			if local.uidValidity != status.uidValidity {
				// UIDVALIDITY changed — must re-sync entire mailbox
				// For v0.2, just update and re-fetch all
				lastUid = 0
			} else {
				lastUid = local.uidNext - 1
			}
		} else {
			mailboxId = UUID().uuidString
			try store.upsertMailbox(MailboxRecord(
				id: mailboxId,
				accountId: accountConfig.id,
				name: remoteMailbox.name,
				uidValidity: status.uidValidity,
				uidNext: status.uidNext
			))
			lastUid = 0
		}

		// Fetch new envelopes
		let envelopes = try await imapClient.fetchEnvelopes(uidsGreaterThan: lastUid)

		if !envelopes.isEmpty {
			// Convert to MessageRecords and insert
			let records = envelopes.map { envelope -> MessageRecord in
				envelopeToRecord(envelope, accountId: accountConfig.id, mailboxId: mailboxId)
			}
			try store.insertMessages(records)

			// Reconstruct threads for new messages
			let reconstructor = ThreadReconstructor(store: store)
			try reconstructor.processMessages(records)

			emit(.newMessages(count: envelopes.count, mailbox: remoteMailbox.name))
		}

		// Update mailbox sync state
		try store.updateMailboxSync(
			id: mailboxId,
			uidValidity: status.uidValidity,
			uidNext: status.uidNext
		)
	}

	private func envelopeToRecord(
		_ envelope: FetchedEnvelope, accountId: String, mailboxId: String
	) -> MessageRecord {
		let toJson = encodeAddresses(envelope.to)
		let ccJson = encodeAddresses(envelope.cc)
		return MessageRecord(
			id: UUID().uuidString,
			accountId: accountId,
			mailboxId: mailboxId,
			uid: envelope.uid,
			messageId: envelope.messageId,
			inReplyTo: envelope.inReplyTo,
			refs: envelope.references,
			subject: envelope.subject,
			fromAddress: envelope.from?.address,
			fromName: envelope.from?.name,
			toAddresses: toJson,
			ccAddresses: ccJson,
			date: envelope.date,
			snippet: envelope.snippet,
			bodyText: envelope.bodyText,
			bodyHtml: envelope.bodyHtml,
			isRead: envelope.isRead,
			isFlagged: envelope.isFlagged,
			size: envelope.size
		)
	}

	private func encodeAddresses(_ addresses: [EmailAddress]) -> String? {
		guard !addresses.isEmpty else { return nil }
		struct Addr: Codable { var name: String?; var address: String }
		let addrs = addresses.map { Addr(name: $0.name, address: $0.address) }
		guard let data = try? JSONEncoder().encode(addrs) else { return nil }
		return String(data: data, encoding: .utf8)
	}

	// MARK: - Periodic Sync

	public func startPeriodicSync(interval: Duration = .seconds(300)) {
		stopSync()
		syncTask = Task { [weak self] in
			while !Task.isCancelled {
				try? await self?.syncNow()
				do {
					try await Task.sleep(for: interval)
				} catch {
					// CancellationError — exit the loop
					break
				}
			}
		}
	}

	public func stopSync() {
		syncTask?.cancel()
		syncTask = nil
	}
}
  • Step 4: Remove placeholder, run tests
rm -f Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift
cd Packages/MagnumOpusCore && swift test --filter SyncCoordinatorTests
# Expected: all tests pass
  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/SyncEngine/ Packages/MagnumOpusCore/Tests/SyncEngineTests/
git commit -m "add sync coordinator: imap → mailstore pipeline with delta sync"

Chunk 4: Real IMAP Client (NIO)

Replace the mock with a real swift-nio-imap based actor. This is the most complex module — built in two tasks: connection layer, then high-level operations.

Task 9: NIO Connection Layer

The IMAP client actor manages a NIO channel with TLS. Commands are sent sequentially (one at a time) with tag-based response matching.

Files:

  • Create: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift

  • Create: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPResponseHandler.swift

  • Create: Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift

  • Step 1: Create IMAPResponseHandler (NIO ChannelInboundHandler)

This handler collects IMAP responses and delivers them to a waiting continuation when a tagged response completes a command.

Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPResponseHandler.swift:

import NIO
import NIOIMAPCore
import NIOIMAP

final class IMAPResponseHandler: ChannelInboundHandler {
	typealias InboundIn = Response

	private var buffer: [Response] = []
	private var expectedTag: String?
	private var continuation: CheckedContinuation<[Response], Error>?
	private var greetingContinuation: CheckedContinuation<Void, Error>?

	func channelRead(context: ChannelHandlerContext, data: NIOAny) {
		let response = unwrapInboundIn(data)
		buffer.append(response)

		switch response {
		case .untagged(let payload):
			// Server greeting arrives as untagged OK
			if case .conditionalState(let state) = payload, greetingContinuation != nil {
				if case .ok = state.code {
					greetingContinuation?.resume()
					greetingContinuation = nil
				}
			}
		case .tagged(let tagged):
			if tagged.tag == expectedTag {
				let collected = buffer
				buffer = []
				expectedTag = nil
				continuation?.resume(returning: collected)
				continuation = nil
			}
		case .fatal(let text):
			let error = IMAPError.serverError(String(describing: text))
			continuation?.resume(throwing: error)
			continuation = nil
			greetingContinuation?.resume(throwing: error)
			greetingContinuation = nil
		default:
			break
		}
	}

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

	func waitForGreeting() async throws {
		try await withCheckedThrowingContinuation { cont in
			greetingContinuation = cont
		}
	}

	func sendCommand(tag: String, continuation cont: CheckedContinuation<[Response], Error>) {
		expectedTag = tag
		continuation = cont
		buffer = []
	}
}

public enum IMAPError: Error, Sendable {
	case notConnected
	case serverError(String)
	case authenticationFailed
	case unexpectedResponse(String)
}

Note: The exact Response enum variants depend on the swift-nio-imap version. The implementing agent must check NIOIMAPCore.Response and adapt the switch cases accordingly. The core pattern (buffer responses, match by tag, resume continuation) is correct regardless of API details.

  • Step 2: Create IMAPConnection (NIO bootstrap + TLS)

Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift:

import NIO
import NIOIMAPCore
import NIOIMAP
import NIOSSL

/// Actor because it holds mutable `channel` state — `Sendable class` would not compile in Swift 6.
actor IMAPConnection {
	private let host: String
	private let port: Int
	private let group: EventLoopGroup
	private var channel: Channel?
	private let responseHandler: IMAPResponseHandler

	init(host: String, port: Int) {
		self.host = host
		self.port = port
		self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
		self.responseHandler = IMAPResponseHandler()
	}

	func connect() async throws {
		let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
		let handler = responseHandler
		let hostname = host

		let bootstrap = ClientBootstrap(group: group)
			.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
			.channelInitializer { channel in
				let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
				return channel.pipeline.addHandlers([
					sslHandler,
					IMAPClientHandler(),
					handler,
				])
			}

		channel = try await bootstrap.connect(host: host, port: port).get()
		try await handler.waitForGreeting()
	}

	func sendCommand(_ tag: String, command: CommandStreamPart) async throws -> [Response] {
		guard let channel else { throw IMAPError.notConnected }
		return try await withCheckedThrowingContinuation { continuation in
			responseHandler.sendCommand(tag: tag, continuation: continuation)
			channel.writeAndFlush(command, promise: nil)
		}
	}

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

	func shutdown() async throws {
		try await group.shutdownGracefully()
	}
}

Note: IMAPClientHandler is the NIO channel handler from the NIOIMAP module that encodes/decodes IMAP wire protocol. The try! in channelInitializer is acceptable here because TLS context creation failure is a programmer error (bad config), not a runtime condition. The implementing agent should verify the exact handler name and adjust if needed.

  • Step 3: Create IMAPCommandRunner

This thin layer manages tag generation and command execution.

Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift:

import NIOIMAPCore

/// Not Sendable — owned exclusively by the IMAPClient actor.
struct IMAPCommandRunner {
	private let connection: IMAPConnection
	private var tagCounter: Int = 0

	init(connection: IMAPConnection) {
		self.connection = connection
	}

	mutating func nextTag() -> String {
		tagCounter += 1
		return "A\(tagCounter)"
	}

	mutating func run(_ command: Command) async throws -> [Response] {
		let tag = nextTag()
		let tagged = TaggedCommand(tag: tag, command: command)
		return try await connection.sendCommand(tag, command: .tagged(tagged))
	}
}
  • Step 4: Verify build
cd Packages/MagnumOpusCore && swift build --target IMAPClient
# Expected: builds (may need adjustments for exact NIO-IMAP API)

If the build fails due to API differences in swift-nio-imap, adapt the types to match the actual API. The key patterns (ChannelInboundHandler, continuation-based response collection, TLS bootstrap) are correct.

  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/IMAPClient/
git commit -m "add nio connection layer: tls bootstrap, response handler, command runner"

Task 10: IMAPClient Actor (High-Level Operations)

Wire the connection layer into the public IMAPClient actor that conforms to IMAPClientProtocol.

Files:

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

  • Create: Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift

  • Step 1: Write IMAPClient actor

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

import Models
import NIOIMAPCore

public actor IMAPClient: IMAPClientProtocol {
	private let host: String
	private let port: Int
	private let credentials: Credentials
	private var connection: IMAPConnection?
	private var runner: IMAPCommandRunner?

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

	public func connect() async throws {
		let conn = IMAPConnection(host: host, port: port)
		try await conn.connect()
		connection = conn
		var newRunner = IMAPCommandRunner(connection: conn)

		// Authenticate — must reassign runner after mutating call to preserve tag counter
		let responses = try await newRunner.run(
			.login(username: credentials.username, password: credentials.password)
		)
		guard responses.contains(where: isOKTagged) else {
			throw IMAPError.authenticationFailed
		}
		runner = newRunner
	}

	public func disconnect() async throws {
		if var r = runner {
			_ = try? await r.run(.logout)
			runner = r
		}
		try await connection?.disconnect()
		try await connection?.shutdown()
		connection = nil
		runner = nil
	}

	public func listMailboxes() async throws -> [IMAPMailboxInfo] {
		guard var runner else { throw IMAPError.notConnected }
		let responses = try await runner.run(.list(reference: .init(""), mailboxPattern: .init("*")))
		self.runner = runner
		return parseListResponses(responses)
	}

	public func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
		guard var runner else { throw IMAPError.notConnected }
		let responses = try await runner.run(.select(.init(name)))
		self.runner = runner
		return parseSelectResponses(responses, name: name)
	}

	public func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
		guard var runner else { throw IMAPError.notConnected }
		let range: MessageIdentifierRange<UID> = MessageIdentifierRange(
			(.init(integerLiteral: UInt32(uid + 1))...(.max))
		)
		let responses = try await runner.run(.uidFetch(
			range,
			[.envelope, .flags, .uid, .rfc822Size, .bodySection(peek: true, .init(kind: .text), nil)]
		))
		self.runner = runner
		return parseFetchResponses(responses)
	}

	public func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
		guard var runner else { throw IMAPError.notConnected }
		let range: MessageIdentifierRange<UID> = MessageIdentifierRange(
			(.init(integerLiteral: UInt32(uids.lowerBound))...(.init(integerLiteral: UInt32(uids.upperBound))))
		)
		let responses = try await runner.run(.uidFetch(range, [.uid, .flags]))
		self.runner = runner
		return parseFlagResponses(responses)
	}

	public func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
		guard var runner else { throw IMAPError.notConnected }
		let range: MessageIdentifierRange<UID> = .single(.init(integerLiteral: UInt32(uid)))
		let responses = try await runner.run(.uidFetch(
			range,
			[.bodySection(peek: true, .init(kind: .text), nil)]
		))
		self.runner = runner
		return parseBodyResponse(responses)
	}

	// MARK: - Response parsing helpers

	/// These methods extract data from NIO-IMAP Response objects.
	/// The exact Response enum structure depends on the swift-nio-imap version.
	/// The implementing agent MUST verify these against the actual API.

	private func isOKTagged(_ response: Response) -> Bool {
		if case .tagged(let tagged) = response {
			return tagged.state == .ok
		}
		return false
	}

	private func parseListResponses(_ responses: [Response]) -> [IMAPMailboxInfo] {
		var mailboxes: [IMAPMailboxInfo] = []
		for response in responses {
			if case .untagged(let payload) = response,
			   case .mailboxData(let data) = payload,
			   case .list(let listInfo) = data {
				let attrs = Set(listInfo.attributes.map { String(describing: $0) })
				mailboxes.append(IMAPMailboxInfo(name: String(listInfo.path.name), attributes: attrs))
			}
		}
		return mailboxes
	}

	private func parseSelectResponses(_ responses: [Response], name: String) -> IMAPMailboxStatus {
		var uidValidity = 0
		var uidNext = 0
		var messageCount = 0
		var recentCount = 0

		for response in responses {
			if case .untagged(let payload) = response {
				switch payload {
				case .mailboxData(let data):
					switch data {
					case .exists(let count): messageCount = count
					case .recent(let count): recentCount = count
					default: break
					}
				case .conditionalState(let state):
					if case .ok(let responseText) = state,
					   let code = responseText.code {
						switch code {
						case .uidValidity(let val): uidValidity = Int(val)
						case .uidNext(let val): uidNext = Int(val)
						default: break
						}
					}
				default: break
				}
			}
		}

		return IMAPMailboxStatus(
			name: name, uidValidity: uidValidity, uidNext: uidNext,
			messageCount: messageCount, recentCount: recentCount
		)
	}

	private func parseFetchResponses(_ responses: [Response]) -> [FetchedEnvelope] {
		var envelopes: [FetchedEnvelope] = []
		for response in responses {
			if case .fetch(let fetchResponse) = response {
				if let envelope = extractEnvelope(from: fetchResponse) {
					envelopes.append(envelope)
				}
			}
		}
		return envelopes
	}

	private func extractEnvelope(from fetchResponse: FetchResponse) -> FetchedEnvelope? {
		var uid = 0
		var envelope: Envelope?
		var flags: [Flag] = []
		var size = 0
		var bodyText: String?

		for attribute in fetchResponse.messageAttributes {
			switch attribute {
			case .uid(let u): uid = Int(u)
			case .envelope(let env): envelope = env
			case .flags(let f): flags = f
			case .rfc822Size(let s): size = s
			case .body(_, let data):
				if let data, let text = String(bytes: data, encoding: .utf8) {
					bodyText = text
				}
			default: break
			}
		}

		guard let env = envelope else { return nil }

		let isRead = flags.contains(.seen)
		let isFlagged = flags.contains(.flagged)

		return FetchedEnvelope(
			uid: uid,
			messageId: env.messageID.map(String.init),
			inReplyTo: env.inReplyTo.map(String.init),
			references: nil, // References not in envelope — need BODY[HEADER.FIELDS]
			subject: env.subject.map(String.init),
			from: env.from.first.map { EmailAddress(name: $0.displayName, address: $0.emailAddress) },
			to: (env.to ?? []).map { EmailAddress(name: $0.displayName, address: $0.emailAddress) },
			cc: (env.cc ?? []).map { EmailAddress(name: $0.displayName, address: $0.emailAddress) },
			date: env.date.map(String.init) ?? "",
			snippet: bodyText.map { String($0.prefix(200)) },
			bodyText: bodyText,
			bodyHtml: nil,
			isRead: isRead,
			isFlagged: isFlagged,
			size: size
		)
	}

	private func parseFlagResponses(_ responses: [Response]) -> [UIDFlagsPair] {
		var pairs: [UIDFlagsPair] = []
		for response in responses {
			if case .fetch(let fetchResponse) = response {
				var uid = 0
				var isRead = false
				var isFlagged = false
				for attr in fetchResponse.messageAttributes {
					switch attr {
					case .uid(let u): uid = Int(u)
					case .flags(let f):
						isRead = f.contains(.seen)
						isFlagged = f.contains(.flagged)
					default: break
					}
				}
				if uid > 0 {
					pairs.append(UIDFlagsPair(uid: uid, isRead: isRead, isFlagged: isFlagged))
				}
			}
		}
		return pairs
	}

	private func parseBodyResponse(_ responses: [Response]) -> (text: String?, html: String?) {
		for response in responses {
			if case .fetch(let fetchResponse) = response {
				for attr in fetchResponse.messageAttributes {
					if case .body(_, let data) = attr,
					   let data,
					   let text = String(bytes: data, encoding: .utf8) {
						return (text, nil)
					}
				}
			}
		}
		return (nil, nil)
	}
}

CRITICAL NOTE: The response parsing code above is written against an assumed swift-nio-imap API. The actual enum cases, property names, and types WILL differ. The implementing agent must:

  1. Run swift build --target IMAPClient and fix all compilation errors
  2. Check the actual Response, FetchResponse, Envelope, Flag, MessageAttribute types in NIOIMAPCore
  3. Adapt the switch cases and property access accordingly
  4. The overall patterns (iterate responses, match on cases, extract data) are correct
  • Step 2: Write parsing unit tests

These test the response parsing logic with constructed response objects, not live IMAP.

Create Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift:

import Testing
@testable import IMAPClient
import NIOIMAPCore

@Suite("IMAP Response Parsing")
struct IMAPResponseParsingTests {
	@Test("IMAPClient can be instantiated")
	func instantiation() {
		let client = IMAPClient(
			host: "imap.example.com",
			port: 993,
			credentials: .init(username: "user", password: "pass")
		)
		// Verify it exists and conforms to protocol
		let _: any IMAPClientProtocol = client
	}

	// The exact NIOIMAPCore constructors vary by version.
	// The implementing agent MUST add the following tests, adapting constructors to the actual API:

	@Test("isOKTagged correctly identifies OK tagged responses")
	func isOKTagged() {
		// Construct a tagged response with .ok status
		// Verify IMAPClient.isOKTagged returns true
		// Construct a tagged response with .no status
		// Verify IMAPClient.isOKTagged returns false
		// The implementing agent must expose isOKTagged or test via connect behavior
	}

	@Test("tag counter increments across commands")
	func tagCounterIncrements() async {
		// Create an IMAPCommandRunner with a mock connection
		// Call nextTag() three times
		// Verify tags are "A1", "A2", "A3"
		var runner = IMAPCommandRunner(connection: IMAPConnection(host: "localhost", port: 993))
		#expect(runner.nextTag() == "A1")
		#expect(runner.nextTag() == "A2")
		#expect(runner.nextTag() == "A3")
	}

	@Test("IMAPError cases are Sendable")
	func errorSendability() {
		let error: any Error & Sendable = IMAPError.notConnected
		#expect(error is IMAPError)
	}
}
  • Step 3: Build and fix compilation errors
cd Packages/MagnumOpusCore && swift build --target IMAPClient 2>&1
# Fix any compilation errors by adapting to actual swift-nio-imap API
# This is expected — the response parsing code must match the real types
  • Step 4: Run all tests
cd Packages/MagnumOpusCore && swift test
# Expected: all existing tests still pass, new tests pass
  • Step 5: Commit
git add Packages/MagnumOpusCore/Sources/IMAPClient/ Packages/MagnumOpusCore/Tests/IMAPClientTests/
git commit -m "add real imap client actor: nio connection, command pipeline, envelope parsing"

Chunk 5: SwiftUI Apps

Task 11: Xcode Project Setup (macOS + iOS)

Use XcodeGen to create a multi-platform project that imports MagnumOpusCore as a local package.

Files:

  • Create: Apps/project.yml

  • Create: Apps/MagnumOpus/MagnumOpusApp.swift

  • Create: Apps/MagnumOpus/ContentView.swift

  • Step 1: Create XcodeGen project.yml

Create Apps/project.yml:

name: MagnumOpus
options:
  bundleIdPrefix: de.felixfoertsch
  deploymentTarget:
    macOS: "15.0"
    iOS: "18.0"
  xcodeVersion: "16.0"
  indentWidth: 4
  tabWidth: 4
  usesTabs: true
packages:
  MagnumOpusCore:
    path: ../Packages/MagnumOpusCore
targets:
  MagnumOpus-macOS:
    type: application
    platform: macOS
    sources:
      - path: MagnumOpus
    settings:
      base:
        PRODUCT_NAME: MagnumOpus
        PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.MagnumOpus
        DEVELOPMENT_TEAM: NG5W75WE8U
        SWIFT_STRICT_CONCURRENCY: complete
        SWIFT_VERSION: "6.0"
        MACOSX_DEPLOYMENT_TARGET: "15.0"
    dependencies:
      - package: MagnumOpusCore
        product: Models
      - package: MagnumOpusCore
        product: MailStore
      - package: MagnumOpusCore
        product: IMAPClient
      - package: MagnumOpusCore
        product: SyncEngine
  MagnumOpus-iOS:
    type: application
    platform: iOS
    sources:
      - path: MagnumOpus
    settings:
      base:
        PRODUCT_NAME: MagnumOpus
        PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.MagnumOpus
        DEVELOPMENT_TEAM: NG5W75WE8U
        SWIFT_STRICT_CONCURRENCY: complete
        SWIFT_VERSION: "6.0"
        IPHONEOS_DEPLOYMENT_TARGET: "18.0"
        TARGETED_DEVICE_FAMILY: "1,2"
    dependencies:
      - package: MagnumOpusCore
        product: Models
      - package: MagnumOpusCore
        product: MailStore
      - package: MagnumOpusCore
        product: IMAPClient
      - package: MagnumOpusCore
        product: SyncEngine
  MagnumOpusTests:
    type: bundle.unit-test
    platform: macOS
    sources:
      - path: MagnumOpusTests
    dependencies:
      - target: MagnumOpus-macOS
    settings:
      base:
        SWIFT_VERSION: "6.0"
  • Step 2: Create app entry point

Create Apps/MagnumOpus/MagnumOpusApp.swift:

import SwiftUI

@main
struct MagnumOpusApp: App {
	var body: some Scene {
		WindowGroup {
			ContentView()
		}
		#if os(macOS)
		.defaultSize(width: 1200, height: 800)
		#endif
	}
}
  • Step 3: Create placeholder ContentView

Create Apps/MagnumOpus/ContentView.swift:

import SwiftUI

struct ContentView: View {
	var body: some View {
		NavigationSplitView {
			Text("Sidebar")
		} content: {
			Text("Thread List")
		} detail: {
			Text("Detail")
		}
	}
}
  • Step 4: Create test placeholder
mkdir -p Apps/MagnumOpusTests

Create Apps/MagnumOpusTests/AppTests.swift:

import Testing

@Suite("App")
struct AppTests {
	@Test("placeholder")
	func placeholder() {
		#expect(true)
	}
}
  • Step 5: Generate Xcode project and verify build
cd Apps && xcodegen generate
# Expected: Generated project "MagnumOpus.xcodeproj"
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 6: Commit
git add Apps/
git commit -m "scaffold multi-platform xcode project with xcodegen"

Task 12: ViewModels

Two ViewModels: MailViewModel for the main mail interface, AccountSetupViewModel for first-launch setup.

Files:

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

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

  • Step 1: Create MailViewModel

Create Apps/MagnumOpus/ViewModels/MailViewModel.swift:

import SwiftUI
import GRDB
import Models
import MailStore
import SyncEngine
import IMAPClient

@Observable
@MainActor
final class MailViewModel {
	private var store: MailStore?
	private var coordinator: SyncCoordinator?

	var threads: [ThreadSummary] = []
	var selectedThread: ThreadSummary?
	var messages: [MessageSummary] = []
	var mailboxes: [MailboxInfo] = []
	var selectedMailbox: MailboxInfo?
	var syncState: SyncState = .idle
	var errorMessage: String?

	private var threadObservation: Task<Void, Never>?
	private var messageObservation: Task<Void, Never>?

	var hasAccount: Bool {
		store != nil && coordinator != nil
	}

	func setup(config: AccountConfig, credentials: Credentials) throws {
		let dbPath = Self.databasePath(for: config.id)
		let dbPool = try DatabaseSetup.openDatabase(atPath: dbPath)
		let mailStore = MailStore(dbWriter: dbPool)
		let imapClient = IMAPClient(
			host: config.imapHost,
			port: config.imapPort,
			credentials: credentials
		)
		store = mailStore
		coordinator = SyncCoordinator(
			accountConfig: config,
			imapClient: imapClient,
			store: mailStore
		)
	}

	func loadMailboxes(accountId: String) async {
		guard let store else { return }
		do {
			let records = try store.mailboxes(accountId: accountId)
			mailboxes = records.map { record in
				MailboxInfo(
					id: record.id, accountId: record.accountId,
					name: record.name, unreadCount: 0, totalCount: 0
				)
			}
			if selectedMailbox == nil, let inbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
				selectedMailbox = inbox
			}
		} catch {
			errorMessage = error.localizedDescription
		}
	}

	func startObservingThreads(accountId: String) {
		guard let store else { return }
		threadObservation?.cancel()
		threadObservation = Task {
			do {
				for try await summaries in store.observeThreadSummaries(accountId: accountId) {
					self.threads = summaries
				}
			} catch {
				if !Task.isCancelled {
					self.errorMessage = error.localizedDescription
				}
			}
		}
	}

	func selectThread(_ thread: ThreadSummary) {
		selectedThread = thread
		messageObservation?.cancel()
		guard let store else { return }
		messageObservation = Task {
			do {
				for try await msgs in store.observeMessages(threadId: thread.id) {
					self.messages = msgs
				}
			} catch {
				if !Task.isCancelled {
					self.errorMessage = error.localizedDescription
				}
			}
		}
	}

	func syncNow() async {
		guard let coordinator else { return }
		do {
			try await coordinator.syncNow()
			syncState = coordinator.syncState
		} catch {
			errorMessage = error.localizedDescription
			syncState = .error(error.localizedDescription)
		}
	}

	func startPeriodicSync() {
		coordinator?.startPeriodicSync()
	}

	func stopSync() {
		coordinator?.stopSync()
		threadObservation?.cancel()
		messageObservation?.cancel()
	}

	static func databasePath(for accountId: String) -> String {
		let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
			.appendingPathComponent("MagnumOpus", isDirectory: true)
		try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
		return dir.appendingPathComponent("\(accountId).sqlite").path
	}
}

Note: DatabaseSetup.openDatabase() returns a GRDB DatabasePool, hence the import GRDB at the top. If module visibility requires it, use MailStore.DatabaseSetup instead.

  • Step 2: Create AccountSetupViewModel

Create Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift:

import SwiftUI
import Models

@Observable
@MainActor
final class AccountSetupViewModel {
	var email: String = ""
	var password: String = ""
	var imapHost: String = ""
	var imapPort: String = "993"
	var accountName: String = ""

	var isAutoDiscovering = false
	var autoDiscoveryFailed = false
	var isManualMode = false
	var errorMessage: String?

	var canSubmit: Bool {
		!email.isEmpty && !password.isEmpty && !imapHost.isEmpty && !imapPort.isEmpty
	}

	func autoDiscover() async {
		isAutoDiscovering = true
		autoDiscoveryFailed = false

		// Auto-discovery implementation in Task 18
		// For now, fall back to manual entry
		isAutoDiscovering = false
		isManualMode = true
	}

	func buildConfig() -> (AccountConfig, Credentials)? {
		guard let port = Int(imapPort), canSubmit else { return nil }
		let id = email.replacingOccurrences(of: "@", with: "-at-")
			.replacingOccurrences(of: ".", with: "-")
		let config = AccountConfig(
			id: id,
			name: accountName.isEmpty ? email : accountName,
			email: email,
			imapHost: imapHost,
			imapPort: port
		)
		let credentials = Credentials(username: email, password: password)
		return (config, credentials)
	}
}
  • Step 3: Commit
git add Apps/MagnumOpus/ViewModels/
git commit -m "add mail, account setup viewmodels with grdb observation"

Task 13: Three-Column Layout Views

Files:

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

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

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

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

  • Modify: Apps/MagnumOpus/ContentView.swift

  • Step 1: Create SidebarView

Create Apps/MagnumOpus/Views/SidebarView.swift:

import SwiftUI
import Models

struct SidebarView: View {
	@Bindable var viewModel: MailViewModel

	var body: some View {
		List(selection: Binding(
			get: { viewModel.selectedMailbox?.id },
			set: { newId in
				viewModel.selectedMailbox = viewModel.mailboxes.first { $0.id == newId }
			}
		)) {
			Section("Mailboxes") {
				ForEach(viewModel.mailboxes) { mailbox in
					Label(mailbox.name, systemImage: mailbox.systemImage)
						.tag(mailbox.id)
						.badge(mailbox.unreadCount)
				}
			}
		}
		.navigationTitle("Magnum Opus")
		.listStyle(.sidebar)
		.toolbar {
			ToolbarItem {
				Button {
					Task { await viewModel.syncNow() }
				} label: {
					switch viewModel.syncState {
					case .syncing:
						ProgressView()
							.controlSize(.small)
					default:
						Label("Sync", systemImage: "arrow.trianglehead.2.clockwise")
					}
				}
			}
		}
	}
}
  • Step 2: Create ThreadListView

Create Apps/MagnumOpus/Views/ThreadListView.swift:

import SwiftUI
import Models

struct ThreadListView: View {
	@Bindable var viewModel: MailViewModel

	var body: some View {
		List(viewModel.threads, selection: Binding(
			get: { viewModel.selectedThread?.id },
			set: { newId in
				if let thread = viewModel.threads.first(where: { $0.id == newId }) {
					viewModel.selectThread(thread)
				}
			}
		)) { thread in
			ThreadRow(thread: thread)
				.tag(thread.id)
		}
		.listStyle(.inset)
		.navigationTitle(viewModel.selectedMailbox?.name ?? "Mail")
		.overlay {
			if viewModel.threads.isEmpty {
				ContentUnavailableView(
					"No Messages",
					systemImage: "tray",
					description: Text("No threads in this mailbox")
				)
			}
		}
	}
}

struct ThreadRow: View {
	let thread: ThreadSummary

	var body: some View {
		VStack(alignment: .leading, spacing: 4) {
			HStack {
				Text(thread.senders)
					.fontWeight(thread.unreadCount > 0 ? .bold : .regular)
					.lineLimit(1)
				Spacer()
				Text(thread.lastDate, style: .relative)
					.font(.caption)
					.foregroundStyle(.secondary)
			}
			Text(thread.subject ?? "(No Subject)")
				.font(.subheadline)
				.lineLimit(1)
			if let snippet = thread.snippet {
				Text(snippet)
					.font(.caption)
					.foregroundStyle(.tertiary)
					.lineLimit(1)
			}
			HStack(spacing: 8) {
				if thread.messageCount > 1 {
					Text("\(thread.messageCount)")
						.font(.caption2)
						.foregroundStyle(.secondary)
						.padding(.horizontal, 6)
						.padding(.vertical, 1)
						.background(.quaternary, in: Capsule())
				}
				if thread.unreadCount > 0 {
					Circle()
						.fill(.blue)
						.frame(width: 8, height: 8)
				}
			}
		}
		.padding(.vertical, 2)
	}
}
  • Step 3: Create ThreadDetailView

Create Apps/MagnumOpus/Views/ThreadDetailView.swift:

import SwiftUI
import Models

struct ThreadDetailView: View {
	let thread: ThreadSummary?
	let messages: [MessageSummary]

	var body: some View {
		Group {
			if let thread {
				ScrollView {
					VStack(alignment: .leading, spacing: 0) {
						Text(thread.subject ?? "(No Subject)")
							.font(.title2)
							.fontWeight(.semibold)
							.padding()

						Divider()

						ForEach(messages) { message in
							MessageView(message: message)
							Divider()
						}
					}
				}
			} else {
				ContentUnavailableView(
					"No Thread Selected",
					systemImage: "envelope",
					description: Text("Select a thread to read")
				)
			}
		}
	}
}

struct MessageView: View {
	let message: MessageSummary

	var body: some View {
		VStack(alignment: .leading, spacing: 8) {
			HStack {
				Text(message.from?.displayName ?? "Unknown")
					.fontWeight(.semibold)
				Spacer()
				Text(message.date, style: .date)
					.font(.caption)
					.foregroundStyle(.secondary)
			}

			if !message.to.isEmpty {
				Text("To: \(message.to.map(\.displayName).joined(separator: ", "))")
					.font(.caption)
					.foregroundStyle(.secondary)
			}

			if let bodyText = message.bodyText {
				Text(bodyText)
					.font(.body)
					.textSelection(.enabled)
			} else if message.snippet != nil {
				Text(message.snippet ?? "")
					.font(.body)
					.foregroundStyle(.secondary)
					.italic()
			} else {
				Text("Loading body…")
					.font(.body)
					.foregroundStyle(.tertiary)
			}
		}
		.padding()
	}
}
  • Step 4: Create AccountSetupView

Create Apps/MagnumOpus/Views/AccountSetupView.swift:

import SwiftUI

struct AccountSetupView: View {
	@Bindable var viewModel: AccountSetupViewModel
	var onComplete: () -> Void

	var body: some View {
		Form {
			Section("Account") {
				TextField("Email", text: $viewModel.email)
					#if os(iOS)
					.textContentType(.emailAddress)
					.keyboardType(.emailAddress)
					.autocapitalization(.none)
					#endif
				SecureField("Password", text: $viewModel.password)
				TextField("Account Name (optional)", text: $viewModel.accountName)
			}

			if viewModel.isManualMode || viewModel.autoDiscoveryFailed {
				Section("Server Settings") {
					TextField("IMAP Host", text: $viewModel.imapHost)
					TextField("IMAP Port", text: $viewModel.imapPort)
				}
			}

			if let error = viewModel.errorMessage {
				Section {
					Text(error)
						.foregroundStyle(.red)
				}
			}

			Section {
				if viewModel.isAutoDiscovering {
					ProgressView("Discovering settings…")
				} else {
					Button("Connect") {
						onComplete()
					}
					.disabled(!viewModel.canSubmit)

					if !viewModel.isManualMode {
						Button("Enter server settings manually") {
							viewModel.isManualMode = true
						}
					}
				}
			}
		}
		.formStyle(.grouped)
		.navigationTitle("Add Account")
		.task {
			if !viewModel.email.isEmpty && !viewModel.isManualMode {
				await viewModel.autoDiscover()
			}
		}
	}
}
  • Step 5: Wire into ContentView

Replace Apps/MagnumOpus/ContentView.swift:

import SwiftUI
import Models
import MailStore

struct ContentView: View {
	@State private var viewModel = MailViewModel()
	@State private var accountSetup = AccountSetupViewModel()
	@State private var showingAccountSetup = false

	var body: some View {
		Group {
			if viewModel.hasAccount {
				mailView
			} else {
				NavigationStack {
					AccountSetupView(viewModel: accountSetup) {
						connectAccount()
					}
				}
			}
		}
		.onAppear {
			loadExistingAccount()
		}
	}

	private var mailView: some View {
		NavigationSplitView {
			SidebarView(viewModel: viewModel)
		} content: {
			ThreadListView(viewModel: viewModel)
		} detail: {
			ThreadDetailView(
				thread: viewModel.selectedThread,
				messages: viewModel.messages
			)
		}
		.task {
			await viewModel.syncNow()
			viewModel.startPeriodicSync()
		}
	}

	private func connectAccount() {
		guard let (config, credentials) = accountSetup.buildConfig() else { return }
		do {
			try viewModel.setup(config: config, credentials: credentials)
			// Keychain storage added in Task 15
			Task {
				await viewModel.syncNow()
				await viewModel.loadMailboxes(accountId: config.id)
				viewModel.startObservingThreads(accountId: config.id)
				viewModel.startPeriodicSync()
			}
		} catch {
			accountSetup.errorMessage = error.localizedDescription
		}
	}

	private func loadExistingAccount() {
		// Keychain loading added in Task 15
		// For now, always show setup
	}
}
  • Step 6: Regenerate Xcode project and build
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 7: Commit
git add Apps/
git commit -m "add three-column swiftui layout: sidebar, thread list, detail, account setup"

Task 14: HTML Message Rendering

Display HTML emails safely using WKWebView wrapped for SwiftUI.

Files:

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

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

  • Step 1: Create WKWebView wrapper

Create Apps/MagnumOpus/Views/MessageWebView.swift:

import SwiftUI
import WebKit

#if os(macOS)
struct MessageWebView: NSViewRepresentable {
	let html: String

	func makeNSView(context: Context) -> WKWebView {
		let config = WKWebViewConfiguration()
		config.preferences.isElementFullscreenEnabled = false
		let prefs = WKWebpagePreferences()
		prefs.allowsContentJavaScript = false
		config.defaultWebpagePreferences = prefs
		let webView = WKWebView(frame: .zero, configuration: config)
		return webView
	}

	func updateNSView(_ webView: WKWebView, context: Context) {
		let sanitized = sanitizeHTML(html)
		webView.loadHTMLString(sanitized, baseURL: nil)
	}
}
#else
struct MessageWebView: UIViewRepresentable {
	let html: String

	func makeUIView(context: Context) -> WKWebView {
		let config = WKWebViewConfiguration()
		let prefs = WKWebpagePreferences()
		prefs.allowsContentJavaScript = false
		config.defaultWebpagePreferences = prefs
		let webView = WKWebView(frame: .zero, configuration: config)
		webView.scrollView.isScrollEnabled = false
		return webView
	}

	func updateUIView(_ webView: WKWebView, context: Context) {
		let sanitized = sanitizeHTML(html)
		webView.loadHTMLString(sanitized, baseURL: nil)
	}
}
#endif

/// Strip scripts, event handlers, and external resources for safe rendering
private func sanitizeHTML(_ html: String) -> String {
	var result = html
	// Remove script tags and their content
	let scriptPattern = "<script[^>]*>[\\s\\S]*?</script>"
	result = result.replacingOccurrences(
		of: scriptPattern,
		with: "",
		options: .regularExpression
	)
	// Remove event handler attributes (onclick, onload, etc.)
	let eventPattern = "\\s+on\\w+\\s*=\\s*\"[^\"]*\""
	result = result.replacingOccurrences(
		of: eventPattern,
		with: "",
		options: .regularExpression
	)
	// Block remote images by default (replace src with data-src)
	let imgPattern = "(<img[^>]*?)\\ssrc\\s*=\\s*\"(https?://[^\"]*)\""
	result = result.replacingOccurrences(
		of: imgPattern,
		with: "$1 data-blocked-src=\"$2\"",
		options: .regularExpression
	)
	// Wrap in basic styling
	return """
		<!DOCTYPE html>
		<html><head>
		<meta name="viewport" content="width=device-width, initial-scale=1">
		<style>
			body { font-family: -apple-system, system-ui; font-size: 14px; padding: 8px; }
			img[data-blocked-src] { display: none; }
		</style>
		</head><body>
		\(result)
		</body></html>
		"""
}
  • Step 2: Update MessageView to support HTML

In Apps/MagnumOpus/Views/ThreadDetailView.swift, update MessageView:

struct MessageView: View {
	let message: MessageSummary
	@State private var showHTML = false

	var body: some View {
		VStack(alignment: .leading, spacing: 8) {
			HStack {
				Text(message.from?.displayName ?? "Unknown")
					.fontWeight(.semibold)
				Spacer()
				if message.bodyHtml != nil {
					Toggle(isOn: $showHTML) {
						Text("HTML")
							.font(.caption)
					}
					.toggleStyle(.button)
					.controlSize(.small)
				}
				Text(message.date, style: .date)
					.font(.caption)
					.foregroundStyle(.secondary)
			}

			if !message.to.isEmpty {
				Text("To: \(message.to.map(\.displayName).joined(separator: ", "))")
					.font(.caption)
					.foregroundStyle(.secondary)
			}

			if showHTML, let html = message.bodyHtml {
				MessageWebView(html: html)
					.frame(minHeight: 200)
			} else if let bodyText = message.bodyText {
				Text(bodyText)
					.font(.body)
					.textSelection(.enabled)
			} else if let snippet = message.snippet {
				Text(snippet)
					.font(.body)
					.foregroundStyle(.secondary)
					.italic()
			} else {
				Text("Loading body…")
					.font(.body)
					.foregroundStyle(.tertiary)
			}
		}
		.padding()
	}
}
  • Step 3: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 4: Commit
git add Apps/MagnumOpus/Views/
git commit -m "add html email rendering with wkwebview, script/tracker blocking"

Chunk 6: Account Setup, Keychain, and Polish

Task 15: Keychain Credential Storage

Store and retrieve IMAP credentials securely via Keychain.

Files:

  • Create: Apps/MagnumOpus/Services/KeychainService.swift

  • Step 1: Write KeychainService

Create Apps/MagnumOpus/Services/KeychainService.swift:

import Foundation
import Security
import Models

enum KeychainService {
	private static let service = "de.felixfoertsch.MagnumOpus"

	static func saveCredentials(_ credentials: Credentials, for accountId: String) throws {
		let passwordData = Data(credentials.password.utf8)

		// Delete existing entry first
		let deleteQuery: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrService as String: service,
			kSecAttrAccount as String: accountId,
		]
		SecItemDelete(deleteQuery as CFDictionary)

		let query: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrService as String: service,
			kSecAttrAccount as String: accountId,
			kSecAttrLabel as String: credentials.username,
			kSecValueData as String: passwordData,
			kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
		]

		let status = SecItemAdd(query as CFDictionary, nil)
		guard status == errSecSuccess else {
			throw KeychainError.saveFailed(status)
		}
	}

	static func loadCredentials(for accountId: String) throws -> Credentials? {
		let query: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrService as String: service,
			kSecAttrAccount as String: accountId,
			kSecReturnData as String: true,
			kSecReturnAttributes as String: true,
			kSecMatchLimit as String: kSecMatchLimitOne,
		]

		var result: AnyObject?
		let status = SecItemCopyMatching(query as CFDictionary, &result)

		guard status == errSecSuccess,
			  let attrs = result as? [String: Any],
			  let data = attrs[kSecValueData as String] as? Data,
			  let password = String(data: data, encoding: .utf8),
			  let username = attrs[kSecAttrLabel as String] as? String
		else {
			if status == errSecItemNotFound { return nil }
			throw KeychainError.loadFailed(status)
		}

		return Credentials(username: username, password: password)
	}

	static func deleteCredentials(for accountId: String) throws {
		let query: [String: Any] = [
			kSecClass as String: kSecClassGenericPassword,
			kSecAttrService as String: service,
			kSecAttrAccount as String: accountId,
		]
		let status = SecItemDelete(query as CFDictionary)
		guard status == errSecSuccess || status == errSecItemNotFound else {
			throw KeychainError.deleteFailed(status)
		}
	}
}

enum KeychainError: Error {
	case saveFailed(OSStatus)
	case loadFailed(OSStatus)
	case deleteFailed(OSStatus)
}
  • Step 2: Wire Keychain into ContentView

Update Apps/MagnumOpus/ContentView.swift — replace connectAccount() and loadExistingAccount():

private func connectAccount() {
	guard let (config, credentials) = accountSetup.buildConfig() else { return }
	do {
		try viewModel.setup(config: config, credentials: credentials)
		try KeychainService.saveCredentials(credentials, for: config.id)
		// Persist account config to UserDefaults
		if let data = try? JSONEncoder().encode(config) {
			UserDefaults.standard.set(data, forKey: "accountConfig")
		}
		Task {
			await viewModel.syncNow()
			await viewModel.loadMailboxes(accountId: config.id)
			viewModel.startObservingThreads(accountId: config.id)
			viewModel.startPeriodicSync()
		}
	} catch {
		accountSetup.errorMessage = error.localizedDescription
	}
}

private func loadExistingAccount() {
	guard let data = UserDefaults.standard.data(forKey: "accountConfig"),
		  let config = try? JSONDecoder().decode(AccountConfig.self, from: data),
		  let credentials = try? KeychainService.loadCredentials(for: config.id)
	else { return }
	do {
		try viewModel.setup(config: config, credentials: credentials)
		Task {
			await viewModel.loadMailboxes(accountId: config.id)
			viewModel.startObservingThreads(accountId: config.id)
			await viewModel.syncNow()
			viewModel.startPeriodicSync()
		}
	} catch {
		// Account config exists but setup failed — show account setup
	}
}
  • Step 3: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 4: Commit
git add Apps/MagnumOpus/Services/KeychainService.swift Apps/MagnumOpus/ContentView.swift
git commit -m "add keychain credential storage, persist account config"

Task 16: IMAP Auto-Discovery

Query Mozilla ISPDB (Thunderbird autoconfig) and DNS SRV records to auto-detect IMAP settings.

Files:

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

  • Modify: Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift

  • Step 1: Write AutoDiscovery service

Create Apps/MagnumOpus/Services/AutoDiscovery.swift:

import Foundation

struct DiscoveredServer: Sendable {
	var hostname: String
	var port: Int
	var socketType: String // "SSL" or "STARTTLS"
}

enum AutoDiscovery {
	/// Try Mozilla ISPDB first, then DNS SRV, then return nil
	static func discoverIMAP(for email: String) async -> DiscoveredServer? {
		guard let domain = email.split(separator: "@").last.map(String.init) else { return nil }

		// 1. Try Mozilla ISPDB
		if let server = await queryISPDB(domain: domain) {
			return server
		}

		// 2. Try DNS SRV record (RFC 6186)
		if let server = await querySRV(domain: domain) {
			return server
		}

		return nil
	}

	private static func queryISPDB(domain: String) async -> DiscoveredServer? {
		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 }

		return parseISPDBXML(xml)
	}

	/// Minimal XML parsing — extract first <incomingServer type="imap"> block
	private static func parseISPDBXML(_ xml: String) -> DiscoveredServer? {
		// Find <incomingServer type="imap"> section
		guard let imapRange = xml.range(of: "<incomingServer type=\"imap\">"),
			  let endRange = xml.range(of: "</incomingServer>", range: imapRange.upperBound..<xml.endIndex)
		else { return nil }

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

		func extractTag(_ tag: String) -> String? {
			guard let start = section.range(of: "<\(tag)>"),
				  let end = section.range(of: "</\(tag)>", 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 querySRV(domain: String) async -> DiscoveredServer? {
		// DNS SRV lookup for _imaps._tcp.<domain> (RFC 6186)
		// Use dnssd or nw_connection for SRV queries
		// For v0.2: try well-known hostname patterns as fallback
		let candidates = [
			"imap.\(domain)",
			"mail.\(domain)",
		]
		for candidate in candidates {
			// Quick TCP connect test on port 993
			if await testConnection(host: candidate, port: 993) {
				return DiscoveredServer(hostname: candidate, port: 993, socketType: "SSL")
			}
		}
		return nil
	}

	private static func testConnection(host: String, port: Int) async -> Bool {
		do {
			return try await withThrowingTaskGroup(of: Bool.self) { group in
				group.addTask {
					let task = URLSession.shared.streamTask(withHostName: host, port: port)
					task.resume()
					// readData is a legacy callback API — bridge to async
					let (data, _, _) = try await task.readData(ofMinLength: 1, maxLength: 1024, timeout: 3)
					task.cancel()
					return !data.isEmpty
				}
				group.addTask {
					try await Task.sleep(for: .seconds(3))
					return false
				}
				// First completed result wins, cancel the other
				let result = try await group.next() ?? false
				group.cancelAll()
				return result
			}
		} catch {
			return false
		}
	}
}
  • Step 2: Wire into AccountSetupViewModel

Update Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift — replace autoDiscover():

func autoDiscover() async {
	guard !email.isEmpty else { return }
	isAutoDiscovering = true
	autoDiscoveryFailed = false

	if let server = await AutoDiscovery.discoverIMAP(for: email) {
		imapHost = server.hostname
		imapPort = String(server.port)
		isAutoDiscovering = false
	} else {
		isAutoDiscovering = false
		autoDiscoveryFailed = true
		isManualMode = true
	}
}

Also update AccountSetupView to trigger auto-discovery when email field changes:

In Apps/MagnumOpus/Views/AccountSetupView.swift, add to the email TextField:

TextField("Email", text: $viewModel.email)
	.onChange(of: viewModel.email) { _, newValue in
		if newValue.contains("@") && !viewModel.isManualMode {
			Task { await viewModel.autoDiscover() }
		}
	}
  • Step 3: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 4: Commit
git add Apps/MagnumOpus/Services/AutoDiscovery.swift Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift Apps/MagnumOpus/Views/AccountSetupView.swift
git commit -m "add imap auto-discovery: mozilla ispdb, dns srv fallback"

Task 17: Offline Behavior and Error States

Add an offline banner and proper error display throughout the app.

Files:

  • Modify: Apps/MagnumOpus/ContentView.swift

  • Modify: Apps/MagnumOpus/Views/SidebarView.swift

  • Step 1: Add sync status banner to mailView

In Apps/MagnumOpus/ContentView.swift, update mailView:

private var mailView: some View {
	NavigationSplitView {
		SidebarView(viewModel: viewModel)
	} content: {
		ThreadListView(viewModel: viewModel)
	} detail: {
		ThreadDetailView(
			thread: viewModel.selectedThread,
			messages: viewModel.messages
		)
	}
	.safeAreaInset(edge: .bottom) {
		statusBanner
	}
	.task {
		await viewModel.syncNow()
		viewModel.startPeriodicSync()
	}
}

@ViewBuilder
private var statusBanner: some View {
	switch viewModel.syncState {
	case .error(let message):
		HStack {
			Image(systemName: "wifi.slash")
			Text("Offline — showing cached mail")
			Spacer()
			Button("Retry") {
				Task { await viewModel.syncNow() }
			}
			.buttonStyle(.borderless)
		}
		.font(.caption)
		.padding(8)
		.background(.yellow.opacity(0.2))
	case .syncing(let mailbox):
		HStack {
			ProgressView()
				.controlSize(.small)
			Text("Syncing\(mailbox.map { " \($0)" } ?? "")…")
				.font(.caption)
			Spacer()
		}
		.padding(8)
		.background(.blue.opacity(0.1))
	case .idle:
		EmptyView()
	}
}
  • Step 2: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 3: Commit
git add Apps/MagnumOpus/ContentView.swift
git commit -m "add offline banner, sync status indicators"

Task 18: Background Body Prefetch

After initial sync, progressively fetch full message bodies for recent messages (last 30 days) so they're available offline.

Files:

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

  • Step 1: Add body prefetch to SyncCoordinator

Add to SyncCoordinator.swift after the performSync() method:

/// Fetch full bodies for recent messages that don't have bodyText yet
private func prefetchBodies(mailboxId: String) async {
	let thirtyDaysAgo = ISO8601DateFormatter().string(
		from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
	)
	do {
		let messages = try store.messages(mailboxId: mailboxId)
		let recent = messages.filter { $0.bodyText == nil && $0.date >= thirtyDaysAgo }
		for message in recent.prefix(50) {
			guard !Task.isCancelled else { break }
			let (text, html) = try await imapClient.fetchBody(uid: message.uid)
			if text != nil || html != nil {
				try store.storeBody(messageId: message.id, text: text, html: html)
			}
		}
	} catch {
		// Background prefetch failure is non-fatal — log and continue
	}
}

Call it at the end of syncMailbox():

// Prefetch bodies before disconnect — runs within the same sync cycle
// while IMAP connection is still active
await prefetchBodies(mailboxId: mailboxId)
  • Step 2: Run all package tests
cd Packages/MagnumOpusCore && swift test
# Expected: all tests pass
  • Step 3: Commit
git add Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift
git commit -m "add background body prefetch for recent messages (last 30 days)"

Task 19: Run All Tests and Final Verification

  • Step 1: Run package tests
cd Packages/MagnumOpusCore && swift test
# Expected: all tests pass (ModelsTests, MailStoreTests, SyncEngineTests, IMAPClientTests)
  • Step 2: Build macOS app
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 3: Build iOS app
xcodebuild -project Apps/MagnumOpus.xcodeproj -scheme MagnumOpus-iOS -destination 'generic/platform=iOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
  • Step 4: Manual verification checklist
  1. Launch macOS app → account setup screen appears
  2. Enter IMAP credentials → auto-discovery finds settings (or manual entry)
  3. Initial sync runs → sidebar shows mailboxes
  4. Select INBOX → thread list populates
  5. Select a thread → messages display in detail view
  6. Search for a keyword → matching messages appear
  7. Check offline: disconnect network → app still shows cached data, offline banner appears
  8. Reconnect → sync resumes, banner disappears
  • Step 5: Bump CalVer version

Update version in Apps/project.yml to today's date (e.g., 2026.03.13). The implementing agent should set MARKETING_VERSION in both macOS and iOS targets.

  • Step 6: Final commit
git status
# Review any remaining unstaged files and add them explicitly, e.g.:
# git add Packages/ Apps/ docs/
git commit -m "v0.2 complete: native swift email client with imap sync, grdb, swiftui"

Future Phases (not in this plan)

Documented in docs/plans/2026-03-13-v0.2-native-email-client-design.md:

  • v0.3: SMTP client, compose/reply/forward, triage actions (archive, delete, flag)
  • v0.4: VTODO tasks via CalDAV, unified inbox, GTD triage workflow
  • v0.5: Contacts via CardDAV, calendar via CalDAV, delegation
  • Later: IMAP IDLE, multiple accounts, keyboard-first triage