diff --git a/docs/plans/2026-03-13-v0.2-native-email-client-design.md b/docs/plans/2026-03-13-v0.2-native-email-client-design.md index 2eda70e..b35e52d 100644 --- a/docs/plans/2026-03-13-v0.2-native-email-client-design.md +++ b/docs/plans/2026-03-13-v0.2-native-email-client-design.md @@ -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 + // 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 `