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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user