Files
MagnumOpus/docs/plans/2026-03-13-v0.2-implementation-plan.md
T
felixfoertsch f28b44d445 move v0.1 artifacts to DELETE/, fix xcode build, bump calver to 2026.03.14
- move backend/, clients/, scripts/ to DELETE/ (v0.1 era, replaced by on-device arch)
- delete feature/v0.1-backend-and-macos branch
- add TaskStore dependency to project.yml
- fix ComposeViewModel deinit concurrency, make toMessageSummary public
- regenerate Xcode project, verify macOS build succeeds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 10:40:41 +01:00

4279 lines
122 KiB
Markdown

# 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