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