refine v0.2 spec after review: fix blockers, clarify ambiguities

address IMAP client complexity, remove bidirectional flag sync,
add attachment table, error handling, HTML rendering, FTS5 sync notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 15:59:07 +01:00
parent 1f0f5a188c
commit 7aa3106be3

View File

@@ -6,7 +6,7 @@
**Architectural pivot from v0.1:** No remote server. All sync, indexing, and storage run on-device. Standard protocols (IMAP) connect directly to mail servers. The v0.1 TypeScript prototype (branch `feature/v0.1-backend-and-macos`) remains as reference.
**Design document:** `docs/plans/2026-03-10-magnum-opus-design.md`
**Design document:** `docs/plans/2026-03-10-magnum-opus-design.md` (architecture section superseded by this document — on-device Swift replaces the Hono/Uberspace backend)
---
@@ -84,7 +84,7 @@ MagnumOpus/
### Core Types (`Models` module)
- **`Account`** — IMAP/SMTP server config. Credentials stored in Keychain, not SQLite.
- **`Account`** — IMAP server config (SMTP deferred to v0.3). Credentials stored in Keychain, not SQLite.
- **`Mailbox`** — IMAP folder (Inbox, Sent, Drafts, Archive, Trash, custom). Tracks `uidValidity` and `uidNext` for delta sync.
- **`Message`** — email with headers, body, attachment metadata. Identified by RFC 5322 `Message-ID`.
- **`Thread`** — group of messages linked by `In-Reply-To` / `References` headers.
@@ -97,9 +97,8 @@ CREATE TABLE account (
name TEXT NOT NULL,
email TEXT NOT NULL,
imapHost TEXT NOT NULL,
imapPort INTEGER NOT NULL,
smtpHost TEXT NOT NULL,
smtpPort INTEGER NOT NULL
imapPort INTEGER NOT NULL
-- SMTP fields added in v0.3
);
CREATE TABLE mailbox (
@@ -121,8 +120,8 @@ CREATE TABLE message (
subject TEXT,
fromAddress TEXT,
fromName TEXT,
toAddresses TEXT,
ccAddresses TEXT,
toAddresses TEXT, -- JSON array of {"name", "address"} objects
ccAddresses TEXT, -- JSON array of {"name", "address"} objects
date TEXT NOT NULL,
snippet TEXT,
bodyText TEXT,
@@ -147,12 +146,29 @@ CREATE TABLE threadMessage (
PRIMARY KEY (threadId, messageId)
);
CREATE TABLE attachment (
id TEXT PRIMARY KEY,
messageId TEXT NOT NULL REFERENCES message(id),
filename TEXT,
mimeType TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
contentId TEXT, -- for inline images (CID references)
cachePath TEXT -- path in app sandbox, NULL until downloaded
);
CREATE VIRTUAL TABLE messageFts USING fts5(
subject, fromName, fromAddress, bodyText,
content='message', content_rowid='rowid'
);
-- FTS sync: the SQL above is illustrative. Implementation must use GRDB's
-- DatabaseMigrator with db.create(virtualTable:using:) for automatic
-- trigger generation to keep messageFts in sync on insert/update/delete.
```
### Cross-Mailbox Deduplication
The same message can appear in multiple IMAP mailboxes (e.g., CC to self → Inbox + Sent). Each IMAP copy gets its own row in the `message` table (different `mailboxId` + `uid`). Thread reconstruction deduplicates by `messageId` (RFC 5322 Message-ID): when threading, all rows sharing the same `messageId` join the same thread. The `threadMessage` join table references the `message.id` (UUID), so a thread may contain multiple rows for the same logical message — the UI deduplicates by `messageId` when rendering. Messages without a `messageId` (broken mailers) cannot be deduplicated and each gets its own single-message thread.
### Thread Reconstruction
Simplified JWZ algorithm — no subject-based fallback grouping (produces false matches):
@@ -161,13 +177,17 @@ Simplified JWZ algorithm — no subject-based fallback grouping (produces false
2. Look up existing threads where any member shares a reference
3. If found → add to that thread (merge if multiple threads match)
4. If not found → create new thread
5. Update thread metadata (last date, message count, subject from root message)
5. Update thread metadata (last date, message count, subject from earliest known message in thread — stripped of `Re:`/`Fwd:` prefixes)
---
## IMAP Client Layer
Wraps swift-nio-imap into a clean async/await API:
### Complexity Note
swift-nio-imap is a low-level protocol encoder/decoder, not a ready-made IMAP client. Building the `IMAPClient` actor requires: TLS negotiation, IMAP state machine management, command-response pairing, untagged response handling, literal continuations, and error recovery. This is the single largest implementation effort in v0.2 and must be broken into multiple implementation tasks with explicit milestones.
### Public API
```swift
actor IMAPClient {
@@ -185,6 +205,15 @@ actor IMAPClient {
}
```
### Internal Layers
The actor is built in layers:
1. **Connection** — SwiftNIO `Channel` with TLS (via swift-nio-ssl), handles connect/disconnect/reconnect
2. **Command pipeline** — sends IMAP commands, pairs tagged responses, handles continuation requests
3. **Response parser** — processes untagged responses (EXISTS, EXPUNGE, FLAGS updates)
4. **High-level operations**`listMailboxes`, `selectMailbox`, `fetchMessages` built on the pipeline
### Sync Strategy (Poll-Based for v0.2)
1. Connect to IMAP server
@@ -192,7 +221,7 @@ actor IMAPClient {
- Changed → full re-sync of that mailbox (rare — means server rebuilt UIDs)
- Same → fetch messages with UID > last known `uidNext`
3. Fetch envelopes + snippets eagerly; full bodies lazily
4. Sync flag changes (read/unread/starred) bidirectionally
4. Fetch flag changes from server (read-only — v0.2 does not write flags back to server). Local `isRead`/`isFlagged` state mirrors the server. Opening a message locally does not change flags in v0.2 — read state is purely server-driven.
5. Disconnect, schedule next poll
### Eager vs. Lazy Fetching
@@ -221,6 +250,8 @@ final class SyncCoordinator {
var syncState: SyncState // .idle, .syncing(progress), .error(Error)
var events: AsyncStream<SyncEvent>
// 5-minute interval applies in foreground only.
// On iOS background, sync uses BGAppRefreshTask at system-determined intervals.
func startPeriodicSync(interval: Duration = .seconds(300))
func syncNow() async throws
func stopSync()
@@ -238,7 +269,6 @@ enum SyncEvent {
### MailStore — Read/Write Interface
```swift
@Observable
final class MailStore {
func threads(in mailbox: String, limit: Int, offset: Int) -> [Thread]
func messages(in thread: Thread) -> [Message]
@@ -254,7 +284,17 @@ final class MailStore {
}
```
GRDB `ValueObservation` provides live-updating queries. When SyncEngine writes new messages, any SwiftUI view observing that mailbox automatically updates. This replaces the SSE pattern from v0.1.
GRDB `ValueObservation` is the sole mechanism for reactive UI updates. `MailStore` itself is not `@Observable` — it provides `ValueObservation`-based streams that ViewModels consume. This replaces the SSE pattern from v0.1.
### Error Handling & Offline Behavior
The app must remain fully usable with cached data when offline. Error strategy:
- **Connection failure / offline:** `SyncState` moves to `.error`. UI shows a non-intrusive banner ("Offline — showing cached mail"). All cached data remains browsable and searchable.
- **Authentication failure:** Surface to user immediately with option to re-enter credentials. Do not retry automatically (avoids account lockout).
- **Mid-sync connection drop:** Abort current sync cycle gracefully. Already-stored messages are kept. Next poll retries from the last successful `uidNext`.
- **Retry policy:** Exponential backoff starting at 30 seconds, capped at 15 minutes. Reset on successful sync or manual trigger.
- **TLS failure:** Do not fall back to plaintext. Surface error to user.
---
@@ -264,7 +304,7 @@ GRDB `ValueObservation` provides live-updating queries. When SyncEngine writes n
```
AppRoot
├── AccountSetupView ← first launch: configure IMAP/SMTP
├── AccountSetupView ← first launch: configure IMAP
└── MainView (NavigationSplitView)
├── Sidebar
│ └── MailboxListView ← Inbox, Sent, Drafts, Archive, Trash, custom
@@ -275,6 +315,15 @@ AppRoot
└── MessageView ← single message (header, body, attachments)
```
### HTML Email Rendering
- Use `WKWebView` for HTML email display (works on macOS, iOS, iPadOS)
- **Block remote content by default** (tracking pixels, external images). Show a "Load remote content" button.
- For `multipart/alternative` messages, prefer plain text; offer a toggle to view HTML
- Strip `<script>` tags and event handler attributes before rendering
- Inline CSS only — no external stylesheet loading
- Attachments with `Content-ID` (inline images) are resolved from the local cache
### Platform Adaptation
`NavigationSplitView` handles platform differences automatically:
@@ -286,11 +335,13 @@ AppRoot
### Account Setup (First Launch)
1. User enters email address
2. Auto-discovery via SRV records + well-known configs (Gmail, Outlook, Fastmail, etc.)
3. Fall back to manual IMAP/SMTP host + port entry
2. Auto-discovery: query Mozilla ISPDB (Thunderbird autoconfig database) as primary, DNS SRV records (RFC 6186) as fallback
3. Fall back to manual IMAP host + port entry
4. Credentials stored in Keychain
5. Initial sync begins immediately after setup
SMTP configuration is deferred to v0.3 (compose/reply). v0.2 setup only collects IMAP details.
### Data Flow
```
@@ -303,7 +354,7 @@ SyncCoordinator (background sync)
SwiftUI Views
```
No manual refresh needed. Background sync + GRDB observation = automatic UI updates.
No manual refresh needed. ViewModels observe two sources: **MailStore** (via GRDB `ValueObservation`) for data, and **SyncCoordinator** (via `@Observable`) for sync status (progress, errors). Background sync + GRDB observation = automatic UI updates.
---
@@ -335,7 +386,7 @@ Not tested in v0.2: performance at scale, real IMAP server integration.
### In
- Single account (IMAP only, SMTP config stored but not used yet)
- Single account (IMAP only, read-only — no flag write-back to server)
- IMAP sync via swift-nio-imap (poll-based, configurable interval)
- SQLite cache with GRDB (envelope eager, body lazy + cached)
- FTS5 full-text search
@@ -344,7 +395,7 @@ Not tested in v0.2: performance at scale, real IMAP server integration.
- Mailbox navigation (Inbox, Sent, Drafts, Archive, Trash, custom folders)
- Message reading with HTML/plain text display
- Attachment metadata display (download on demand)
- Account setup with auto-discovery
- Account setup with auto-discovery (Mozilla ISPDB + DNS SRV)
- Keychain credential storage
- Background body prefetch (last 30 days)