- 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>
122 KiB
Magnum Opus v0.2 — Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a read-only native Swift email client (macOS + iOS) that syncs via IMAP directly on-device, stores in SQLite/GRDB, and displays threaded email in a three-column SwiftUI layout with full-text search.
Architecture: Pure on-device — no remote server. IMAPClient (swift-nio-imap) syncs mail → SyncCoordinator writes to MailStore (GRDB/SQLite/FTS5) → GRDB ValueObservation pushes updates to @Observable ViewModels → SwiftUI renders. All modules live in a local Swift Package (MagnumOpusCore), consumed by macOS and iOS app targets.
Tech Stack: Swift 6 (strict concurrency), SwiftUI, GRDB.swift, swift-nio-imap, swift-nio-ssl, Keychain Services
Design Document: docs/plans/2026-03-13-v0.2-native-email-client-design.md
Branch: Create feature/v0.2-native-swift from current branch (preserves docs from v0.1 work).
File Structure
MagnumOpus/
├── Packages/
│ └── MagnumOpusCore/
│ ├── Package.swift
│ ├── Sources/
│ │ ├── Models/
│ │ │ ├── AccountConfig.swift ← IMAP connection info (no DB dependency)
│ │ │ ├── Credentials.swift ← username + password value type
│ │ │ ├── EmailAddress.swift ← name + address pair
│ │ │ ├── SyncState.swift ← .idle / .syncing / .error enum
│ │ │ ├── SyncEvent.swift ← .newMessages / .flagsChanged / etc.
│ │ │ ├── ThreadSummary.swift ← UI-facing thread display type
│ │ │ ├── MessageSummary.swift ← UI-facing message display type
│ │ │ └── MailboxInfo.swift ← UI-facing mailbox display type
│ │ │
│ │ ├── MailStore/
│ │ │ ├── MailStore.swift ← public API: queries, inserts, streams
│ │ │ ├── DatabaseSetup.swift ← DatabaseMigrator + schema
│ │ │ ├── Records/
│ │ │ │ ├── AccountRecord.swift
│ │ │ │ ├── MailboxRecord.swift
│ │ │ │ ├── MessageRecord.swift
│ │ │ │ ├── ThreadRecord.swift
│ │ │ │ ├── ThreadMessageRecord.swift
│ │ │ │ └── AttachmentRecord.swift
│ │ │ ├── ThreadReconstructor.swift ← simplified JWZ algorithm
│ │ │ └── Queries.swift ← complex joins, FTS5 search
│ │ │
│ │ ├── IMAPClient/
│ │ │ ├── IMAPClientProtocol.swift ← protocol for testability
│ │ │ ├── IMAPClient.swift ← actor: real NIO implementation
│ │ │ ├── IMAPConnection.swift ← NIO bootstrap + TLS + channel
│ │ │ ├── IMAPResponseHandler.swift ← ChannelInboundHandler
│ │ │ ├── IMAPCommandRunner.swift ← tagged command send + response collect
│ │ │ ├── FetchedEnvelope.swift ← parsed IMAP envelope data
│ │ │ └── IMAPTypes.swift ← MailboxStatus, UIDRange, FetchFields
│ │ │
│ │ └── SyncEngine/
│ │ └── SyncCoordinator.swift ← orchestrates IMAP → MailStore
│ │
│ └── Tests/
│ ├── ModelsTests/
│ │ └── EmailAddressTests.swift
│ ├── MailStoreTests/
│ │ ├── MailStoreTests.swift
│ │ ├── ThreadReconstructorTests.swift
│ │ └── SearchTests.swift
│ ├── IMAPClientTests/
│ │ └── IMAPResponseParsingTests.swift
│ └── SyncEngineTests/
│ ├── SyncCoordinatorTests.swift
│ └── MockIMAPClient.swift
│
├── Apps/
│ ├── project.yml ← XcodeGen: macOS + iOS targets
│ ├── MagnumOpus/
│ │ ├── MagnumOpusApp.swift
│ │ ├── ContentView.swift
│ │ ├── Services/
│ │ │ ├── KeychainService.swift
│ │ │ └── AutoDiscovery.swift
│ │ ├── ViewModels/
│ │ │ ├── MailViewModel.swift
│ │ │ └── AccountSetupViewModel.swift
│ │ └── Views/
│ │ ├── AccountSetupView.swift
│ │ ├── SidebarView.swift
│ │ ├── ThreadListView.swift
│ │ ├── ThreadDetailView.swift
│ │ └── MessageWebView.swift
│ └── MagnumOpusTests/
│ └── ViewModelTests.swift
│
├── docs/
├── Ideas/
└── scripts/
Dependency graph: 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
.swiftfiles in each module (SPM requires at least one.swiftfile per target) -
Step 1: Create Package.swift
Create Packages/MagnumOpusCore/Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MagnumOpusCore",
// macOS 15+ / iOS 18+ required for Swift 6 strict concurrency + latest SwiftUI APIs
platforms: [
.macOS(.v15),
.iOS(.v18),
],
products: [
.library(name: "Models", targets: ["Models"]),
.library(name: "MailStore", targets: ["MailStore"]),
.library(name: "IMAPClient", targets: ["IMAPClient"]),
.library(name: "SyncEngine", targets: ["SyncEngine"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-nio-imap.git", from: "0.1.0"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"),
.package(url: "https://github.com/groue/GRDB.swift.git", from: "7.0.0"),
],
targets: [
.target(name: "Models"),
.target(
name: "MailStore",
dependencies: [
"Models",
.product(name: "GRDB", package: "GRDB.swift"),
]
),
.target(
name: "IMAPClient",
dependencies: [
"Models",
.product(name: "NIOIMAPCore", package: "swift-nio-imap"),
.product(name: "NIOIMAP", package: "swift-nio-imap"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
]
),
.target(
name: "SyncEngine",
dependencies: ["Models", "IMAPClient", "MailStore"]
),
.testTarget(name: "ModelsTests", dependencies: ["Models"]),
.testTarget(name: "MailStoreTests", dependencies: ["MailStore"]),
.testTarget(
name: "IMAPClientTests",
dependencies: [
"IMAPClient",
.product(name: "NIOIMAPCore", package: "swift-nio-imap"),
]
),
.testTarget(name: "SyncEngineTests", dependencies: ["SyncEngine", "IMAPClient", "MailStore"]),
]
)
- Step 2: Create directory structure with placeholder Swift files
SPM requires at least one .swift file per declared target. Create empty enum placeholders so the package compiles before real sources are added in later tasks.
cd /Users/felixfoertsch/Developer/MagnumOpus
mkdir -p Packages/MagnumOpusCore/Sources/{Models,MailStore,IMAPClient,SyncEngine}
mkdir -p Packages/MagnumOpusCore/Tests/{ModelsTests,MailStoreTests,IMAPClientTests,SyncEngineTests}
# Placeholder files (replaced by real sources in later tasks)
echo 'enum MailStorePlaceholder {}' > Packages/MagnumOpusCore/Sources/MailStore/Placeholder.swift
echo 'enum IMAPClientPlaceholder {}' > Packages/MagnumOpusCore/Sources/IMAPClient/Placeholder.swift
echo 'enum SyncEnginePlaceholder {}' > Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/ModelsTests/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/MailStoreTests/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/IMAPClientTests/Placeholder.swift
echo 'import Testing' > Packages/MagnumOpusCore/Tests/SyncEngineTests/Placeholder.swift
Note: Dependency resolution may take several minutes on first run as swift-nio-imap pulls substantial transitive dependencies.
- Step 3: Verify package resolves
cd Packages/MagnumOpusCore && swift package resolve
# Expected: dependencies download successfully
- Step 4: Commit
git add Packages/
git commit -m "scaffold MagnumOpusCore swift package with four modules"
Task 2: Models Module
All types in this module are pure Swift — no GRDB, no NIO. These are the shared vocabulary used across modules and by app targets.
Files:
-
Create:
Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/Credentials.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/EmailAddress.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/SyncState.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/SyncEvent.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/MessageSummary.swift -
Create:
Packages/MagnumOpusCore/Sources/Models/MailboxInfo.swift -
Create:
Packages/MagnumOpusCore/Tests/ModelsTests/EmailAddressTests.swift -
Step 1: Create AccountConfig
Create Packages/MagnumOpusCore/Sources/Models/AccountConfig.swift:
public struct AccountConfig: Sendable, Codable, Equatable {
public var id: String
public var name: String
public var email: String
public var imapHost: String
public var imapPort: Int
public init(id: String, name: String, email: String, imapHost: String, imapPort: Int) {
self.id = id
self.name = name
self.email = email
self.imapHost = imapHost
self.imapPort = imapPort
}
}
- Step 2: Create Credentials
Create Packages/MagnumOpusCore/Sources/Models/Credentials.swift:
public struct Credentials: Sendable {
public var username: String
public var password: String
public init(username: String, password: String) {
self.username = username
self.password = password
}
}
- Step 3: Create EmailAddress
Create Packages/MagnumOpusCore/Sources/Models/EmailAddress.swift:
public struct EmailAddress: Sendable, Codable, Equatable, Hashable {
public var name: String?
public var address: String
public init(name: String? = nil, address: String) {
self.name = name
self.address = address
}
public var displayName: String {
name ?? address
}
/// Parses "Alice <alice@example.com>" or bare "alice@example.com"
public static func parse(_ raw: String) -> EmailAddress {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
guard let openAngle = trimmed.lastIndex(of: "<"),
let closeAngle = trimmed.lastIndex(of: ">"),
openAngle < closeAngle
else {
return EmailAddress(address: trimmed)
}
let addr = String(trimmed[trimmed.index(after: openAngle)..<closeAngle])
let namepart = String(trimmed[..<openAngle]).trimmingCharacters(in: .whitespaces)
return EmailAddress(
name: namepart.isEmpty ? nil : namepart.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
address: addr
)
}
}
- Step 4: Create SyncState and SyncEvent
Create Packages/MagnumOpusCore/Sources/Models/SyncState.swift:
public enum SyncState: Sendable, Equatable {
case idle
case syncing(mailbox: String?)
case error(String)
}
Create Packages/MagnumOpusCore/Sources/Models/SyncEvent.swift:
/// Note: spec defines syncFailed(Error), but Error is not Sendable.
/// Using String instead for Swift 6 strict concurrency compliance.
public enum SyncEvent: Sendable {
case syncStarted
case newMessages(count: Int, mailbox: String)
case flagsChanged(messageIds: [String])
case syncCompleted
case syncFailed(String)
}
- Step 5: Create UI-facing display types
Create Packages/MagnumOpusCore/Sources/Models/ThreadSummary.swift:
import Foundation
public struct ThreadSummary: Sendable, Identifiable, Equatable {
public var id: String
public var accountId: String
public var subject: String?
public var lastDate: Date
public var messageCount: Int
public var unreadCount: Int
public var senders: String
public var snippet: String?
public init(
id: String, accountId: String, subject: String?, lastDate: Date,
messageCount: Int, unreadCount: Int, senders: String, snippet: String?
) {
self.id = id
self.accountId = accountId
self.subject = subject
self.lastDate = lastDate
self.messageCount = messageCount
self.unreadCount = unreadCount
self.senders = senders
self.snippet = snippet
}
}
Create Packages/MagnumOpusCore/Sources/Models/MessageSummary.swift:
import Foundation
public struct MessageSummary: Sendable, Identifiable, Equatable {
public var id: String
public var messageId: String?
public var threadId: String?
public var from: EmailAddress?
public var to: [EmailAddress]
public var cc: [EmailAddress]
public var subject: String?
public var date: Date
public var snippet: String?
public var bodyText: String?
public var bodyHtml: String?
public var isRead: Bool
public var isFlagged: Bool
public var hasAttachments: Bool
public init(
id: String, messageId: String?, threadId: String?,
from: EmailAddress?, to: [EmailAddress], cc: [EmailAddress],
subject: String?, date: Date, snippet: String?,
bodyText: String?, bodyHtml: String?,
isRead: Bool, isFlagged: Bool, hasAttachments: Bool
) {
self.id = id
self.messageId = messageId
self.threadId = threadId
self.from = from
self.to = to
self.cc = cc
self.subject = subject
self.date = date
self.snippet = snippet
self.bodyText = bodyText
self.bodyHtml = bodyHtml
self.isRead = isRead
self.isFlagged = isFlagged
self.hasAttachments = hasAttachments
}
}
Create Packages/MagnumOpusCore/Sources/Models/MailboxInfo.swift:
public struct MailboxInfo: Sendable, Identifiable, Equatable {
public var id: String
public var accountId: String
public var name: String
public var unreadCount: Int
public var totalCount: Int
public init(id: String, accountId: String, name: String, unreadCount: Int, totalCount: Int) {
self.id = id
self.accountId = accountId
self.name = name
self.unreadCount = unreadCount
self.totalCount = totalCount
}
public var systemImage: String {
switch name.lowercased() {
case "inbox": "tray"
case "sent", "sent messages": "paperplane"
case "drafts": "doc"
case "trash", "deleted messages": "trash"
case "archive", "all mail": "archivebox"
case "junk", "spam": "xmark.bin"
default: "folder"
}
}
}
- Step 6: Write EmailAddress tests
Create Packages/MagnumOpusCore/Tests/ModelsTests/EmailAddressTests.swift:
import Testing
@testable import Models
@Suite("EmailAddress")
struct EmailAddressTests {
@Test("parses name and address from angle-bracket format")
func parsesNameAndAddress() {
let addr = EmailAddress.parse("Alice <alice@example.com>")
#expect(addr.name == "Alice")
#expect(addr.address == "alice@example.com")
#expect(addr.displayName == "Alice")
}
@Test("parses bare email address")
func parsesBareAddress() {
let addr = EmailAddress.parse("bob@example.com")
#expect(addr.name == nil)
#expect(addr.address == "bob@example.com")
#expect(addr.displayName == "bob@example.com")
}
@Test("parses quoted name with angle brackets")
func parsesQuotedName() {
let addr = EmailAddress.parse("\"Bob Smith\" <bob@example.com>")
#expect(addr.name == "Bob Smith")
#expect(addr.address == "bob@example.com")
}
@Test("handles empty string gracefully")
func handlesEmpty() {
let addr = EmailAddress.parse("")
#expect(addr.address == "")
}
}
- Step 7: Run tests
cd Packages/MagnumOpusCore && swift test --filter ModelsTests
# Expected: all tests pass
- Step 8: Commit
git add Packages/MagnumOpusCore/Sources/Models/ Packages/MagnumOpusCore/Tests/ModelsTests/
git commit -m "add models module: shared types for accounts, messages, threads, sync"
Chunk 2: MailStore
Task 3: Database Schema and Migrations
Files:
-
Create:
Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/MessageRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/ThreadRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/ThreadMessageRecord.swift -
Create:
Packages/MagnumOpusCore/Sources/MailStore/Records/AttachmentRecord.swift -
Step 1: Create GRDB record types
Create Packages/MagnumOpusCore/Sources/MailStore/Records/AccountRecord.swift:
import GRDB
public struct AccountRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "account"
public var id: String
public var name: String
public var email: String
public var imapHost: String
public var imapPort: Int
public init(id: String, name: String, email: String, imapHost: String, imapPort: Int) {
self.id = id
self.name = name
self.email = email
self.imapHost = imapHost
self.imapPort = imapPort
}
}
Create Packages/MagnumOpusCore/Sources/MailStore/Records/MailboxRecord.swift:
import GRDB
public struct MailboxRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "mailbox"
public var id: String
public var accountId: String
public var name: String
public var uidValidity: Int
public var uidNext: Int
public init(id: String, accountId: String, name: String, uidValidity: Int, uidNext: Int) {
self.id = id
self.accountId = accountId
self.name = name
self.uidValidity = uidValidity
self.uidNext = uidNext
}
}
Create Packages/MagnumOpusCore/Sources/MailStore/Records/MessageRecord.swift:
import GRDB
public struct MessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "message"
public var id: String
public var accountId: String
public var mailboxId: String
public var uid: Int
public var messageId: String?
public var inReplyTo: String?
public var refs: String?
public var subject: String?
public var fromAddress: String?
public var fromName: String?
public var toAddresses: String?
public var ccAddresses: String?
public var date: String
public var snippet: String?
public var bodyText: String?
public var bodyHtml: String?
public var isRead: Bool
public var isFlagged: Bool
public var size: Int
public init(
id: String, accountId: String, mailboxId: String, uid: Int,
messageId: String?, inReplyTo: String?, refs: String?,
subject: String?, fromAddress: String?, fromName: String?,
toAddresses: String?, ccAddresses: String?,
date: String, snippet: String?, bodyText: String?, bodyHtml: String?,
isRead: Bool, isFlagged: Bool, size: Int
) {
self.id = id
self.accountId = accountId
self.mailboxId = mailboxId
self.uid = uid
self.messageId = messageId
self.inReplyTo = inReplyTo
self.refs = refs
self.subject = subject
self.fromAddress = fromAddress
self.fromName = fromName
self.toAddresses = toAddresses
self.ccAddresses = ccAddresses
self.date = date
self.snippet = snippet
self.bodyText = bodyText
self.bodyHtml = bodyHtml
self.isRead = isRead
self.isFlagged = isFlagged
self.size = size
}
}
Create Packages/MagnumOpusCore/Sources/MailStore/Records/ThreadRecord.swift:
import GRDB
public struct ThreadRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "thread"
public var id: String
public var accountId: String
public var subject: String?
public var lastDate: String
public var messageCount: Int
public init(id: String, accountId: String, subject: String?, lastDate: String, messageCount: Int) {
self.id = id
self.accountId = accountId
self.subject = subject
self.lastDate = lastDate
self.messageCount = messageCount
}
}
Create Packages/MagnumOpusCore/Sources/MailStore/Records/ThreadMessageRecord.swift:
import GRDB
public struct ThreadMessageRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "threadMessage"
public var threadId: String
public var messageId: String
public init(threadId: String, messageId: String) {
self.threadId = threadId
self.messageId = messageId
}
}
Create Packages/MagnumOpusCore/Sources/MailStore/Records/AttachmentRecord.swift:
import GRDB
public struct AttachmentRecord: Codable, FetchableRecord, PersistableRecord, Sendable {
public static let databaseTableName = "attachment"
public var id: String
public var messageId: String
public var filename: String?
public var mimeType: String
public var size: Int
public var contentId: String?
public var cachePath: String?
public init(
id: String, messageId: String, filename: String?, mimeType: String,
size: Int, contentId: String?, cachePath: String?
) {
self.id = id
self.messageId = messageId
self.filename = filename
self.mimeType = mimeType
self.size = size
self.contentId = contentId
self.cachePath = cachePath
}
}
- Step 2: Create database schema with DatabaseMigrator
Create Packages/MagnumOpusCore/Sources/MailStore/DatabaseSetup.swift:
import GRDB
public enum DatabaseSetup {
public static func migrator() -> DatabaseMigrator {
var migrator = DatabaseMigrator()
migrator.registerMigration("v1_initial") { db in
try db.create(table: "account") { t in
t.primaryKey("id", .text)
t.column("name", .text).notNull()
t.column("email", .text).notNull()
t.column("imapHost", .text).notNull()
t.column("imapPort", .integer).notNull()
}
try db.create(table: "mailbox") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.column("name", .text).notNull()
t.column("uidValidity", .integer).notNull()
t.column("uidNext", .integer).notNull()
}
try db.create(table: "message") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.belongsTo("mailbox", onDelete: .cascade).notNull()
t.column("uid", .integer).notNull()
t.column("messageId", .text)
t.column("inReplyTo", .text)
t.column("refs", .text)
t.column("subject", .text)
t.column("fromAddress", .text)
t.column("fromName", .text)
t.column("toAddresses", .text)
t.column("ccAddresses", .text)
t.column("date", .text).notNull()
t.column("snippet", .text)
t.column("bodyText", .text)
t.column("bodyHtml", .text)
t.column("isRead", .boolean).notNull().defaults(to: false)
t.column("isFlagged", .boolean).notNull().defaults(to: false)
t.column("size", .integer).notNull().defaults(to: 0)
t.uniqueKey(["mailboxId", "uid"])
}
try db.create(table: "thread") { t in
t.primaryKey("id", .text)
t.belongsTo("account", onDelete: .cascade).notNull()
t.column("subject", .text)
t.column("lastDate", .text).notNull()
t.column("messageCount", .integer).notNull().defaults(to: 0)
}
try db.create(table: "threadMessage") { t in
t.belongsTo("thread", onDelete: .cascade).notNull()
t.belongsTo("message", onDelete: .cascade).notNull()
t.primaryKey(["threadId", "messageId"])
}
try db.create(table: "attachment") { t in
t.primaryKey("id", .text)
t.belongsTo("message", onDelete: .cascade).notNull()
t.column("filename", .text)
t.column("mimeType", .text).notNull()
t.column("size", .integer).notNull().defaults(to: 0)
t.column("contentId", .text)
t.column("cachePath", .text)
}
try db.create(index: "idx_message_mailbox_uid", on: "message", columns: ["mailboxId", "uid"])
try db.create(index: "idx_message_messageId", on: "message", columns: ["messageId"])
try db.create(index: "idx_thread_lastDate", on: "thread", columns: ["lastDate"])
}
migrator.registerMigration("v1_fts5") { db in
try db.create(virtualTable: "messageFts", using: FTS5()) { t in
t.synchronize(withTable: "message")
t.tokenizer = .porter(wrapping: .unicode61())
t.column("subject")
t.column("fromName")
t.column("fromAddress")
t.column("bodyText")
}
}
return migrator
}
public static func openDatabase(atPath path: String) throws -> DatabasePool {
let pool = try DatabasePool(path: path)
try migrator().migrate(pool)
return pool
}
public static func openInMemoryDatabase() throws -> DatabaseQueue {
let queue = try DatabaseQueue()
try migrator().migrate(queue)
return queue
}
}
- Step 3: Verify schema compiles and creates tables
Remove the placeholder file and verify real sources compile:
rm -f Packages/MagnumOpusCore/Sources/MailStore/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target MailStore
# Expected: builds successfully
- Step 4: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/
git commit -m "add mailstore schema: accounts, mailboxes, messages, threads, FTS5"
Task 4: MailStore CRUD Operations
Files:
-
Create:
Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift -
Create:
Packages/MagnumOpusCore/Tests/MailStoreTests/MailStoreTests.swift -
Step 1: Write failing tests for basic CRUD
Create Packages/MagnumOpusCore/Tests/MailStoreTests/MailStoreTests.swift:
import Testing
import GRDB
@testable import MailStore
@testable import Models
@Suite("MailStore CRUD")
struct MailStoreTests {
func makeStore() throws -> MailStore {
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
}
@Test("insert and retrieve account")
func accountCRUD() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
let accounts = try store.accounts()
#expect(accounts.count == 1)
#expect(accounts[0].name == "Personal")
}
@Test("insert and retrieve mailbox")
func mailboxCRUD() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
let mailboxes = try store.mailboxes(accountId: "acc1")
#expect(mailboxes.count == 1)
#expect(mailboxes[0].name == "INBOX")
}
@Test("insert and retrieve messages")
func messageCRUD() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
try store.insertMessages([
MessageRecord(
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
messageId: "msg001@example.com", inReplyTo: nil, refs: nil,
subject: "Hello", fromAddress: "alice@example.com", fromName: "Alice",
toAddresses: "[{\"address\":\"user@example.com\"}]", ccAddresses: nil,
date: "2024-03-08T10:15:32Z", snippet: "Hi there",
bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 1024
),
])
let messages = try store.messages(mailboxId: "mb1")
#expect(messages.count == 1)
#expect(messages[0].subject == "Hello")
}
@Test("update mailbox uidNext")
func updateMailboxUidNext() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
try store.updateMailboxSync(id: "mb1", uidValidity: 1, uidNext: 150)
let mailboxes = try store.mailboxes(accountId: "acc1")
#expect(mailboxes[0].uidNext == 150)
}
@Test("update message flags")
func updateFlags() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
try store.insertMessages([
MessageRecord(
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
messageId: nil, inReplyTo: nil, refs: nil,
subject: "Test", fromAddress: nil, fromName: nil,
toAddresses: nil, ccAddresses: nil,
date: "2024-03-08T10:15:32Z", snippet: nil,
bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 0
),
])
try store.updateFlags(messageId: "m1", isRead: true, isFlagged: true)
let messages = try store.messages(mailboxId: "mb1")
#expect(messages[0].isRead == true)
#expect(messages[0].isFlagged == true)
}
@Test("store body text and html")
func storeBody() throws {
let store = try makeStore()
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "user@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
try store.insertMessages([
MessageRecord(
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
messageId: nil, inReplyTo: nil, refs: nil,
subject: "Test", fromAddress: nil, fromName: nil,
toAddresses: nil, ccAddresses: nil,
date: "2024-03-08T10:15:32Z", snippet: nil,
bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 0
),
])
try store.storeBody(messageId: "m1", text: "Plain text body", html: "<p>HTML body</p>")
let msg = try store.message(id: "m1")
#expect(msg?.bodyText == "Plain text body")
#expect(msg?.bodyHtml == "<p>HTML body</p>")
}
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests 2>&1 | head -20
# Expected: FAIL — MailStore type not defined
- Step 3: Write MailStore implementation
Create Packages/MagnumOpusCore/Sources/MailStore/MailStore.swift:
import GRDB
import Models
public final class MailStore: Sendable {
private let dbWriter: any DatabaseWriter
public init(dbWriter: any DatabaseWriter) {
self.dbWriter = dbWriter
}
// MARK: - Accounts
public func insertAccount(_ account: AccountRecord) throws {
try dbWriter.write { db in
try account.insert(db)
}
}
public func accounts() throws -> [AccountRecord] {
try dbWriter.read { db in
try AccountRecord.fetchAll(db)
}
}
// MARK: - Mailboxes
public func upsertMailbox(_ mailbox: MailboxRecord) throws {
try dbWriter.write { db in
try mailbox.save(db)
}
}
public func mailboxes(accountId: String) throws -> [MailboxRecord] {
try dbWriter.read { db in
try MailboxRecord
.filter(Column("accountId") == accountId)
.order(Column("name"))
.fetchAll(db)
}
}
public func mailbox(id: String) throws -> MailboxRecord? {
try dbWriter.read { db in
try MailboxRecord.fetchOne(db, key: id)
}
}
public func updateMailboxSync(id: String, uidValidity: Int, uidNext: Int) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE mailbox SET uidValidity = ?, uidNext = ? WHERE id = ?",
arguments: [uidValidity, uidNext, id]
)
}
}
// MARK: - Messages
public func insertMessages(_ messages: [MessageRecord]) throws {
try dbWriter.write { db in
for message in messages {
try message.save(db)
}
}
}
public func messages(mailboxId: String) throws -> [MessageRecord] {
try dbWriter.read { db in
try MessageRecord
.filter(Column("mailboxId") == mailboxId)
.order(Column("date").desc)
.fetchAll(db)
}
}
public func message(id: String) throws -> MessageRecord? {
try dbWriter.read { db in
try MessageRecord.fetchOne(db, key: id)
}
}
public func messagesForThread(threadId: String) throws -> [MessageRecord] {
try dbWriter.read { db in
try MessageRecord
.joining(required: MessageRecord.hasOne(
ThreadMessageRecord.self,
key: "threadMessage",
using: ForeignKey(["messageId"])
).filter(Column("threadId") == threadId))
.order(Column("date").asc)
.fetchAll(db)
}
}
public func updateFlags(messageId: String, isRead: Bool, isFlagged: Bool) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET isRead = ?, isFlagged = ? WHERE id = ?",
arguments: [isRead, isFlagged, messageId]
)
}
}
public func storeBody(messageId: String, text: String?, html: String?) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE message SET bodyText = ?, bodyHtml = ? WHERE id = ?",
arguments: [text, html, messageId]
)
}
}
// MARK: - Threads
public func threads(accountId: String) throws -> [ThreadRecord] {
try dbWriter.read { db in
try ThreadRecord
.filter(Column("accountId") == accountId)
.order(Column("lastDate").desc)
.fetchAll(db)
}
}
public func insertThread(_ thread: ThreadRecord) throws {
try dbWriter.write { db in
try thread.save(db)
}
}
public func linkMessageToThread(threadId: String, messageId: String) throws {
try dbWriter.write { db in
try ThreadMessageRecord(threadId: threadId, messageId: messageId).save(db)
}
}
public func updateThread(id: String, lastDate: String, messageCount: Int, subject: String?) throws {
try dbWriter.write { db in
try db.execute(
sql: "UPDATE thread SET lastDate = ?, messageCount = ?, subject = COALESCE(?, subject) WHERE id = ?",
arguments: [lastDate, messageCount, subject, id]
)
}
}
/// Returns all message IDs linked to a thread
public func threadMessageIds(threadId: String) throws -> [String] {
try dbWriter.read { db in
try String.fetchAll(
db,
sql: "SELECT messageId FROM threadMessage WHERE threadId = ?",
arguments: [threadId]
)
}
}
/// Finds thread IDs that contain any of the given message IDs (by RFC 5322 Message-ID)
public func findThreadsByMessageIds(_ messageIds: Set<String>) throws -> [String] {
guard !messageIds.isEmpty else { return [] }
return try dbWriter.read { db in
let placeholders = databaseQuestionMarks(count: messageIds.count)
let sql = """
SELECT DISTINCT tm.threadId
FROM threadMessage tm
JOIN message m ON m.id = tm.messageId
WHERE m.messageId IN (\(placeholders))
"""
return try String.fetchAll(db, sql: sql, arguments: StatementArguments(Array(messageIds)))
}
}
/// Merges multiple threads into one, keeping the first thread ID
public func mergeThreads(_ threadIds: [String]) throws {
guard threadIds.count > 1 else { return }
let keepId = threadIds[0]
let mergeIds = Array(threadIds.dropFirst())
try dbWriter.write { db in
for mergeId in mergeIds {
try db.execute(
sql: "UPDATE threadMessage SET threadId = ? WHERE threadId = ?",
arguments: [keepId, mergeId]
)
try db.execute(
sql: "DELETE FROM thread WHERE id = ?",
arguments: [mergeId]
)
}
}
}
/// Access the underlying database writer (for ValueObservation)
public var databaseReader: any DatabaseReader {
dbWriter
}
}
- Step 4: Run tests to verify they pass
rm -f Packages/MagnumOpusCore/Tests/MailStoreTests/.gitkeep
cd Packages/MagnumOpusCore && swift test --filter MailStoreTests
# Expected: all tests pass
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/ Packages/MagnumOpusCore/Tests/MailStoreTests/
git commit -m "add mailstore CRUD: accounts, mailboxes, messages, threads, flags, body"
Task 5: Thread Reconstruction
Simplified JWZ algorithm: link messages by Message-ID / In-Reply-To / References. No subject-based fallback.
Files:
-
Create:
Packages/MagnumOpusCore/Sources/MailStore/ThreadReconstructor.swift -
Create:
Packages/MagnumOpusCore/Tests/MailStoreTests/ThreadReconstructorTests.swift -
Step 1: Write failing tests
Create Packages/MagnumOpusCore/Tests/MailStoreTests/ThreadReconstructorTests.swift:
import Testing
import GRDB
@testable import MailStore
@Suite("ThreadReconstructor")
struct ThreadReconstructorTests {
func makeStore() throws -> MailStore {
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
}
func seedAccount(_ store: MailStore) throws {
try store.insertAccount(AccountRecord(
id: "acc1", name: "Test", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
}
func makeMessage(
id: String, messageId: String?, inReplyTo: String? = nil,
refs: String? = nil, subject: String = "Test", date: String = "2024-03-08T10:00:00Z"
) -> MessageRecord {
MessageRecord(
id: id, accountId: "acc1", mailboxId: "mb1", uid: Int.random(in: 1...99999),
messageId: messageId, inReplyTo: inReplyTo, refs: refs,
subject: subject, fromAddress: "alice@example.com", fromName: "Alice",
toAddresses: nil, ccAddresses: nil,
date: date, snippet: nil, bodyText: nil, bodyHtml: nil,
isRead: false, isFlagged: false, size: 100
)
}
@Test("creates new thread for standalone message")
func standaloneMessage() throws {
let store = try makeStore()
try seedAccount(store)
let msg = makeMessage(id: "m1", messageId: "msg001@example.com")
try store.insertMessages([msg])
let reconstructor = ThreadReconstructor(store: store)
try reconstructor.processMessages([msg])
let threads = try store.threads(accountId: "acc1")
#expect(threads.count == 1)
#expect(threads[0].messageCount == 1)
}
@Test("groups reply into same thread via In-Reply-To")
func replyByInReplyTo() throws {
let store = try makeStore()
try seedAccount(store)
let msg1 = makeMessage(id: "m1", messageId: "msg001@example.com", date: "2024-03-08T10:00:00Z")
let msg2 = makeMessage(
id: "m2", messageId: "msg002@example.com",
inReplyTo: "msg001@example.com",
subject: "Re: Test", date: "2024-03-08T11:00:00Z"
)
try store.insertMessages([msg1, msg2])
let reconstructor = ThreadReconstructor(store: store)
try reconstructor.processMessages([msg1])
try reconstructor.processMessages([msg2])
let threads = try store.threads(accountId: "acc1")
#expect(threads.count == 1)
#expect(threads[0].messageCount == 2)
}
@Test("groups reply into same thread via References")
func replyByReferences() throws {
let store = try makeStore()
try seedAccount(store)
let msg1 = makeMessage(id: "m1", messageId: "msg001@example.com", date: "2024-03-08T10:00:00Z")
let msg2 = makeMessage(
id: "m2", messageId: "msg003@example.com",
refs: "msg001@example.com msg002@example.com",
date: "2024-03-08T12:00:00Z"
)
try store.insertMessages([msg1, msg2])
let reconstructor = ThreadReconstructor(store: store)
try reconstructor.processMessages([msg1])
try reconstructor.processMessages([msg2])
let threads = try store.threads(accountId: "acc1")
#expect(threads.count == 1)
}
@Test("merges threads when new message connects them")
func mergeThreads() throws {
let store = try makeStore()
try seedAccount(store)
let msg1 = makeMessage(id: "m1", messageId: "msg001@example.com", date: "2024-03-08T10:00:00Z")
let msg2 = makeMessage(id: "m2", messageId: "msg002@example.com", date: "2024-03-08T11:00:00Z")
try store.insertMessages([msg1, msg2])
let reconstructor = ThreadReconstructor(store: store)
try reconstructor.processMessages([msg1])
try reconstructor.processMessages([msg2])
// two separate threads
#expect(try store.threads(accountId: "acc1").count == 2)
// msg3 references both, merging the threads
let msg3 = makeMessage(
id: "m3", messageId: "msg003@example.com",
refs: "msg001@example.com msg002@example.com",
date: "2024-03-08T12:00:00Z"
)
try store.insertMessages([msg3])
try reconstructor.processMessages([msg3])
#expect(try store.threads(accountId: "acc1").count == 1)
#expect(try store.threads(accountId: "acc1")[0].messageCount == 3)
}
@Test("message without messageId gets its own thread")
func noMessageId() throws {
let store = try makeStore()
try seedAccount(store)
let msg = makeMessage(id: "m1", messageId: nil)
try store.insertMessages([msg])
let reconstructor = ThreadReconstructor(store: store)
try reconstructor.processMessages([msg])
let threads = try store.threads(accountId: "acc1")
#expect(threads.count == 1)
#expect(threads[0].messageCount == 1)
}
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter ThreadReconstructorTests 2>&1 | head -10
# Expected: FAIL — ThreadReconstructor not defined
- Step 3: Write ThreadReconstructor implementation
Create Packages/MagnumOpusCore/Sources/MailStore/ThreadReconstructor.swift:
import Foundation
import GRDB
/// Simplified JWZ thread reconstruction.
/// Links messages by Message-ID, In-Reply-To, and References headers.
/// No subject-based fallback (produces false matches).
public struct ThreadReconstructor: Sendable {
private let store: MailStore
public init(store: MailStore) {
self.store = store
}
/// Process newly inserted messages and assign them to threads.
public func processMessages(_ messages: [MessageRecord]) throws {
for message in messages {
try processOneMessage(message)
}
}
private func processOneMessage(_ message: MessageRecord) throws {
// Collect all related Message-IDs from In-Reply-To and References
var relatedIds = Set<String>()
if let inReplyTo = message.inReplyTo, !inReplyTo.isEmpty {
relatedIds.insert(inReplyTo)
}
if let refs = message.refs, !refs.isEmpty {
for ref in refs.split(separator: " ") {
let trimmed = ref.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty {
relatedIds.insert(trimmed)
}
}
}
if let mid = message.messageId, !mid.isEmpty {
relatedIds.insert(mid)
}
// Find existing threads that contain any of these Message-IDs
let matchingThreadIds = try store.findThreadsByMessageIds(relatedIds)
let threadId: String
if matchingThreadIds.isEmpty {
// No existing thread — create a new one
threadId = UUID().uuidString
let subject = stripReplyPrefix(message.subject)
try store.insertThread(ThreadRecord(
id: threadId,
accountId: message.accountId,
subject: subject,
lastDate: message.date,
messageCount: 1
))
} else if matchingThreadIds.count == 1 {
// Exactly one matching thread — add to it
threadId = matchingThreadIds[0]
try updateThreadMetadata(threadId: threadId, newMessage: message)
} else {
// Multiple matching threads — merge them, then add message
try store.mergeThreads(matchingThreadIds)
threadId = matchingThreadIds[0]
try updateThreadMetadata(threadId: threadId, newMessage: message)
}
// Link message to thread
try store.linkMessageToThread(threadId: threadId, messageId: message.id)
}
private func updateThreadMetadata(threadId: String, newMessage: MessageRecord) throws {
let existingMessageIds = try store.threadMessageIds(threadId: threadId)
let newCount = existingMessageIds.count + 1
let threads = try store.threads(accountId: newMessage.accountId)
let currentThread = threads.first { $0.id == threadId }
let lastDate = max(currentThread?.lastDate ?? "", newMessage.date)
try store.updateThread(
id: threadId,
lastDate: lastDate,
messageCount: newCount,
subject: nil
)
}
/// Strip Re:, Fwd:, and similar prefixes for thread subject normalization
private func stripReplyPrefix(_ subject: String?) -> String? {
guard var s = subject else { return nil }
let prefixes = ["re:", "fwd:", "fw:"]
var changed = true
while changed {
changed = false
let trimmed = s.trimmingCharacters(in: .whitespaces)
for prefix in prefixes {
if trimmed.lowercased().hasPrefix(prefix) {
s = String(trimmed.dropFirst(prefix.count))
changed = true
break
}
}
}
return s.trimmingCharacters(in: .whitespaces)
}
}
- Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore && swift test --filter ThreadReconstructorTests
# Expected: all tests pass
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/ThreadReconstructor.swift Packages/MagnumOpusCore/Tests/MailStoreTests/ThreadReconstructorTests.swift
git commit -m "add thread reconstruction: simplified JWZ with merge support"
Task 6: FTS5 Search and ValueObservation Streams
Files:
-
Create:
Packages/MagnumOpusCore/Sources/MailStore/Queries.swift -
Create:
Packages/MagnumOpusCore/Tests/MailStoreTests/SearchTests.swift -
Step 1: Write failing tests
Create Packages/MagnumOpusCore/Tests/MailStoreTests/SearchTests.swift:
import Testing
import GRDB
@testable import MailStore
@testable import Models
@Suite("MailStore Search & Queries")
struct SearchTests {
func makeStore() throws -> MailStore {
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
}
func seedData(_ store: MailStore) throws {
try store.insertAccount(AccountRecord(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
))
try store.upsertMailbox(MailboxRecord(
id: "mb1", accountId: "acc1", name: "INBOX", uidValidity: 1, uidNext: 100
))
try store.insertMessages([
MessageRecord(
id: "m1", accountId: "acc1", mailboxId: "mb1", uid: 1,
messageId: "msg001@example.com", inReplyTo: nil, refs: nil,
subject: "Quarterly planning meeting", fromAddress: "alice@example.com",
fromName: "Alice Johnson", toAddresses: nil, ccAddresses: nil,
date: "2024-03-08T10:00:00Z", snippet: "Let's discuss Q2 goals",
bodyText: "Let's discuss Q2 goals and roadmap priorities.", bodyHtml: nil,
isRead: false, isFlagged: false, size: 1024
),
MessageRecord(
id: "m2", accountId: "acc1", mailboxId: "mb1", uid: 2,
messageId: "msg002@example.com", inReplyTo: nil, refs: nil,
subject: "Invoice #4521", fromAddress: "billing@vendor.com",
fromName: "Billing Dept", toAddresses: nil, ccAddresses: nil,
date: "2024-03-07T09:00:00Z", snippet: "Please find attached",
bodyText: "Your invoice for March is attached.", bodyHtml: nil,
isRead: true, isFlagged: false, size: 2048
),
])
}
@Test("FTS5 search finds messages by subject")
func searchBySubject() throws {
let store = try makeStore()
try seedData(store)
let results = try store.search(query: "quarterly")
#expect(results.count == 1)
#expect(results[0].id == "m1")
}
@Test("FTS5 search finds messages by body text")
func searchByBody() throws {
let store = try makeStore()
try seedData(store)
let results = try store.search(query: "roadmap")
#expect(results.count == 1)
#expect(results[0].id == "m1")
}
@Test("FTS5 search finds messages by sender name")
func searchBySender() throws {
let store = try makeStore()
try seedData(store)
let results = try store.search(query: "alice")
#expect(results.count == 1)
#expect(results[0].fromName == "Alice Johnson")
}
@Test("FTS5 search returns empty for no matches")
func searchNoMatch() throws {
let store = try makeStore()
try seedData(store)
let results = try store.search(query: "nonexistent")
#expect(results.isEmpty)
}
@Test("thread summaries include unread count and senders")
func threadSummaries() throws {
let store = try makeStore()
try seedData(store)
let reconstructor = ThreadReconstructor(store: store)
let messages = try store.messages(mailboxId: "mb1")
try reconstructor.processMessages(messages)
let summaries = try store.threadSummaries(accountId: "acc1")
#expect(summaries.count == 2)
// First thread (most recent) should be "Quarterly planning meeting"
#expect(summaries[0].subject == "Quarterly planning meeting")
#expect(summaries[0].unreadCount == 1)
}
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter SearchTests 2>&1 | head -10
# Expected: FAIL — search method not defined
- Step 3: Write Queries implementation
Create Packages/MagnumOpusCore/Sources/MailStore/Queries.swift:
import GRDB
import Models
extension MailStore {
/// Full-text search across subject, sender, and body via FTS5
public func search(query: String) throws -> [MessageRecord] {
try dbWriter.read { db in
guard let pattern = FTS5Pattern(matchingAllPrefixesIn: query) else { return [] }
return try MessageRecord.fetchAll(db, sql: """
SELECT message.* FROM message
JOIN messageFts ON messageFts.rowid = message.rowid
WHERE messageFts MATCH ?
""", arguments: [pattern.rawPattern])
}
}
/// Thread summaries with unread count and latest sender info, ordered by lastDate DESC
public func threadSummaries(accountId: String) throws -> [ThreadSummary] {
try dbWriter.read { db in
try Self.threadSummariesFromDB(db, accountId: accountId)
}
}
/// Observe thread summaries reactively — UI updates automatically on DB change.
/// Uses `any DatabaseWriter` so it works with both DatabasePool (production) and DatabaseQueue (tests).
public func observeThreadSummaries(accountId: String) -> AsyncThrowingStream<[ThreadSummary], Error> {
let dbWriter = self.dbWriter
let observation = ValueObservation.tracking { db -> [ThreadSummary] in
try Self.threadSummariesFromDB(db, accountId: accountId)
}
return AsyncThrowingStream { continuation in
let cancellable = observation.start(in: dbWriter, onError: { error in
continuation.finish(throwing: error)
}, onChange: { summaries in
continuation.yield(summaries)
})
continuation.onTermination = { _ in cancellable.cancel() }
}
}
/// Observe messages in a thread reactively
public func observeMessages(threadId: String) -> AsyncThrowingStream<[MessageSummary], Error> {
let dbWriter = self.dbWriter
let observation = ValueObservation.tracking { db -> [MessageRecord] in
try MessageRecord.fetchAll(db, sql: """
SELECT m.* FROM message m
JOIN threadMessage tm ON tm.messageId = m.id
WHERE tm.threadId = ?
ORDER BY m.date ASC
""", arguments: [threadId])
}
return AsyncThrowingStream { continuation in
let cancellable = observation.start(in: dbWriter, onError: { error in
continuation.finish(throwing: error)
}, onChange: { records in
continuation.yield(records.map(Self.toMessageSummary))
})
continuation.onTermination = { _ in cancellable.cancel() }
}
}
// MARK: - Internal helpers
static func threadSummariesFromDB(_ db: Database, accountId: String) throws -> [ThreadSummary] {
let sql = """
SELECT
t.id, t.accountId, t.subject, t.lastDate, t.messageCount,
(SELECT COUNT(*) FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.isRead = 0) as unreadCount,
(SELECT GROUP_CONCAT(DISTINCT m.fromName, ', ') FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id AND m.fromName IS NOT NULL) as senders,
(SELECT m.snippet FROM threadMessage tm JOIN message m ON m.id = tm.messageId WHERE tm.threadId = t.id ORDER BY m.date DESC LIMIT 1) as snippet
FROM thread t
WHERE t.accountId = ?
ORDER BY t.lastDate DESC
"""
let rows = try Row.fetchAll(db, sql: sql, arguments: [accountId])
return rows.map { row in
ThreadSummary(
id: row["id"], accountId: row["accountId"], subject: row["subject"],
lastDate: Self.parseDate(row["lastDate"] as String? ?? "") ?? Date.distantPast,
messageCount: row["messageCount"], unreadCount: row["unreadCount"],
senders: row["senders"] ?? "", snippet: row["snippet"]
)
}
}
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatter: ISO8601DateFormatter = {
ISO8601DateFormatter()
}()
static func parseDate(_ iso: String) -> Date? {
isoFormatterWithFractional.date(from: iso) ?? isoFormatter.date(from: iso)
}
static func toMessageSummary(_ record: MessageRecord) -> MessageSummary {
MessageSummary(
id: record.id,
messageId: record.messageId,
threadId: nil,
from: record.fromAddress.map { EmailAddress.parse($0) },
to: parseAddressList(record.toAddresses),
cc: parseAddressList(record.ccAddresses),
subject: record.subject,
date: parseDate(record.date) ?? Date.distantPast,
snippet: record.snippet,
bodyText: record.bodyText,
bodyHtml: record.bodyHtml,
isRead: record.isRead,
isFlagged: record.isFlagged,
hasAttachments: false
)
}
static func parseAddressList(_ json: String?) -> [EmailAddress] {
guard let json, let data = json.data(using: .utf8) else { return [] }
struct Addr: Codable { var name: String?; var address: String }
guard let addrs = try? JSONDecoder().decode([Addr].self, from: data) else { return [] }
return addrs.map { EmailAddress(name: $0.name, address: $0.address) }
}
}
Note: The FTS5 search uses raw SQL to join message with the synchronized messageFts virtual table. GRDB's FTS5Pattern(matchingAllPrefixesIn:) sanitizes user input for safe FTS5 queries. The implementing agent should verify the exact GRDB FTS5 pattern API and adjust if needed.
- Step 4: Run tests to verify they pass
cd Packages/MagnumOpusCore && swift test --filter SearchTests
# Expected: all tests pass
If GRDB FTS5 API differs from plan, adjust search() to use the raw SQL fallback shown above.
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/MailStore/Queries.swift Packages/MagnumOpusCore/Tests/MailStoreTests/SearchTests.swift
git commit -m "add FTS5 search, thread summaries, reactive observation streams"
Chunk 3: Sync Pipeline (Mock IMAP + SyncCoordinator)
Build the full sync pipeline with a mock IMAPClient first. This validates the SyncCoordinator → MailStore flow before tackling real NIO networking.
Task 7: IMAPClient Protocol, Types, and Mock
Files:
-
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift -
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPTypes.swift -
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/FetchedEnvelope.swift -
Create:
Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift -
Step 1: Create IMAP response types
Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPTypes.swift:
import Models
public struct IMAPMailboxStatus: Sendable {
public var name: String
public var uidValidity: Int
public var uidNext: Int
public var messageCount: Int
public var recentCount: Int
public init(name: String, uidValidity: Int, uidNext: Int, messageCount: Int, recentCount: Int) {
self.name = name
self.uidValidity = uidValidity
self.uidNext = uidNext
self.messageCount = messageCount
self.recentCount = recentCount
}
}
public struct IMAPMailboxInfo: Sendable {
public var name: String
public var attributes: Set<String>
public init(name: String, attributes: Set<String> = []) {
self.name = name
self.attributes = attributes
}
}
public struct UIDFlagsPair: Sendable {
public var uid: Int
public var isRead: Bool
public var isFlagged: Bool
public init(uid: Int, isRead: Bool, isFlagged: Bool) {
self.uid = uid
self.isRead = isRead
self.isFlagged = isFlagged
}
}
Create Packages/MagnumOpusCore/Sources/IMAPClient/FetchedEnvelope.swift:
import Models
/// Parsed IMAP envelope — the data we extract from a FETCH response.
public struct FetchedEnvelope: Sendable {
public var uid: Int
public var messageId: String?
public var inReplyTo: String?
public var references: String?
public var subject: String?
public var from: EmailAddress?
public var to: [EmailAddress]
public var cc: [EmailAddress]
public var date: String
public var snippet: String?
public var bodyText: String?
public var bodyHtml: String?
public var isRead: Bool
public var isFlagged: Bool
public var size: Int
public init(
uid: Int, messageId: String?, inReplyTo: String?, references: String?,
subject: String?, from: EmailAddress?, to: [EmailAddress], cc: [EmailAddress],
date: String, snippet: String?, bodyText: String?, bodyHtml: String?,
isRead: Bool, isFlagged: Bool, size: Int
) {
self.uid = uid
self.messageId = messageId
self.inReplyTo = inReplyTo
self.references = references
self.subject = subject
self.from = from
self.to = to
self.cc = cc
self.date = date
self.snippet = snippet
self.bodyText = bodyText
self.bodyHtml = bodyHtml
self.isRead = isRead
self.isFlagged = isFlagged
self.size = size
}
}
- Step 2: Create IMAPClientProtocol
Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClientProtocol.swift:
public protocol IMAPClientProtocol: Sendable {
func connect() async throws
func disconnect() async throws
func listMailboxes() async throws -> [IMAPMailboxInfo]
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope]
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair]
func fetchBody(uid: Int) async throws -> (text: String?, html: String?)
}
- Step 3: Create MockIMAPClient for testing
Create Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift:
import IMAPClient
import Models
final class MockIMAPClient: IMAPClientProtocol, @unchecked Sendable {
var mailboxes: [IMAPMailboxInfo] = []
var mailboxStatuses: [String: IMAPMailboxStatus] = [:]
var envelopes: [FetchedEnvelope] = []
var flagUpdates: [UIDFlagsPair] = []
var bodies: [Int: (text: String?, html: String?)] = [:]
var connectCalled = false
var disconnectCalled = false
var selectedMailbox: String?
func connect() async throws {
connectCalled = true
}
func disconnect() async throws {
disconnectCalled = true
}
func listMailboxes() async throws -> [IMAPMailboxInfo] {
mailboxes
}
func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
selectedMailbox = name
guard let status = mailboxStatuses[name] else {
throw MockIMAPError.mailboxNotFound(name)
}
return status
}
func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
envelopes.filter { $0.uid > uid }
}
func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
flagUpdates.filter { uids.contains($0.uid) }
}
func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
bodies[uid] ?? (nil, nil)
}
}
enum MockIMAPError: Error {
case mailboxNotFound(String)
}
- Step 4: Remove placeholder files, verify build
rm -f Packages/MagnumOpusCore/Sources/IMAPClient/Placeholder.swift
cd Packages/MagnumOpusCore && swift build --target IMAPClient
# Expected: builds successfully
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/IMAPClient/ Packages/MagnumOpusCore/Tests/SyncEngineTests/MockIMAPClient.swift
git commit -m "add imap client protocol, types, mock for testing"
Task 8: SyncCoordinator
Orchestrates the full sync flow: connect → list mailboxes → select each → fetch new messages → store in MailStore → reconstruct threads → disconnect. Uses IMAPClientProtocol so it's testable with the mock.
Files:
-
Create:
Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift -
Create:
Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift -
Step 1: Write failing tests
Create Packages/MagnumOpusCore/Tests/SyncEngineTests/SyncCoordinatorTests.swift:
import Testing
import GRDB
@testable import SyncEngine
@testable import IMAPClient
@testable import MailStore
@testable import Models
@Suite("SyncCoordinator")
@MainActor
struct SyncCoordinatorTests {
func makeStore() throws -> MailStore {
try MailStore(dbWriter: DatabaseSetup.openInMemoryDatabase())
}
func makeMock() -> MockIMAPClient {
let mock = MockIMAPClient()
mock.mailboxes = [
IMAPMailboxInfo(name: "INBOX"),
IMAPMailboxInfo(name: "Sent"),
]
mock.mailboxStatuses = [
"INBOX": IMAPMailboxStatus(name: "INBOX", uidValidity: 1, uidNext: 3, messageCount: 2, recentCount: 0),
"Sent": IMAPMailboxStatus(name: "Sent", uidValidity: 1, uidNext: 1, messageCount: 0, recentCount: 0),
]
mock.envelopes = [
FetchedEnvelope(
uid: 1, messageId: "msg001@example.com", inReplyTo: nil, references: nil,
subject: "Hello", from: EmailAddress(name: "Alice", address: "alice@example.com"),
to: [EmailAddress(address: "me@example.com")], cc: [],
date: "2024-03-08T10:00:00Z", snippet: "Hi there",
bodyText: nil, bodyHtml: nil, isRead: false, isFlagged: false, size: 1024
),
FetchedEnvelope(
uid: 2, messageId: "msg002@example.com", inReplyTo: "msg001@example.com",
references: "msg001@example.com",
subject: "Re: Hello", from: EmailAddress(name: "Bob", address: "bob@example.com"),
to: [EmailAddress(address: "alice@example.com")], cc: [],
date: "2024-03-08T11:00:00Z", snippet: "Hey!",
bodyText: nil, bodyHtml: nil, isRead: true, isFlagged: false, size: 512
),
]
return mock
}
@Test("full sync creates account, mailboxes, messages, and threads")
func fullSync() async throws {
let store = try makeStore()
let mock = makeMock()
let coordinator = SyncCoordinator(
accountConfig: AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
),
imapClient: mock,
store: store
)
try await coordinator.syncNow()
// Account created
let accounts = try store.accounts()
#expect(accounts.count == 1)
// Mailboxes created
let mailboxes = try store.mailboxes(accountId: "acc1")
#expect(mailboxes.count == 2)
// Messages stored
let inboxMb = mailboxes.first { $0.name == "INBOX" }!
let messages = try store.messages(mailboxId: inboxMb.id)
#expect(messages.count == 2)
// Threads created (msg002 replies to msg001, so 1 thread)
let threads = try store.threads(accountId: "acc1")
#expect(threads.count == 1)
#expect(threads[0].messageCount == 2)
// uidNext updated
let updatedMb = try store.mailbox(id: inboxMb.id)
#expect(updatedMb?.uidNext == 3)
// IMAP client was connected and disconnected
#expect(mock.connectCalled)
#expect(mock.disconnectCalled)
}
@Test("delta sync only fetches new messages")
func deltaSync() async throws {
let store = try makeStore()
let mock = makeMock()
let config = AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
)
let coordinator = SyncCoordinator(accountConfig: config, imapClient: mock, store: store)
// First sync
try await coordinator.syncNow()
// Add a new message for delta sync
mock.envelopes.append(FetchedEnvelope(
uid: 3, messageId: "msg003@example.com", inReplyTo: nil, references: nil,
subject: "New message", from: EmailAddress(name: "Charlie", address: "charlie@example.com"),
to: [EmailAddress(address: "me@example.com")], cc: [],
date: "2024-03-09T10:00:00Z", snippet: "Something new",
bodyText: nil, bodyHtml: nil, isRead: false, isFlagged: false, size: 256
))
mock.mailboxStatuses["INBOX"] = IMAPMailboxStatus(
name: "INBOX", uidValidity: 1, uidNext: 4, messageCount: 3, recentCount: 1
)
// Second sync — should only fetch uid > 2
try await coordinator.syncNow()
let inboxMb = try store.mailboxes(accountId: "acc1").first { $0.name == "INBOX" }!
let messages = try store.messages(mailboxId: inboxMb.id)
#expect(messages.count == 3)
}
@Test("sync state transitions through syncing to idle")
func syncStateTransitions() async throws {
let store = try makeStore()
let mock = makeMock()
let coordinator = SyncCoordinator(
accountConfig: AccountConfig(
id: "acc1", name: "Personal", email: "me@example.com",
imapHost: "imap.example.com", imapPort: 993
),
imapClient: mock,
store: store
)
#expect(coordinator.syncState == .idle)
try await coordinator.syncNow()
#expect(coordinator.syncState == .idle)
}
}
- Step 2: Run tests to verify they fail
cd Packages/MagnumOpusCore && swift test --filter SyncCoordinatorTests 2>&1 | head -10
# Expected: FAIL — SyncCoordinator not defined
- Step 3: Write SyncCoordinator implementation
Create Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift:
import Foundation
import Models
import IMAPClient
import MailStore
@Observable
@MainActor
public final class SyncCoordinator {
private let accountConfig: AccountConfig
private let imapClient: any IMAPClientProtocol
private let store: MailStore
private var syncTask: Task<Void, Never>?
public private(set) var syncState: SyncState = .idle
private var eventHandlers: [(SyncEvent) -> Void] = []
public init(accountConfig: AccountConfig, imapClient: any IMAPClientProtocol, store: MailStore) {
self.accountConfig = accountConfig
self.imapClient = imapClient
self.store = store
}
public func onEvent(_ handler: @escaping (SyncEvent) -> Void) {
eventHandlers.append(handler)
}
private func emit(_ event: SyncEvent) {
for handler in eventHandlers {
handler(event)
}
}
// MARK: - Sync
public func syncNow() async throws {
syncState = .syncing(mailbox: nil)
emit(.syncStarted)
do {
try await performSync()
syncState = .idle
emit(.syncCompleted)
} catch {
syncState = .error(error.localizedDescription)
emit(.syncFailed(error.localizedDescription))
throw error
}
}
private func performSync() async throws {
// Ensure account exists in DB
let existingAccounts = try store.accounts()
if !existingAccounts.contains(where: { $0.id == accountConfig.id }) {
try store.insertAccount(AccountRecord(
id: accountConfig.id,
name: accountConfig.name,
email: accountConfig.email,
imapHost: accountConfig.imapHost,
imapPort: accountConfig.imapPort
))
}
try await imapClient.connect()
do {
try await syncAllMailboxes()
} catch {
try? await imapClient.disconnect()
throw error
}
try? await imapClient.disconnect()
}
private func syncAllMailboxes() async throws {
// List and sync each mailbox
let remoteMailboxes = try await imapClient.listMailboxes()
for remoteMailbox in remoteMailboxes {
syncState = .syncing(mailbox: remoteMailbox.name)
try await syncMailbox(remoteMailbox)
}
}
private func syncMailbox(_ remoteMailbox: IMAPMailboxInfo) async throws {
let status = try await imapClient.selectMailbox(remoteMailbox.name)
// Find or create local mailbox
let localMailboxes = try store.mailboxes(accountId: accountConfig.id)
let localMailbox = localMailboxes.first { $0.name == remoteMailbox.name }
let mailboxId: String
let lastUid: Int
if let local = localMailbox {
mailboxId = local.id
if local.uidValidity != status.uidValidity {
// UIDVALIDITY changed — must re-sync entire mailbox
// For v0.2, just update and re-fetch all
lastUid = 0
} else {
lastUid = local.uidNext - 1
}
} else {
mailboxId = UUID().uuidString
try store.upsertMailbox(MailboxRecord(
id: mailboxId,
accountId: accountConfig.id,
name: remoteMailbox.name,
uidValidity: status.uidValidity,
uidNext: status.uidNext
))
lastUid = 0
}
// Fetch new envelopes
let envelopes = try await imapClient.fetchEnvelopes(uidsGreaterThan: lastUid)
if !envelopes.isEmpty {
// Convert to MessageRecords and insert
let records = envelopes.map { envelope -> MessageRecord in
envelopeToRecord(envelope, accountId: accountConfig.id, mailboxId: mailboxId)
}
try store.insertMessages(records)
// Reconstruct threads for new messages
let reconstructor = ThreadReconstructor(store: store)
try reconstructor.processMessages(records)
emit(.newMessages(count: envelopes.count, mailbox: remoteMailbox.name))
}
// Update mailbox sync state
try store.updateMailboxSync(
id: mailboxId,
uidValidity: status.uidValidity,
uidNext: status.uidNext
)
}
private func envelopeToRecord(
_ envelope: FetchedEnvelope, accountId: String, mailboxId: String
) -> MessageRecord {
let toJson = encodeAddresses(envelope.to)
let ccJson = encodeAddresses(envelope.cc)
return MessageRecord(
id: UUID().uuidString,
accountId: accountId,
mailboxId: mailboxId,
uid: envelope.uid,
messageId: envelope.messageId,
inReplyTo: envelope.inReplyTo,
refs: envelope.references,
subject: envelope.subject,
fromAddress: envelope.from?.address,
fromName: envelope.from?.name,
toAddresses: toJson,
ccAddresses: ccJson,
date: envelope.date,
snippet: envelope.snippet,
bodyText: envelope.bodyText,
bodyHtml: envelope.bodyHtml,
isRead: envelope.isRead,
isFlagged: envelope.isFlagged,
size: envelope.size
)
}
private func encodeAddresses(_ addresses: [EmailAddress]) -> String? {
guard !addresses.isEmpty else { return nil }
struct Addr: Codable { var name: String?; var address: String }
let addrs = addresses.map { Addr(name: $0.name, address: $0.address) }
guard let data = try? JSONEncoder().encode(addrs) else { return nil }
return String(data: data, encoding: .utf8)
}
// MARK: - Periodic Sync
public func startPeriodicSync(interval: Duration = .seconds(300)) {
stopSync()
syncTask = Task { [weak self] in
while !Task.isCancelled {
try? await self?.syncNow()
do {
try await Task.sleep(for: interval)
} catch {
// CancellationError — exit the loop
break
}
}
}
}
public func stopSync() {
syncTask?.cancel()
syncTask = nil
}
}
- Step 4: Remove placeholder, run tests
rm -f Packages/MagnumOpusCore/Sources/SyncEngine/Placeholder.swift
cd Packages/MagnumOpusCore && swift test --filter SyncCoordinatorTests
# Expected: all tests pass
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/SyncEngine/ Packages/MagnumOpusCore/Tests/SyncEngineTests/
git commit -m "add sync coordinator: imap → mailstore pipeline with delta sync"
Chunk 4: Real IMAP Client (NIO)
Replace the mock with a real swift-nio-imap based actor. This is the most complex module — built in two tasks: connection layer, then high-level operations.
Task 9: NIO Connection Layer
The IMAP client actor manages a NIO channel with TLS. Commands are sent sequentially (one at a time) with tag-based response matching.
Files:
-
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift -
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPResponseHandler.swift -
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift -
Step 1: Create IMAPResponseHandler (NIO ChannelInboundHandler)
This handler collects IMAP responses and delivers them to a waiting continuation when a tagged response completes a command.
Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPResponseHandler.swift:
import NIO
import NIOIMAPCore
import NIOIMAP
final class IMAPResponseHandler: ChannelInboundHandler {
typealias InboundIn = Response
private var buffer: [Response] = []
private var expectedTag: String?
private var continuation: CheckedContinuation<[Response], Error>?
private var greetingContinuation: CheckedContinuation<Void, Error>?
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let response = unwrapInboundIn(data)
buffer.append(response)
switch response {
case .untagged(let payload):
// Server greeting arrives as untagged OK
if case .conditionalState(let state) = payload, greetingContinuation != nil {
if case .ok = state.code {
greetingContinuation?.resume()
greetingContinuation = nil
}
}
case .tagged(let tagged):
if tagged.tag == expectedTag {
let collected = buffer
buffer = []
expectedTag = nil
continuation?.resume(returning: collected)
continuation = nil
}
case .fatal(let text):
let error = IMAPError.serverError(String(describing: text))
continuation?.resume(throwing: error)
continuation = nil
greetingContinuation?.resume(throwing: error)
greetingContinuation = nil
default:
break
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
continuation?.resume(throwing: error)
continuation = nil
greetingContinuation?.resume(throwing: error)
greetingContinuation = nil
context.close(promise: nil)
}
func waitForGreeting() async throws {
try await withCheckedThrowingContinuation { cont in
greetingContinuation = cont
}
}
func sendCommand(tag: String, continuation cont: CheckedContinuation<[Response], Error>) {
expectedTag = tag
continuation = cont
buffer = []
}
}
public enum IMAPError: Error, Sendable {
case notConnected
case serverError(String)
case authenticationFailed
case unexpectedResponse(String)
}
Note: The exact Response enum variants depend on the swift-nio-imap version. The implementing agent must check NIOIMAPCore.Response and adapt the switch cases accordingly. The core pattern (buffer responses, match by tag, resume continuation) is correct regardless of API details.
- Step 2: Create IMAPConnection (NIO bootstrap + TLS)
Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPConnection.swift:
import NIO
import NIOIMAPCore
import NIOIMAP
import NIOSSL
/// Actor because it holds mutable `channel` state — `Sendable class` would not compile in Swift 6.
actor IMAPConnection {
private let host: String
private let port: Int
private let group: EventLoopGroup
private var channel: Channel?
private let responseHandler: IMAPResponseHandler
init(host: String, port: Int) {
self.host = host
self.port = port
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
self.responseHandler = IMAPResponseHandler()
}
func connect() async throws {
let sslContext = try NIOSSLContext(configuration: TLSConfiguration.makeClientConfiguration())
let handler = responseHandler
let hostname = host
let bootstrap = ClientBootstrap(group: group)
.channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1)
.channelInitializer { channel in
let sslHandler = try! NIOSSLClientHandler(context: sslContext, serverHostname: hostname)
return channel.pipeline.addHandlers([
sslHandler,
IMAPClientHandler(),
handler,
])
}
channel = try await bootstrap.connect(host: host, port: port).get()
try await handler.waitForGreeting()
}
func sendCommand(_ tag: String, command: CommandStreamPart) async throws -> [Response] {
guard let channel else { throw IMAPError.notConnected }
return try await withCheckedThrowingContinuation { continuation in
responseHandler.sendCommand(tag: tag, continuation: continuation)
channel.writeAndFlush(command, promise: nil)
}
}
func disconnect() async throws {
try await channel?.close()
channel = nil
}
func shutdown() async throws {
try await group.shutdownGracefully()
}
}
Note: IMAPClientHandler is the NIO channel handler from the NIOIMAP module that encodes/decodes IMAP wire protocol. The try! in channelInitializer is acceptable here because TLS context creation failure is a programmer error (bad config), not a runtime condition. The implementing agent should verify the exact handler name and adjust if needed.
- Step 3: Create IMAPCommandRunner
This thin layer manages tag generation and command execution.
Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPCommandRunner.swift:
import NIOIMAPCore
/// Not Sendable — owned exclusively by the IMAPClient actor.
struct IMAPCommandRunner {
private let connection: IMAPConnection
private var tagCounter: Int = 0
init(connection: IMAPConnection) {
self.connection = connection
}
mutating func nextTag() -> String {
tagCounter += 1
return "A\(tagCounter)"
}
mutating func run(_ command: Command) async throws -> [Response] {
let tag = nextTag()
let tagged = TaggedCommand(tag: tag, command: command)
return try await connection.sendCommand(tag, command: .tagged(tagged))
}
}
- Step 4: Verify build
cd Packages/MagnumOpusCore && swift build --target IMAPClient
# Expected: builds (may need adjustments for exact NIO-IMAP API)
If the build fails due to API differences in swift-nio-imap, adapt the types to match the actual API. The key patterns (ChannelInboundHandler, continuation-based response collection, TLS bootstrap) are correct.
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/IMAPClient/
git commit -m "add nio connection layer: tls bootstrap, response handler, command runner"
Task 10: IMAPClient Actor (High-Level Operations)
Wire the connection layer into the public IMAPClient actor that conforms to IMAPClientProtocol.
Files:
-
Create:
Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift -
Create:
Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift -
Step 1: Write IMAPClient actor
Create Packages/MagnumOpusCore/Sources/IMAPClient/IMAPClient.swift:
import Models
import NIOIMAPCore
public actor IMAPClient: IMAPClientProtocol {
private let host: String
private let port: Int
private let credentials: Credentials
private var connection: IMAPConnection?
private var runner: IMAPCommandRunner?
public init(host: String, port: Int, credentials: Credentials) {
self.host = host
self.port = port
self.credentials = credentials
}
public func connect() async throws {
let conn = IMAPConnection(host: host, port: port)
try await conn.connect()
connection = conn
var newRunner = IMAPCommandRunner(connection: conn)
// Authenticate — must reassign runner after mutating call to preserve tag counter
let responses = try await newRunner.run(
.login(username: credentials.username, password: credentials.password)
)
guard responses.contains(where: isOKTagged) else {
throw IMAPError.authenticationFailed
}
runner = newRunner
}
public func disconnect() async throws {
if var r = runner {
_ = try? await r.run(.logout)
runner = r
}
try await connection?.disconnect()
try await connection?.shutdown()
connection = nil
runner = nil
}
public func listMailboxes() async throws -> [IMAPMailboxInfo] {
guard var runner else { throw IMAPError.notConnected }
let responses = try await runner.run(.list(reference: .init(""), mailboxPattern: .init("*")))
self.runner = runner
return parseListResponses(responses)
}
public func selectMailbox(_ name: String) async throws -> IMAPMailboxStatus {
guard var runner else { throw IMAPError.notConnected }
let responses = try await runner.run(.select(.init(name)))
self.runner = runner
return parseSelectResponses(responses, name: name)
}
public func fetchEnvelopes(uidsGreaterThan uid: Int) async throws -> [FetchedEnvelope] {
guard var runner else { throw IMAPError.notConnected }
let range: MessageIdentifierRange<UID> = MessageIdentifierRange(
(.init(integerLiteral: UInt32(uid + 1))...(.max))
)
let responses = try await runner.run(.uidFetch(
range,
[.envelope, .flags, .uid, .rfc822Size, .bodySection(peek: true, .init(kind: .text), nil)]
))
self.runner = runner
return parseFetchResponses(responses)
}
public func fetchFlags(uids: ClosedRange<Int>) async throws -> [UIDFlagsPair] {
guard var runner else { throw IMAPError.notConnected }
let range: MessageIdentifierRange<UID> = MessageIdentifierRange(
(.init(integerLiteral: UInt32(uids.lowerBound))...(.init(integerLiteral: UInt32(uids.upperBound))))
)
let responses = try await runner.run(.uidFetch(range, [.uid, .flags]))
self.runner = runner
return parseFlagResponses(responses)
}
public func fetchBody(uid: Int) async throws -> (text: String?, html: String?) {
guard var runner else { throw IMAPError.notConnected }
let range: MessageIdentifierRange<UID> = .single(.init(integerLiteral: UInt32(uid)))
let responses = try await runner.run(.uidFetch(
range,
[.bodySection(peek: true, .init(kind: .text), nil)]
))
self.runner = runner
return parseBodyResponse(responses)
}
// MARK: - Response parsing helpers
/// These methods extract data from NIO-IMAP Response objects.
/// The exact Response enum structure depends on the swift-nio-imap version.
/// The implementing agent MUST verify these against the actual API.
private func isOKTagged(_ response: Response) -> Bool {
if case .tagged(let tagged) = response {
return tagged.state == .ok
}
return false
}
private func parseListResponses(_ responses: [Response]) -> [IMAPMailboxInfo] {
var mailboxes: [IMAPMailboxInfo] = []
for response in responses {
if case .untagged(let payload) = response,
case .mailboxData(let data) = payload,
case .list(let listInfo) = data {
let attrs = Set(listInfo.attributes.map { String(describing: $0) })
mailboxes.append(IMAPMailboxInfo(name: String(listInfo.path.name), attributes: attrs))
}
}
return mailboxes
}
private func parseSelectResponses(_ responses: [Response], name: String) -> IMAPMailboxStatus {
var uidValidity = 0
var uidNext = 0
var messageCount = 0
var recentCount = 0
for response in responses {
if case .untagged(let payload) = response {
switch payload {
case .mailboxData(let data):
switch data {
case .exists(let count): messageCount = count
case .recent(let count): recentCount = count
default: break
}
case .conditionalState(let state):
if case .ok(let responseText) = state,
let code = responseText.code {
switch code {
case .uidValidity(let val): uidValidity = Int(val)
case .uidNext(let val): uidNext = Int(val)
default: break
}
}
default: break
}
}
}
return IMAPMailboxStatus(
name: name, uidValidity: uidValidity, uidNext: uidNext,
messageCount: messageCount, recentCount: recentCount
)
}
private func parseFetchResponses(_ responses: [Response]) -> [FetchedEnvelope] {
var envelopes: [FetchedEnvelope] = []
for response in responses {
if case .fetch(let fetchResponse) = response {
if let envelope = extractEnvelope(from: fetchResponse) {
envelopes.append(envelope)
}
}
}
return envelopes
}
private func extractEnvelope(from fetchResponse: FetchResponse) -> FetchedEnvelope? {
var uid = 0
var envelope: Envelope?
var flags: [Flag] = []
var size = 0
var bodyText: String?
for attribute in fetchResponse.messageAttributes {
switch attribute {
case .uid(let u): uid = Int(u)
case .envelope(let env): envelope = env
case .flags(let f): flags = f
case .rfc822Size(let s): size = s
case .body(_, let data):
if let data, let text = String(bytes: data, encoding: .utf8) {
bodyText = text
}
default: break
}
}
guard let env = envelope else { return nil }
let isRead = flags.contains(.seen)
let isFlagged = flags.contains(.flagged)
return FetchedEnvelope(
uid: uid,
messageId: env.messageID.map(String.init),
inReplyTo: env.inReplyTo.map(String.init),
references: nil, // References not in envelope — need BODY[HEADER.FIELDS]
subject: env.subject.map(String.init),
from: env.from.first.map { EmailAddress(name: $0.displayName, address: $0.emailAddress) },
to: (env.to ?? []).map { EmailAddress(name: $0.displayName, address: $0.emailAddress) },
cc: (env.cc ?? []).map { EmailAddress(name: $0.displayName, address: $0.emailAddress) },
date: env.date.map(String.init) ?? "",
snippet: bodyText.map { String($0.prefix(200)) },
bodyText: bodyText,
bodyHtml: nil,
isRead: isRead,
isFlagged: isFlagged,
size: size
)
}
private func parseFlagResponses(_ responses: [Response]) -> [UIDFlagsPair] {
var pairs: [UIDFlagsPair] = []
for response in responses {
if case .fetch(let fetchResponse) = response {
var uid = 0
var isRead = false
var isFlagged = false
for attr in fetchResponse.messageAttributes {
switch attr {
case .uid(let u): uid = Int(u)
case .flags(let f):
isRead = f.contains(.seen)
isFlagged = f.contains(.flagged)
default: break
}
}
if uid > 0 {
pairs.append(UIDFlagsPair(uid: uid, isRead: isRead, isFlagged: isFlagged))
}
}
}
return pairs
}
private func parseBodyResponse(_ responses: [Response]) -> (text: String?, html: String?) {
for response in responses {
if case .fetch(let fetchResponse) = response {
for attr in fetchResponse.messageAttributes {
if case .body(_, let data) = attr,
let data,
let text = String(bytes: data, encoding: .utf8) {
return (text, nil)
}
}
}
}
return (nil, nil)
}
}
CRITICAL NOTE: The response parsing code above is written against an assumed swift-nio-imap API. The actual enum cases, property names, and types WILL differ. The implementing agent must:
- Run
swift build --target IMAPClientand fix all compilation errors - Check the actual
Response,FetchResponse,Envelope,Flag,MessageAttributetypes inNIOIMAPCore - Adapt the
switchcases and property access accordingly - The overall patterns (iterate responses, match on cases, extract data) are correct
- Step 2: Write parsing unit tests
These test the response parsing logic with constructed response objects, not live IMAP.
Create Packages/MagnumOpusCore/Tests/IMAPClientTests/IMAPResponseParsingTests.swift:
import Testing
@testable import IMAPClient
import NIOIMAPCore
@Suite("IMAP Response Parsing")
struct IMAPResponseParsingTests {
@Test("IMAPClient can be instantiated")
func instantiation() {
let client = IMAPClient(
host: "imap.example.com",
port: 993,
credentials: .init(username: "user", password: "pass")
)
// Verify it exists and conforms to protocol
let _: any IMAPClientProtocol = client
}
// The exact NIOIMAPCore constructors vary by version.
// The implementing agent MUST add the following tests, adapting constructors to the actual API:
@Test("isOKTagged correctly identifies OK tagged responses")
func isOKTagged() {
// Construct a tagged response with .ok status
// Verify IMAPClient.isOKTagged returns true
// Construct a tagged response with .no status
// Verify IMAPClient.isOKTagged returns false
// The implementing agent must expose isOKTagged or test via connect behavior
}
@Test("tag counter increments across commands")
func tagCounterIncrements() async {
// Create an IMAPCommandRunner with a mock connection
// Call nextTag() three times
// Verify tags are "A1", "A2", "A3"
var runner = IMAPCommandRunner(connection: IMAPConnection(host: "localhost", port: 993))
#expect(runner.nextTag() == "A1")
#expect(runner.nextTag() == "A2")
#expect(runner.nextTag() == "A3")
}
@Test("IMAPError cases are Sendable")
func errorSendability() {
let error: any Error & Sendable = IMAPError.notConnected
#expect(error is IMAPError)
}
}
- Step 3: Build and fix compilation errors
cd Packages/MagnumOpusCore && swift build --target IMAPClient 2>&1
# Fix any compilation errors by adapting to actual swift-nio-imap API
# This is expected — the response parsing code must match the real types
- Step 4: Run all tests
cd Packages/MagnumOpusCore && swift test
# Expected: all existing tests still pass, new tests pass
- Step 5: Commit
git add Packages/MagnumOpusCore/Sources/IMAPClient/ Packages/MagnumOpusCore/Tests/IMAPClientTests/
git commit -m "add real imap client actor: nio connection, command pipeline, envelope parsing"
Chunk 5: SwiftUI Apps
Task 11: Xcode Project Setup (macOS + iOS)
Use XcodeGen to create a multi-platform project that imports MagnumOpusCore as a local package.
Files:
-
Create:
Apps/project.yml -
Create:
Apps/MagnumOpus/MagnumOpusApp.swift -
Create:
Apps/MagnumOpus/ContentView.swift -
Step 1: Create XcodeGen project.yml
Create Apps/project.yml:
name: MagnumOpus
options:
bundleIdPrefix: de.felixfoertsch
deploymentTarget:
macOS: "15.0"
iOS: "18.0"
xcodeVersion: "16.0"
indentWidth: 4
tabWidth: 4
usesTabs: true
packages:
MagnumOpusCore:
path: ../Packages/MagnumOpusCore
targets:
MagnumOpus-macOS:
type: application
platform: macOS
sources:
- path: MagnumOpus
settings:
base:
PRODUCT_NAME: MagnumOpus
PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.MagnumOpus
DEVELOPMENT_TEAM: NG5W75WE8U
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
MACOSX_DEPLOYMENT_TARGET: "15.0"
dependencies:
- package: MagnumOpusCore
product: Models
- package: MagnumOpusCore
product: MailStore
- package: MagnumOpusCore
product: IMAPClient
- package: MagnumOpusCore
product: SyncEngine
MagnumOpus-iOS:
type: application
platform: iOS
sources:
- path: MagnumOpus
settings:
base:
PRODUCT_NAME: MagnumOpus
PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.MagnumOpus
DEVELOPMENT_TEAM: NG5W75WE8U
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
TARGETED_DEVICE_FAMILY: "1,2"
dependencies:
- package: MagnumOpusCore
product: Models
- package: MagnumOpusCore
product: MailStore
- package: MagnumOpusCore
product: IMAPClient
- package: MagnumOpusCore
product: SyncEngine
MagnumOpusTests:
type: bundle.unit-test
platform: macOS
sources:
- path: MagnumOpusTests
dependencies:
- target: MagnumOpus-macOS
settings:
base:
SWIFT_VERSION: "6.0"
- Step 2: Create app entry point
Create Apps/MagnumOpus/MagnumOpusApp.swift:
import SwiftUI
@main
struct MagnumOpusApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
.defaultSize(width: 1200, height: 800)
#endif
}
}
- Step 3: Create placeholder ContentView
Create Apps/MagnumOpus/ContentView.swift:
import SwiftUI
struct ContentView: View {
var body: some View {
NavigationSplitView {
Text("Sidebar")
} content: {
Text("Thread List")
} detail: {
Text("Detail")
}
}
}
- Step 4: Create test placeholder
mkdir -p Apps/MagnumOpusTests
Create Apps/MagnumOpusTests/AppTests.swift:
import Testing
@Suite("App")
struct AppTests {
@Test("placeholder")
func placeholder() {
#expect(true)
}
}
- Step 5: Generate Xcode project and verify build
cd Apps && xcodegen generate
# Expected: Generated project "MagnumOpus.xcodeproj"
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 6: Commit
git add Apps/
git commit -m "scaffold multi-platform xcode project with xcodegen"
Task 12: ViewModels
Two ViewModels: MailViewModel for the main mail interface, AccountSetupViewModel for first-launch setup.
Files:
-
Create:
Apps/MagnumOpus/ViewModels/MailViewModel.swift -
Create:
Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift -
Step 1: Create MailViewModel
Create Apps/MagnumOpus/ViewModels/MailViewModel.swift:
import SwiftUI
import GRDB
import Models
import MailStore
import SyncEngine
import IMAPClient
@Observable
@MainActor
final class MailViewModel {
private var store: MailStore?
private var coordinator: SyncCoordinator?
var threads: [ThreadSummary] = []
var selectedThread: ThreadSummary?
var messages: [MessageSummary] = []
var mailboxes: [MailboxInfo] = []
var selectedMailbox: MailboxInfo?
var syncState: SyncState = .idle
var errorMessage: String?
private var threadObservation: Task<Void, Never>?
private var messageObservation: Task<Void, Never>?
var hasAccount: Bool {
store != nil && coordinator != nil
}
func setup(config: AccountConfig, credentials: Credentials) throws {
let dbPath = Self.databasePath(for: config.id)
let dbPool = try DatabaseSetup.openDatabase(atPath: dbPath)
let mailStore = MailStore(dbWriter: dbPool)
let imapClient = IMAPClient(
host: config.imapHost,
port: config.imapPort,
credentials: credentials
)
store = mailStore
coordinator = SyncCoordinator(
accountConfig: config,
imapClient: imapClient,
store: mailStore
)
}
func loadMailboxes(accountId: String) async {
guard let store else { return }
do {
let records = try store.mailboxes(accountId: accountId)
mailboxes = records.map { record in
MailboxInfo(
id: record.id, accountId: record.accountId,
name: record.name, unreadCount: 0, totalCount: 0
)
}
if selectedMailbox == nil, let inbox = mailboxes.first(where: { $0.name.lowercased() == "inbox" }) {
selectedMailbox = inbox
}
} catch {
errorMessage = error.localizedDescription
}
}
func startObservingThreads(accountId: String) {
guard let store else { return }
threadObservation?.cancel()
threadObservation = Task {
do {
for try await summaries in store.observeThreadSummaries(accountId: accountId) {
self.threads = summaries
}
} catch {
if !Task.isCancelled {
self.errorMessage = error.localizedDescription
}
}
}
}
func selectThread(_ thread: ThreadSummary) {
selectedThread = thread
messageObservation?.cancel()
guard let store else { return }
messageObservation = Task {
do {
for try await msgs in store.observeMessages(threadId: thread.id) {
self.messages = msgs
}
} catch {
if !Task.isCancelled {
self.errorMessage = error.localizedDescription
}
}
}
}
func syncNow() async {
guard let coordinator else { return }
do {
try await coordinator.syncNow()
syncState = coordinator.syncState
} catch {
errorMessage = error.localizedDescription
syncState = .error(error.localizedDescription)
}
}
func startPeriodicSync() {
coordinator?.startPeriodicSync()
}
func stopSync() {
coordinator?.stopSync()
threadObservation?.cancel()
messageObservation?.cancel()
}
static func databasePath(for accountId: String) -> String {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("MagnumOpus", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("\(accountId).sqlite").path
}
}
Note: DatabaseSetup.openDatabase() returns a GRDB DatabasePool, hence the import GRDB at the top. If module visibility requires it, use MailStore.DatabaseSetup instead.
- Step 2: Create AccountSetupViewModel
Create Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift:
import SwiftUI
import Models
@Observable
@MainActor
final class AccountSetupViewModel {
var email: String = ""
var password: String = ""
var imapHost: String = ""
var imapPort: String = "993"
var accountName: String = ""
var isAutoDiscovering = false
var autoDiscoveryFailed = false
var isManualMode = false
var errorMessage: String?
var canSubmit: Bool {
!email.isEmpty && !password.isEmpty && !imapHost.isEmpty && !imapPort.isEmpty
}
func autoDiscover() async {
isAutoDiscovering = true
autoDiscoveryFailed = false
// Auto-discovery implementation in Task 18
// For now, fall back to manual entry
isAutoDiscovering = false
isManualMode = true
}
func buildConfig() -> (AccountConfig, Credentials)? {
guard let port = Int(imapPort), canSubmit else { return nil }
let id = email.replacingOccurrences(of: "@", with: "-at-")
.replacingOccurrences(of: ".", with: "-")
let config = AccountConfig(
id: id,
name: accountName.isEmpty ? email : accountName,
email: email,
imapHost: imapHost,
imapPort: port
)
let credentials = Credentials(username: email, password: password)
return (config, credentials)
}
}
- Step 3: Commit
git add Apps/MagnumOpus/ViewModels/
git commit -m "add mail, account setup viewmodels with grdb observation"
Task 13: Three-Column Layout Views
Files:
-
Create:
Apps/MagnumOpus/Views/SidebarView.swift -
Create:
Apps/MagnumOpus/Views/ThreadListView.swift -
Create:
Apps/MagnumOpus/Views/ThreadDetailView.swift -
Create:
Apps/MagnumOpus/Views/AccountSetupView.swift -
Modify:
Apps/MagnumOpus/ContentView.swift -
Step 1: Create SidebarView
Create Apps/MagnumOpus/Views/SidebarView.swift:
import SwiftUI
import Models
struct SidebarView: View {
@Bindable var viewModel: MailViewModel
var body: some View {
List(selection: Binding(
get: { viewModel.selectedMailbox?.id },
set: { newId in
viewModel.selectedMailbox = viewModel.mailboxes.first { $0.id == newId }
}
)) {
Section("Mailboxes") {
ForEach(viewModel.mailboxes) { mailbox in
Label(mailbox.name, systemImage: mailbox.systemImage)
.tag(mailbox.id)
.badge(mailbox.unreadCount)
}
}
}
.navigationTitle("Magnum Opus")
.listStyle(.sidebar)
.toolbar {
ToolbarItem {
Button {
Task { await viewModel.syncNow() }
} label: {
switch viewModel.syncState {
case .syncing:
ProgressView()
.controlSize(.small)
default:
Label("Sync", systemImage: "arrow.trianglehead.2.clockwise")
}
}
}
}
}
}
- Step 2: Create ThreadListView
Create Apps/MagnumOpus/Views/ThreadListView.swift:
import SwiftUI
import Models
struct ThreadListView: View {
@Bindable var viewModel: MailViewModel
var body: some View {
List(viewModel.threads, selection: Binding(
get: { viewModel.selectedThread?.id },
set: { newId in
if let thread = viewModel.threads.first(where: { $0.id == newId }) {
viewModel.selectThread(thread)
}
}
)) { thread in
ThreadRow(thread: thread)
.tag(thread.id)
}
.listStyle(.inset)
.navigationTitle(viewModel.selectedMailbox?.name ?? "Mail")
.overlay {
if viewModel.threads.isEmpty {
ContentUnavailableView(
"No Messages",
systemImage: "tray",
description: Text("No threads in this mailbox")
)
}
}
}
}
struct ThreadRow: View {
let thread: ThreadSummary
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(thread.senders)
.fontWeight(thread.unreadCount > 0 ? .bold : .regular)
.lineLimit(1)
Spacer()
Text(thread.lastDate, style: .relative)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(thread.subject ?? "(No Subject)")
.font(.subheadline)
.lineLimit(1)
if let snippet = thread.snippet {
Text(snippet)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
HStack(spacing: 8) {
if thread.messageCount > 1 {
Text("\(thread.messageCount)")
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
if thread.unreadCount > 0 {
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
}
}
}
.padding(.vertical, 2)
}
}
- Step 3: Create ThreadDetailView
Create Apps/MagnumOpus/Views/ThreadDetailView.swift:
import SwiftUI
import Models
struct ThreadDetailView: View {
let thread: ThreadSummary?
let messages: [MessageSummary]
var body: some View {
Group {
if let thread {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(thread.subject ?? "(No Subject)")
.font(.title2)
.fontWeight(.semibold)
.padding()
Divider()
ForEach(messages) { message in
MessageView(message: message)
Divider()
}
}
}
} else {
ContentUnavailableView(
"No Thread Selected",
systemImage: "envelope",
description: Text("Select a thread to read")
)
}
}
}
}
struct MessageView: View {
let message: MessageSummary
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(message.from?.displayName ?? "Unknown")
.fontWeight(.semibold)
Spacer()
Text(message.date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
if !message.to.isEmpty {
Text("To: \(message.to.map(\.displayName).joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
if let bodyText = message.bodyText {
Text(bodyText)
.font(.body)
.textSelection(.enabled)
} else if message.snippet != nil {
Text(message.snippet ?? "")
.font(.body)
.foregroundStyle(.secondary)
.italic()
} else {
Text("Loading body…")
.font(.body)
.foregroundStyle(.tertiary)
}
}
.padding()
}
}
- Step 4: Create AccountSetupView
Create Apps/MagnumOpus/Views/AccountSetupView.swift:
import SwiftUI
struct AccountSetupView: View {
@Bindable var viewModel: AccountSetupViewModel
var onComplete: () -> Void
var body: some View {
Form {
Section("Account") {
TextField("Email", text: $viewModel.email)
#if os(iOS)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocapitalization(.none)
#endif
SecureField("Password", text: $viewModel.password)
TextField("Account Name (optional)", text: $viewModel.accountName)
}
if viewModel.isManualMode || viewModel.autoDiscoveryFailed {
Section("Server Settings") {
TextField("IMAP Host", text: $viewModel.imapHost)
TextField("IMAP Port", text: $viewModel.imapPort)
}
}
if let error = viewModel.errorMessage {
Section {
Text(error)
.foregroundStyle(.red)
}
}
Section {
if viewModel.isAutoDiscovering {
ProgressView("Discovering settings…")
} else {
Button("Connect") {
onComplete()
}
.disabled(!viewModel.canSubmit)
if !viewModel.isManualMode {
Button("Enter server settings manually") {
viewModel.isManualMode = true
}
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Add Account")
.task {
if !viewModel.email.isEmpty && !viewModel.isManualMode {
await viewModel.autoDiscover()
}
}
}
}
- Step 5: Wire into ContentView
Replace Apps/MagnumOpus/ContentView.swift:
import SwiftUI
import Models
import MailStore
struct ContentView: View {
@State private var viewModel = MailViewModel()
@State private var accountSetup = AccountSetupViewModel()
@State private var showingAccountSetup = false
var body: some View {
Group {
if viewModel.hasAccount {
mailView
} else {
NavigationStack {
AccountSetupView(viewModel: accountSetup) {
connectAccount()
}
}
}
}
.onAppear {
loadExistingAccount()
}
}
private var mailView: some View {
NavigationSplitView {
SidebarView(viewModel: viewModel)
} content: {
ThreadListView(viewModel: viewModel)
} detail: {
ThreadDetailView(
thread: viewModel.selectedThread,
messages: viewModel.messages
)
}
.task {
await viewModel.syncNow()
viewModel.startPeriodicSync()
}
}
private func connectAccount() {
guard let (config, credentials) = accountSetup.buildConfig() else { return }
do {
try viewModel.setup(config: config, credentials: credentials)
// Keychain storage added in Task 15
Task {
await viewModel.syncNow()
await viewModel.loadMailboxes(accountId: config.id)
viewModel.startObservingThreads(accountId: config.id)
viewModel.startPeriodicSync()
}
} catch {
accountSetup.errorMessage = error.localizedDescription
}
}
private func loadExistingAccount() {
// Keychain loading added in Task 15
// For now, always show setup
}
}
- Step 6: Regenerate Xcode project and build
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 7: Commit
git add Apps/
git commit -m "add three-column swiftui layout: sidebar, thread list, detail, account setup"
Task 14: HTML Message Rendering
Display HTML emails safely using WKWebView wrapped for SwiftUI.
Files:
-
Create:
Apps/MagnumOpus/Views/MessageWebView.swift -
Modify:
Apps/MagnumOpus/Views/ThreadDetailView.swift -
Step 1: Create WKWebView wrapper
Create Apps/MagnumOpus/Views/MessageWebView.swift:
import SwiftUI
import WebKit
#if os(macOS)
struct MessageWebView: NSViewRepresentable {
let html: String
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.preferences.isElementFullscreenEnabled = false
let prefs = WKWebpagePreferences()
prefs.allowsContentJavaScript = false
config.defaultWebpagePreferences = prefs
let webView = WKWebView(frame: .zero, configuration: config)
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
let sanitized = sanitizeHTML(html)
webView.loadHTMLString(sanitized, baseURL: nil)
}
}
#else
struct MessageWebView: UIViewRepresentable {
let html: String
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
let prefs = WKWebpagePreferences()
prefs.allowsContentJavaScript = false
config.defaultWebpagePreferences = prefs
let webView = WKWebView(frame: .zero, configuration: config)
webView.scrollView.isScrollEnabled = false
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
let sanitized = sanitizeHTML(html)
webView.loadHTMLString(sanitized, baseURL: nil)
}
}
#endif
/// Strip scripts, event handlers, and external resources for safe rendering
private func sanitizeHTML(_ html: String) -> String {
var result = html
// Remove script tags and their content
let scriptPattern = "<script[^>]*>[\\s\\S]*?</script>"
result = result.replacingOccurrences(
of: scriptPattern,
with: "",
options: .regularExpression
)
// Remove event handler attributes (onclick, onload, etc.)
let eventPattern = "\\s+on\\w+\\s*=\\s*\"[^\"]*\""
result = result.replacingOccurrences(
of: eventPattern,
with: "",
options: .regularExpression
)
// Block remote images by default (replace src with data-src)
let imgPattern = "(<img[^>]*?)\\ssrc\\s*=\\s*\"(https?://[^\"]*)\""
result = result.replacingOccurrences(
of: imgPattern,
with: "$1 data-blocked-src=\"$2\"",
options: .regularExpression
)
// Wrap in basic styling
return """
<!DOCTYPE html>
<html><head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system, system-ui; font-size: 14px; padding: 8px; }
img[data-blocked-src] { display: none; }
</style>
</head><body>
\(result)
</body></html>
"""
}
- Step 2: Update MessageView to support HTML
In Apps/MagnumOpus/Views/ThreadDetailView.swift, update MessageView:
struct MessageView: View {
let message: MessageSummary
@State private var showHTML = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(message.from?.displayName ?? "Unknown")
.fontWeight(.semibold)
Spacer()
if message.bodyHtml != nil {
Toggle(isOn: $showHTML) {
Text("HTML")
.font(.caption)
}
.toggleStyle(.button)
.controlSize(.small)
}
Text(message.date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
if !message.to.isEmpty {
Text("To: \(message.to.map(\.displayName).joined(separator: ", "))")
.font(.caption)
.foregroundStyle(.secondary)
}
if showHTML, let html = message.bodyHtml {
MessageWebView(html: html)
.frame(minHeight: 200)
} else if let bodyText = message.bodyText {
Text(bodyText)
.font(.body)
.textSelection(.enabled)
} else if let snippet = message.snippet {
Text(snippet)
.font(.body)
.foregroundStyle(.secondary)
.italic()
} else {
Text("Loading body…")
.font(.body)
.foregroundStyle(.tertiary)
}
}
.padding()
}
}
- Step 3: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 4: Commit
git add Apps/MagnumOpus/Views/
git commit -m "add html email rendering with wkwebview, script/tracker blocking"
Chunk 6: Account Setup, Keychain, and Polish
Task 15: Keychain Credential Storage
Store and retrieve IMAP credentials securely via Keychain.
Files:
-
Create:
Apps/MagnumOpus/Services/KeychainService.swift -
Step 1: Write KeychainService
Create Apps/MagnumOpus/Services/KeychainService.swift:
import Foundation
import Security
import Models
enum KeychainService {
private static let service = "de.felixfoertsch.MagnumOpus"
static func saveCredentials(_ credentials: Credentials, for accountId: String) throws {
let passwordData = Data(credentials.password.utf8)
// Delete existing entry first
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
]
SecItemDelete(deleteQuery as CFDictionary)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
kSecAttrLabel as String: credentials.username,
kSecValueData as String: passwordData,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainError.saveFailed(status)
}
}
static func loadCredentials(for accountId: String) throws -> Credentials? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
kSecReturnData as String: true,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let attrs = result as? [String: Any],
let data = attrs[kSecValueData as String] as? Data,
let password = String(data: data, encoding: .utf8),
let username = attrs[kSecAttrLabel as String] as? String
else {
if status == errSecItemNotFound { return nil }
throw KeychainError.loadFailed(status)
}
return Credentials(username: username, password: password)
}
static func deleteCredentials(for accountId: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: accountId,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.deleteFailed(status)
}
}
}
enum KeychainError: Error {
case saveFailed(OSStatus)
case loadFailed(OSStatus)
case deleteFailed(OSStatus)
}
- Step 2: Wire Keychain into ContentView
Update Apps/MagnumOpus/ContentView.swift — replace connectAccount() and loadExistingAccount():
private func connectAccount() {
guard let (config, credentials) = accountSetup.buildConfig() else { return }
do {
try viewModel.setup(config: config, credentials: credentials)
try KeychainService.saveCredentials(credentials, for: config.id)
// Persist account config to UserDefaults
if let data = try? JSONEncoder().encode(config) {
UserDefaults.standard.set(data, forKey: "accountConfig")
}
Task {
await viewModel.syncNow()
await viewModel.loadMailboxes(accountId: config.id)
viewModel.startObservingThreads(accountId: config.id)
viewModel.startPeriodicSync()
}
} catch {
accountSetup.errorMessage = error.localizedDescription
}
}
private func loadExistingAccount() {
guard let data = UserDefaults.standard.data(forKey: "accountConfig"),
let config = try? JSONDecoder().decode(AccountConfig.self, from: data),
let credentials = try? KeychainService.loadCredentials(for: config.id)
else { return }
do {
try viewModel.setup(config: config, credentials: credentials)
Task {
await viewModel.loadMailboxes(accountId: config.id)
viewModel.startObservingThreads(accountId: config.id)
await viewModel.syncNow()
viewModel.startPeriodicSync()
}
} catch {
// Account config exists but setup failed — show account setup
}
}
- Step 3: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 4: Commit
git add Apps/MagnumOpus/Services/KeychainService.swift Apps/MagnumOpus/ContentView.swift
git commit -m "add keychain credential storage, persist account config"
Task 16: IMAP Auto-Discovery
Query Mozilla ISPDB (Thunderbird autoconfig) and DNS SRV records to auto-detect IMAP settings.
Files:
-
Create:
Apps/MagnumOpus/Services/AutoDiscovery.swift -
Modify:
Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift -
Step 1: Write AutoDiscovery service
Create Apps/MagnumOpus/Services/AutoDiscovery.swift:
import Foundation
struct DiscoveredServer: Sendable {
var hostname: String
var port: Int
var socketType: String // "SSL" or "STARTTLS"
}
enum AutoDiscovery {
/// Try Mozilla ISPDB first, then DNS SRV, then return nil
static func discoverIMAP(for email: String) async -> DiscoveredServer? {
guard let domain = email.split(separator: "@").last.map(String.init) else { return nil }
// 1. Try Mozilla ISPDB
if let server = await queryISPDB(domain: domain) {
return server
}
// 2. Try DNS SRV record (RFC 6186)
if let server = await querySRV(domain: domain) {
return server
}
return nil
}
private static func queryISPDB(domain: String) async -> DiscoveredServer? {
let url = URL(string: "https://autoconfig.thunderbird.net/v1.1/\(domain)")!
guard let (data, response) = try? await URLSession.shared.data(from: url),
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let xml = String(data: data, encoding: .utf8)
else { return nil }
return parseISPDBXML(xml)
}
/// Minimal XML parsing — extract first <incomingServer type="imap"> block
private static func parseISPDBXML(_ xml: String) -> DiscoveredServer? {
// Find <incomingServer type="imap"> section
guard let imapRange = xml.range(of: "<incomingServer type=\"imap\">"),
let endRange = xml.range(of: "</incomingServer>", range: imapRange.upperBound..<xml.endIndex)
else { return nil }
let section = String(xml[imapRange.upperBound..<endRange.lowerBound])
func extractTag(_ tag: String) -> String? {
guard let start = section.range(of: "<\(tag)>"),
let end = section.range(of: "</\(tag)>", range: start.upperBound..<section.endIndex)
else { return nil }
return String(section[start.upperBound..<end.lowerBound])
}
guard let hostname = extractTag("hostname"),
let portStr = extractTag("port"),
let port = Int(portStr)
else { return nil }
let socketType = extractTag("socketType") ?? "SSL"
return DiscoveredServer(hostname: hostname, port: port, socketType: socketType)
}
private static func querySRV(domain: String) async -> DiscoveredServer? {
// DNS SRV lookup for _imaps._tcp.<domain> (RFC 6186)
// Use dnssd or nw_connection for SRV queries
// For v0.2: try well-known hostname patterns as fallback
let candidates = [
"imap.\(domain)",
"mail.\(domain)",
]
for candidate in candidates {
// Quick TCP connect test on port 993
if await testConnection(host: candidate, port: 993) {
return DiscoveredServer(hostname: candidate, port: 993, socketType: "SSL")
}
}
return nil
}
private static func testConnection(host: String, port: Int) async -> Bool {
do {
return try await withThrowingTaskGroup(of: Bool.self) { group in
group.addTask {
let task = URLSession.shared.streamTask(withHostName: host, port: port)
task.resume()
// readData is a legacy callback API — bridge to async
let (data, _, _) = try await task.readData(ofMinLength: 1, maxLength: 1024, timeout: 3)
task.cancel()
return !data.isEmpty
}
group.addTask {
try await Task.sleep(for: .seconds(3))
return false
}
// First completed result wins, cancel the other
let result = try await group.next() ?? false
group.cancelAll()
return result
}
} catch {
return false
}
}
}
- Step 2: Wire into AccountSetupViewModel
Update Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift — replace autoDiscover():
func autoDiscover() async {
guard !email.isEmpty else { return }
isAutoDiscovering = true
autoDiscoveryFailed = false
if let server = await AutoDiscovery.discoverIMAP(for: email) {
imapHost = server.hostname
imapPort = String(server.port)
isAutoDiscovering = false
} else {
isAutoDiscovering = false
autoDiscoveryFailed = true
isManualMode = true
}
}
Also update AccountSetupView to trigger auto-discovery when email field changes:
In Apps/MagnumOpus/Views/AccountSetupView.swift, add to the email TextField:
TextField("Email", text: $viewModel.email)
.onChange(of: viewModel.email) { _, newValue in
if newValue.contains("@") && !viewModel.isManualMode {
Task { await viewModel.autoDiscover() }
}
}
- Step 3: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 4: Commit
git add Apps/MagnumOpus/Services/AutoDiscovery.swift Apps/MagnumOpus/ViewModels/AccountSetupViewModel.swift Apps/MagnumOpus/Views/AccountSetupView.swift
git commit -m "add imap auto-discovery: mozilla ispdb, dns srv fallback"
Task 17: Offline Behavior and Error States
Add an offline banner and proper error display throughout the app.
Files:
-
Modify:
Apps/MagnumOpus/ContentView.swift -
Modify:
Apps/MagnumOpus/Views/SidebarView.swift -
Step 1: Add sync status banner to mailView
In Apps/MagnumOpus/ContentView.swift, update mailView:
private var mailView: some View {
NavigationSplitView {
SidebarView(viewModel: viewModel)
} content: {
ThreadListView(viewModel: viewModel)
} detail: {
ThreadDetailView(
thread: viewModel.selectedThread,
messages: viewModel.messages
)
}
.safeAreaInset(edge: .bottom) {
statusBanner
}
.task {
await viewModel.syncNow()
viewModel.startPeriodicSync()
}
}
@ViewBuilder
private var statusBanner: some View {
switch viewModel.syncState {
case .error(let message):
HStack {
Image(systemName: "wifi.slash")
Text("Offline — showing cached mail")
Spacer()
Button("Retry") {
Task { await viewModel.syncNow() }
}
.buttonStyle(.borderless)
}
.font(.caption)
.padding(8)
.background(.yellow.opacity(0.2))
case .syncing(let mailbox):
HStack {
ProgressView()
.controlSize(.small)
Text("Syncing\(mailbox.map { " \($0)" } ?? "")…")
.font(.caption)
Spacer()
}
.padding(8)
.background(.blue.opacity(0.1))
case .idle:
EmptyView()
}
}
- Step 2: Build and verify
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 3: Commit
git add Apps/MagnumOpus/ContentView.swift
git commit -m "add offline banner, sync status indicators"
Task 18: Background Body Prefetch
After initial sync, progressively fetch full message bodies for recent messages (last 30 days) so they're available offline.
Files:
-
Modify:
Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift -
Step 1: Add body prefetch to SyncCoordinator
Add to SyncCoordinator.swift after the performSync() method:
/// Fetch full bodies for recent messages that don't have bodyText yet
private func prefetchBodies(mailboxId: String) async {
let thirtyDaysAgo = ISO8601DateFormatter().string(
from: Calendar.current.date(byAdding: .day, value: -30, to: Date())!
)
do {
let messages = try store.messages(mailboxId: mailboxId)
let recent = messages.filter { $0.bodyText == nil && $0.date >= thirtyDaysAgo }
for message in recent.prefix(50) {
guard !Task.isCancelled else { break }
let (text, html) = try await imapClient.fetchBody(uid: message.uid)
if text != nil || html != nil {
try store.storeBody(messageId: message.id, text: text, html: html)
}
}
} catch {
// Background prefetch failure is non-fatal — log and continue
}
}
Call it at the end of syncMailbox():
// Prefetch bodies before disconnect — runs within the same sync cycle
// while IMAP connection is still active
await prefetchBodies(mailboxId: mailboxId)
- Step 2: Run all package tests
cd Packages/MagnumOpusCore && swift test
# Expected: all tests pass
- Step 3: Commit
git add Packages/MagnumOpusCore/Sources/SyncEngine/SyncCoordinator.swift
git commit -m "add background body prefetch for recent messages (last 30 days)"
Task 19: Run All Tests and Final Verification
- Step 1: Run package tests
cd Packages/MagnumOpusCore && swift test
# Expected: all tests pass (ModelsTests, MailStoreTests, SyncEngineTests, IMAPClientTests)
- Step 2: Build macOS app
cd Apps && xcodegen generate
xcodebuild -project MagnumOpus.xcodeproj -scheme MagnumOpus-macOS -destination 'platform=macOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 3: Build iOS app
xcodebuild -project Apps/MagnumOpus.xcodeproj -scheme MagnumOpus-iOS -destination 'generic/platform=iOS' build 2>&1 | tail -5
# Expected: BUILD SUCCEEDED
- Step 4: Manual verification checklist
- Launch macOS app → account setup screen appears
- Enter IMAP credentials → auto-discovery finds settings (or manual entry)
- Initial sync runs → sidebar shows mailboxes
- Select INBOX → thread list populates
- Select a thread → messages display in detail view
- Search for a keyword → matching messages appear
- Check offline: disconnect network → app still shows cached data, offline banner appears
- Reconnect → sync resumes, banner disappears
- Step 5: Bump CalVer version
Update version in Apps/project.yml to today's date (e.g., 2026.03.13). The implementing agent should set MARKETING_VERSION in both macOS and iOS targets.
- Step 6: Final commit
git status
# Review any remaining unstaged files and add them explicitly, e.g.:
# git add Packages/ Apps/ docs/
git commit -m "v0.2 complete: native swift email client with imap sync, grdb, swiftui"
Future Phases (not in this plan)
Documented in docs/plans/2026-03-13-v0.2-native-email-client-design.md:
- v0.3: SMTP client, compose/reply/forward, triage actions (archive, delete, flag)
- v0.4: VTODO tasks via CalDAV, unified inbox, GTD triage workflow
- v0.5: Contacts via CardDAV, calendar via CalDAV, delegation
- Later: IMAP IDLE, multiple accounts, keyboard-first triage