# 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:** `SyncEngine` → `IMAPClient` + `MailStore` → `Models`. 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
// 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.

```bash
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**

```bash
cd Packages/MagnumOpusCore && swift package resolve
# Expected: dependencies download successfully
```

- [ ] **Step 4: Commit**

```bash
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`:

```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`:

```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`:

```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`:

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

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

```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`:

```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`:

```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`:

```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`:

```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**

```bash
cd Packages/MagnumOpusCore && swift test --filter ModelsTests
# Expected: all tests pass
```

- [ ] **Step 8: Commit**

```bash
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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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`:

```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:

```bash
rm -f Packages/MagnumOpusCore/Sources/MailStore/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target MailStore
# Expected: builds successfully
```

- [ ] **Step 4: Commit**

```bash
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`:

```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**

```bash
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`:

```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**

```bash
rm -f Packages/MagnumOpusCore/Tests/MailStoreTests/.gitkeep
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests
# Expected: all tests pass
```

- [ ] **Step 5: Commit**

```bash
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`:

```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**

```bash
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`:

```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**

```bash
cd Packages/MagnumOpusCore && swift test --filter ThreadReconstructorTests
# Expected: all tests pass
```

- [ ] **Step 5: Commit**

```bash
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`:

```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**

```bash
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`:

```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**

```bash
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**

```bash
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`:

```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`:

```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`:

```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`:

```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**

```bash
rm -f Packages/MagnumOpusCore/Sources/IMAPClient/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target IMAPClient
# Expected: builds successfully
```

- [ ] **Step 5: Commit**

```bash
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`:

```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**

```bash
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`:

```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**

```bash
rm -f Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift
cd Packages/MagnumOpusCore && swift test --filter SyncCoordinatorTests
# Expected: all tests pass
```

- [ ] **Step 5: Commit**

```bash
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`:

```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`:

```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`:

```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**

```bash
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**

```bash
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`:

```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`:

```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**

```bash
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**

```bash
cd Packages/MagnumOpusCore && swift test
# Expected: all existing tests still pass, new tests pass
```

- [ ] **Step 5: Commit**

```bash
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`:

```yaml
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`:

```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`:

```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**

```bash
mkdir -p Apps/MagnumOpusTests
```

Create `Apps/MagnumOpusTests/AppTests.swift`:

```swift
import Testing

@Suite("App")
struct AppTests {
	@Test("placeholder")
	func placeholder() {
		#expect(true)
	}
}
```

- [ ] **Step 5: Generate Xcode project and verify build**

```bash
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**

```bash
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`:

```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`:

```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**

```bash
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`:

```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`:

```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`:

```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`:

```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`:

```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**

```bash
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**

```bash
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`:

```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`:

```swift
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**

```bash
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**

```bash
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`:

```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()`:

```swift
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**

```bash
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**

```bash
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`:

```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()`:

```swift
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:

```swift
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**

```bash
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**

```bash
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`:

```swift
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**

```bash
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**

```bash
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:

```swift
/// 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()`:

```swift
// 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**

```bash
cd Packages/MagnumOpusCore && swift test
# Expected: all tests pass
```

- [ ] **Step 3: Commit**

```bash
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**

```bash
cd Packages/MagnumOpusCore && swift test
# Expected: all tests pass (ModelsTests, MailStoreTests, SyncEngineTests, IMAPClientTests)
```

- [ ] **Step 2: Build macOS app**

```bash
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**

```bash
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**

```bash
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
