Companion to FMailSpec.md. The spec captures the design intent; this file captures what's actually shipped, what diverges from the spec, and what's left.
Last updated: 2026-05-09 (window-UI era).
⚠️ Current UI is menu-bar-only. Everything below documents the three-pane window app, now preserved on thewindow-UIbranch.masterships a menu-bar build: a status-bar dropdown with the unread/search list (multi-select mark read/unread), per-email Open-in-Mail / Reply / Reply All / Forward via Mail.app's native AppleScript commands, and an MCP/Tunnel submenu. Removed in the menu-bar build:AppShell, the sidebar/thread-list/reader views,HTMLBodyView,ReplyConfirmationSheet,ContactsService, themailto:MailComposer, and contact-prefs. Added:MenuBar/{AppDelegate, StatusItemController, MenuEmailRowView, MenuSearchFieldView, MinimalSettingsView}.swift, a flag-only read/unread reconcile on menu open, host-aware MCP OAuth discovery, and MCP on by default. The index / search DSL / threading / MCP data layer is unchanged. Current behaviour and setup: README.md.
| Spec pain point | Status | Where it landed |
|---|---|---|
| #1 Drifting unread counts | Solved | Phase 1 (compute from .emlx flags), refined in Phase 2 (recompute from our own DB on every sync) |
| #2 Weak search | Solved | Phase 3 (FTS5 + DSL with from: / to: / subject: / body: / before: / after: / during: / is: / has: / boolean / phrases) |
| #3 Wrong recipient address | Solved | Phase 4 (Contacts integration + per-contact preferred-address overrides + reply confirmation dialog) |
| #4 Illegible thread view | Mostly solved | Phase 2 (threading via union-find on Apple's message_references) + Phase 3 (single-column stacked thread reader with time-deltas, expand-on-click, unread tinting) + Phase 5 (HTML rendering in locked-down WKWebView). Spec §8 items still open: quote folding, N next-unread shortcut, two-column variant, cid: inline-image resolution. |
FMail is daily-driver capable today. Remaining work is Phase 5 polish.
The spec's Phase 2 "Index & search" was split into our Phase 2 (index + threading) and Phase 3 (search). The spec's Phase 3 "Threads & contacts" was split — threading went into our Phase 2, contacts into Phase 4 alongside compose handoff.
Goal: prove the two access paths the project depends on.
Files added:
FMailApp.swiftPermissions/FullDiskAccessFlow.swiftMailStore/EnvelopeIndexReader.swift(Phase 0 form — superseded later)MailStore/MailStoreEnumerator.swiftCore/Emlx/EmlxParser.swift(subject-only stub)UI/AppShell.swift,UI/Phase0DiagnosticView.swift(later removed)- Project shell via
xcodegen+project.yml,.gitignore FMailTests/Phase0Tests.swift
Verification: shipped diagnostic view showing ~/Library/Mail/V10 path, message count from Envelope Index, first .emlx Subject. Confirmed access works while Mail.app is running.
Goal: usable read-only viewer with correct unread counts.
Files added/expanded:
Core/Emlx/EncodedWord.swift— RFC 2047 (Q + B) decoding for headersCore/Emlx/HeaderParser.swift— RFC 5322 line-folding + header parsingCore/Emlx/MIMEParser.swift— multipart/alternative + multipart/mixed + base64 + quoted-printableCore/Emlx/EmlxParser.swift— full parse (length prefix → RFC 822 → MIME → flag plist trailer)Core/HTML/HTMLStripper.swift— non-WebKit HTML→text (entities, common block tags, whitespace collapse)MailStore/MailboxURL.swift— parses Apple'simap://<account-uuid>/<path>mailbox URLsMailStore/MailboxFilter.swift— hides[Gmail]/All Mail,Recovered Messages*,SendLaterby defaultMailStore/Models.swift—MailAccount,Mailbox,MessageHeader,MessageBodyMailStore/EnvelopeIndexReader.swift— extended withloadMailboxes,loadMessages,perMailboxCountUI/Sidebar/SidebarView.swiftUI/MessageList/MessageListView.swiftUI/Reader/ReaderView.swiftUI/MailModel.swift
Pain point #1 (counts) closed. UI bug fix during Phase 1: the message list showed "Loading…" indefinitely for empty mailboxes — fixed by separating isLoading from loaded-empty.
Goal: own SQLite index foundation; correct thread grouping; real-time change detection.
Decision: raw SQLite3 instead of GRDB.swift (deviation from spec §10) — keeps zero deps. Schema migrations + FTS5 access via import SQLite3 are verbose but small and fully understood.
Files added:
Core/Index/Schema.swift— versioned schema; v1 createdaccounts,mailboxes,messages,recipients,message_links,threads,messages_fts(FTS5),index_metadata. Later v2 addedcontact_prefs. v3 addedmessage_labels(Gmail).Core/Index/IndexDB.swift— actor wrapping our SQLite handle; bulk upserts in transactions of ~2000 rows; read API for the UI.Core/Index/Indexer.swift— orchestrator. Mirrors Apple's Envelope Index → our DB in chunks. Includes account-name heuristic (most-common-sender from Sent mailboxes; falls back to most-common-recipient).Core/Threading/ThreadGrouper.swift— union-find over(message_rowid, parent_message_id_hash)from Apple'smessage_referencestable. thread_id = smallest member rowid (deterministic).MailStore/FileWatcher.swift—FSEventStreamrooted at~/Library/Mail/V10/. 2 s coalescer. PersistslastEventIdto UserDefaults. Filters to*.emlx+Envelope Index*.MailStore/BodyLoader.swift— actor that lazily indexes.emlxfiles per mailbox by ROWID, then parses on demand. (Replaced the body-lookup half of the Phase 1MailDataStore.)
UI rewrites:
UI/MailModel.swift— switched data path from Envelope Index to our IndexDB. Added indexer progress state, sync-coalescing flag.UI/AppShell.swift— full-screen indexing progress on first launch; bottom footer status during incremental sync.UI/MessageList/MessageListView.swift— switched to threads list backed byloadThreadSummaries.UI/Reader/ReaderView.swift— single-column stacked thread reader with time-deltas (+3d,+12m).
Bug fix mid-phase: IndexDB's SQLite handle marked nonisolated(unsafe) to allow access in deinit (Swift 6 actor-deinit isolation rules). FSEvents callback fixed (was casting eventPaths as CFArray; correct interpretation is const char **).
Goal: query DSL + FTS5 + body indexing for content search.
Files added:
Core/QueryDSL/Token.swift,Lexer.swift,AST.swift,Parser.swift,Evaluator.swift,DateExpression.swiftCore/Index/BodyIndexer.swift— actor that walks.emlxfiles for messages wherebody_indexed = 0, parses body, updates the FTS5 row in place (DELETE + INSERT). Resumable across launches.UI/Search/SearchBar.swift(with interpreted-query strip)UI/Search/SearchResultsView.swift
Indexer change: now rebuilds messages_fts from joined messages ⨝ recipients at the end of every full sync, so subject + sender + recipients are searchable immediately. Body content becomes searchable progressively as the body indexer sweeps.
Bug fixes during Phase 3:
- FTS5
MATCHoperator can't accept a table alias on its LHS — changedFROM messages_fts f WHERE f MATCH ?toFROM messages_fts WHERE messages_fts MATCH ?. MessageListViewwas centred (noframe(maxHeight:.infinity, alignment:.top)) — fixed.- Search results used
onTapGesturewith no selection state — switched toList(selection:)bound toselectedSearchResultIdso the highlight persists. - Apple Mail stores Unix epoch in
date_received/date_sent, not Cocoa epoch (timeIntervalSinceReferenceDate) — fixed everywhere (was reading 2024 dates as 2055). - FTS5 single-token queries didn't match anything (
subject:vreturned novermont) — added implicit*prefix on bareword and field-value tokens. Quoted phrases stay exact. during:operator added (not in original spec) — granular range query whose width matches the precision of the supplied date (during:2026= all of 2026,during:2026-03= all of March,during:2026-03-15= that day).after:semantics fixed for partial dates soafter:2024means>= 2025-01-01(after the period), not>= 2024-01-01.- No-colon shortcuts added:
hasattachment,isunread,isread,isflaggedmap to their field forms. - Body-indexer now pauses during incremental sync (was racing the indexer's writes through the same SQLite connection — caused a SIGTRAP in
btreeParseCellPtrIndexon one occasion). - ReaderView OOB crash fixed (was reading the live
messagesInSelectedThreadarray via index from a staleenumerated()snapshot whenopenFromSearchmutated it mid-render).
Goal: pain point #3 (wrong recipient).
Files added:
Contacts/ContactsService.swift— actor wrappingCNContactStore; lazy permission request on first reply; in-memoryemail → contactmap.Compose/ComposeRequest.swift—ComposeRequestvalue type +ReplyBuilderthat turns aMessageHeader+MessageBodyinto a request for reply / reply-all / forward.Compose/MailComposer.swift—mailto:URL builder that drives Mail.app viaNSWorkspace.shared.open(url). Uses RFC 6068 query parameters (subject,body,cc,in-reply-to,references).UI/Reader/ReplyConfirmationSheet.swift— modal sheet showing resolved recipient, contact name, alternate addresses (with picker), "Always reply to X" / "Hide Y from suggestions" checkboxes. Wrong-address-catching mechanic.- Schema v2:
contact_prefs(contact_id, preferred_address, blocked_addresses JSON)+ helper methods onIndexDB. MailModelextensions:startReply,cancelReply,sendReply,startNewMail,replyDraftstate.- Reply / Reply-All / Forward toolbar in expanded
MessageBlock(⌘R, ⌘⇧R, ⌘⌥F). - Account email addresses now exposed on
MailAccount(Indexer was already extracting them; now surfaced in the model). Info.plist(viaproject.yml):NSContactsUsageDescription,NSAppleEventsUsageDescription.
Decision: mailto: only, not AppleScript. Spec §10 said NSAppleScript; we shipped mailto: because (a) RFC 6068 supports everything we need including In-Reply-To/References for threading, (b) no Automation permission prompt, (c) no AppleScript escaping headaches with arbitrary message bodies. Trade-off: Mail.app picks the From-account by heuristic from the original recipient, not from us. If that's wrong in practice, the AppleScript path is still a Phase 5 polish task.
Bug fixes during Phase 4:
- Schema v3 +
message_labelsmirror added: Gmail stores all messages in[Gmail]/All Mail(canonical) and uses Apple'slabelstable to map them to virtual mailboxes (INBOX, Important, Sent Mail). Without mirroring labels, Gmail INBOX/Sent Mail/Important showed empty. NowloadMessagesInMailbox,loadThreadSummaries,recomputeMailboxCounts, and the account-naming heuristic all UNION via labels. - Recipient-heuristic fallback for account naming (handles accounts with mail but no Sent mailbox).
- Quote-builder bug: HTML→text bodies sometimes use CRLF or bare CR; my single-line
split("\n")saw the whole body as one line and only the first line got the>prefix. Now normalises CRLF/CR → LF before splitting. - Empty quote lines now
>(not>) — matches convention.
Items already shipped that the original spec put in Phase 5 (chronological-ish order):
during:operator (above and beyond original DSL).- Per-account email address detection (Sent-mailbox heuristic + recipient-of-incoming fallback).
- Persistent search-result selection.
- "All Mailboxes" virtual mailbox at top of sidebar with global unread count + Dock-tile badge. Auto-selected on launch.
- Drafts/Trash/Junk filtered from "All Mailboxes" + global search results (canonical mailbox + Gmail label).
- App icon (
AppIcon.icns, multi-size, built viaiconutil). - "Open in Mail.app" button per message via
message://URL scheme — handles "body not yet downloaded" cases. Schema v4 addedrfc_message_idmirrored frommessage_global_data(FK ismessages.global_message_id→mgd.ROWID). - Reply toolbar moved to top of each expanded message (so long footers don't push it off-screen).
- Mark as Read / Mark as Unread via AppleScript (
osascriptsubprocess, targeted to the canonical mailbox to keep Mail.app's lockup window minimal). Optimistic-first: FMail's UI updates instantly; sync is suppressed for 30s to avoid full re-mirror;osascriptruns in background. Permission-denied (-1743) errors surface a one-click button that opens System Settings → Privacy → Automation. - Body-text loss bug fixed: incremental FTS update (don't wipe + reinsert each sync); Schema v5 reset of
body_indexedto recover existing data. - DSL aliases added for body search (
content:,text:). - HTML body rendering via locked-down
WKWebView(Core/UI/Reader/HTMLBodyView.swift). Strict CSP blocks all network — no tracking pixels, no remote images, no scripts, no fonts. Height auto-measured viaevaluateJavaScriptso the WebView fits naturally inside the outerScrollView. Reload guarded against feedback loops (compares last-loaded(html, allowRemoteImages)before reloading). - Per-message "Load remote images" opt-in. CSP relaxes to
img-src data: cid: http: https:for that one message instance; scripts/iframes/fonts stay blocked. Per-instance state, not persisted — re-opening the same email starts blocked again. - Boolean
OR/NOTnow compose across all predicate types. Previously the Evaluator routed text predicates into one FTS5 expression and date/flag/scope predicates into a separate SQLANDchain — so(during:2025 OR during:2023)silently becameduring:2025 AND during:2023(empty). The Evaluator now compiles each AST node into a uniform SQL boolean fragment: text predicates lift intoapple_rowid IN (SELECT rowid FROM messages_fts WHERE messages_fts MATCH ?)subqueries, date/flag/scope predicates emit directm.*conditions. Pure-text subtrees still fuse into one MATCH for efficiency. The interpreted-query strip now also shows(a OR b)and-xin proper form. - Bulk-action errors split from body-load errors. Bulk Mark Read/Unread failures used to set
bodyError, which the reader rendered inline alongside actual body-load failures (and tried to surface a-1743Automation deep-link). Bulk failures now land inbulkActionErrorand surface as a modal.alertfromAppShell, withbodyErrorreserved for the reader's "couldn't read this message's.emlx" cases. - Internal refactor: god-class split.
MailModel(1145 LOC) andIndexDB(1170 LOC) were near the top of the file-size list and absorbing too many responsibilities. Refactor split them up:UI/ReadStatusController.swift— owns Mark Read / Unread for messages, threads, search results. Holdsunowned MailModel. Absorbs the per-message and per-thread optimistic-flip siblings, the AppleScript-entry construction (previously duplicated 3×), and theskipSyncsUntilwindow.MailModelnow exposes thin forwarders formarkSelectedSearchResultsAsRead/markSelectedThreadsAsRead.Core/Index/IndexModels.swift— wire types (IndexedMessage,IndexedRecipient,IndexedMessageLink,IndexedThread,ThreadSummary,ContactPrefs) lifted out ofIndexDB.swift.Core/Index/IndexDB+ContactPrefs.swift— contact-prefs CRUD as an actor extension. Required wideningprepare/bind/bindOptional/stepDonefromprivateto internal so the extension can use them.MailScripter.swift—bucketByMailbox,buildAccountScopedBlock,buildCrossAccountFallbackextracted;setReadStatusBatchandfetchBodiesnow share the bucketing + scaffold. NetsetReadStatusBatch≈220→30 LOC.MailStore/EnvelopeIndexReader.swift—EnvelopeReadOnly(production sync reader) merged in fromIndexer.swift. The Phase-1EnvelopeIndexReaderwas pruned to its smoke methods (messageCount,mailboxCount); deadloadMailboxes/perMailboxCount/loadMessagesremoved.- Sidebar selection:
selectAllMailboxes()/selectMailbox(_:)collapse onto oneselect(_ s: SidebarSelection).SidebarView'sBinding(get:set:)hand-roll became one line. Existence guard preserved (silent no-op for.mailbox(id)on a missing id). Core/Logging.swift— centralLog.{sync, mailScripter, fileWatcher, bodyIndexer}overos.Logger. Replaced ad-hocprint/fputsand surfaced previously-silent FSEventStream failure paths.- Cosmetic: dropped dead
MailModel.setReadStatus(_:isRead:)(single),applyOptimisticReadFlag(singular),MailScripter.setReadStatus(single) +makeScript+buildMailboxRef,MailStoreEnumerator.findFirstEmlx/findEmlx,EmlxParser.subject(of:)+peelLengthPrefix+ the String-overloadsplitHeaderBody. RemovedMIMEParser.splitMultipartcursorvestige.
- Second internal refactor pass:
MailModeldecomposition + DB-error surfacing + UI-layer dedupe (commitee55b53).MailModel(760 → 675 LOC) split further. NewUI/SyncCoordinator.swift(123 LOC,@MainActor, weak ref to MailModel) owns the file watcher, body-indexer task lifecycle, sync-coalescing flags (syncInFlight/syncRequestedWhileBusy),skipSyncsUntilwindow,runIncrementalSync, and the post-syncfetchMissingUnreadBodiesprefetch. NewUI/BodyFetchPoller.swift(30 LOC) owns the on-demand.emlxpoll loop (the 8s retry-with-invalidate after the AppleScriptsource of msgtriggers Mail.app's IMAP fetch).ReadStatusControllernow writesmodel.syncCoordinator?.skipSyncsUntilinstead of reaching into MailModel.MailboxKindenum replacesMailbox.kind: String. OneMailboxKind.viewScope(forSelectedKind:allMailboxesScope:)helper plusMailboxKind.isSystemIsolatedconsolidate the three previously-duplicated["drafts", "trash", "junk"]predicates (inMailModel.openFromSearch,MailModel.loadMessagesForSelectedThread,ReadStatusController.currentViewScope). Enum'srawValues match the strings already in DB so SQL filters (kind IN ('drafts','trash','junk')) keep working unchanged;IndexDB.loadMailboxesdecodes viaMailboxKind(rawValue:) ?? .other.- DB errors surfaced instead of silent
try?:countAllUnreadExcludingDraftsfailure now keeps the previous count and logs (rather than zeroing the badge);openFromSearchdistinguishes "DB error" from "no thread" and setsthreadsErrorfor both; bulk Mark-Read writes batch through newIndexDB.setIsReadBatch(rowids:isRead:)(one transaction, single throw —setIsRead(rowid:)now delegates to it), failures alert viabulkActionError;setPreferredAddress/addBlockedAddressfailures log via the newLog.dbos.Logger category instead of being silently dropped. - Shared list components in
UI/Components/:BulkActionHeader.swiftcollapses the duplicated "row count + selection count + Mark Read / Mark Unread / Clear" header used by both the threads list and search results.ListSelectionGesture.swiftcollapses the plain/⌘/⇧ click resolver into oneaction(from: NSEvent.ModifierFlags)returning.open/.toggle/.rangeFromAnchor. - Other small cleanups:
UI/DateFormats.swift(Date.listFormat()) extension unifies the threads-list and search-results row date format (was duplicated verbatim in both files).MailAppOpener.openMessagecalls now route throughMailModel.openInMailApp(_:)(ReaderView no longer reaches into the Compose layer). Dropped unnecessary@Bindablefrom 7 views (onlySidebarViewactually uses$model.x). Two compiler warnings cleared (redundantawaiton synchronousIndexDB.init; deadbody != nilafterif let body = ...). Removed unusedFocusedValues.mailModelextension.Task.detached { runIncrementalSync }simplified toTasksincerunIncrementalSyncis@MainActor(no detach benefit). InnerTask { @MainActor }removed fromBodyIndexerprogress callback (parameter was already@MainActor-isolated). Magic numbers extracted:MailModel.dockBadgeMaxDisplay = 999,HTMLBodyView.imageReflowDelaySeconds = 1.5. Dead_ = acctMap. To enable testing,ReplyConfirmationSheet.subjectPreviewlifted ontoReplyKind.subjectPreview(forKind:originalSubject:)andReaderView.formatDeltalifted into a freeTimeDeltaFormatter.format(_:). - First UI-layer tests (
FMailTests/UILogicTests.swift, 24 cases, no FDA needed):MailboxKindview-scope decision tree (4 cases) +isSystemIsolated;Date.listFormatrendering buckets (3 cases);ReplyKind.subjectPreviewRe/Fwd cases including case-insensitive existing-prefix detection (5 cases);TimeDeltaFormatter.formatsix time buckets (6 cases);MailModel.selectstale-id silent guard,selectAllMailboxes,mailboxesByAccountINBOX-first sort + hidden filtering (5 cases). All pass; pre-existingPhase0Testsstill skip without FDA.
- MCP server (opt-in). Loopback HTTP/JSON-RPC server on
127.0.0.1:8765(configurable in Settings). Eight tools —search_emails,list_threads,get_thread,get_email,find_unanswered_threads,mark_read,delete_messages,move_to_junk— all hit the existing index/threading/DSL/ReadStatus pipeline. Off by default; gated byMCPSettings.enabledand a privacy banner in the Settings window. Hand-rolled JSON-RPC 2.0 + minimal HTTP/1.1 framing onNetwork.framework'sNWListener(no SDK dep). Loopback-only viarequiredInterfaceType = .loopbackplus per-connection peer check. Write tools (mark_read,delete_messages,move_to_junk) block onMailScripterso the LLM sees the result; tool descriptions tell the LLM to keep batches ≤ ~50 to avoid client timeouts (no SSE in v1). Tests cover the JSON-RPC envelope, HTTP framing, every read tool against an in-memory IndexDB fixture, thefind_unanswered_threadsSQL, the write thunk paths, and a full TCP handshake bound on port 0 — 33 cases. Files:FMail/MCP/{MCPSettings, MCPProtocol, MCPDispatcher, MCPServer, MCPModels, MCPHandlers, MCPHandlers+A3, MCPHandlers+Move, MCPTools}.swift,FMail/Core/Index/IndexDB+MCP.swift,FMail/UI/Settings/SettingsView.swift, plusMCPServerlifecycle + thunks (markRead, delete, junk) wired inMailModel.applyMCPSettings. Settings scene gives⌘,;AppShellfooter adds a green pill when running. See MCP_PLAN.md for the full design. - Bulk Delete + Move to Junk (UI + MCP).
BulkActionHeadergains two buttons next to Mark Read / Mark Unread — Delete (trashicon,.destructiverole) and Junk (exclamationmark.octagon). Both apply to threads-list selections and search-results selections.MailScripteraddsdeleteBatchandmoveToJunkBatchvia a sharedrunActionBatchscaffold that accepts per-context action strings (account-scopedtheAccountvs cross-accountanAccount);setReadStatusBatchwas refactored to use the same scaffold.ReadStatusControlleraddsdeleteMessages/moveMessagesToJunk(fire-and-forget UI) plus awaitabledeleteMessages(rowids:) async/moveToJunk(rowids:) asyncfor MCP. Optimistic flip is a removal (rows leave their mailbox), not an in-place update: drops frommessagesInSelectedThread/searchResults, decrements thread + mailbox + global counts, drops empty threads. DB is untouched; the next FSEvent-driven sync re-mirrors Apple's Envelope Index. MCP exposes both asdelete_messagesandmove_to_junkwith the sameMarkReadResultshape (applied,error). - AppleScript move_to_junk removed entirely (after writeback B1 landed). macOS Tahoe broke
junk mailbox of <account>for every account in observed setups, and follow-up attempts (name walk fallback,move msg toverb, droppingignoring application responses) all left the call hanging Mail.app's AppleEvent queue. The Gmail API path viaGmailAPIWritebackService(writeback B1) is the reliable path for authorized Gmail accounts; B2 will add IMAP for the rest.AppleScriptWritebackService.moveToJunknow returns an explicit "unsupported — authorize Gmail or wait for IMAP" error per message instead of trying and hanging.MailScripter.moveToJunkBatchandmoveToJunkActiondeleted along with their unit tests;mark_readanddeletevia AppleScript stay. - Junk script: status + fallback lookup (now-deleted follow-up — pre-writeback). The first cut of
move_to_junkran a single statement (set mailbox of msg to junk mailbox of theAccount) and silently no-op'd whenjunk mailbox of <account>returnedmissing value— observed in practice against a Gmail account where the message was in[Gmail]/All Mail. The MCP call also blocked while Mail.app evaluated the property + did a slow IMAP MOVE, exceeding the LLM client's HTTP timeout. Rewrite:MailScripter.moveToJunkAction(accountVar:)emits a 3-step block — (1)set junk mail status of msg to true(always succeeds, fast, trains Gmail's filter), (2)try junk mailbox of <accountVar>; ifmissing value, walkmailboxes of <accountVar>for namesSpam/Junk/Spam mail/Bulk Mail/[Gmail]/Spam, (3)set mailbox of msg to tgtMbox.MailScripter.makeLookupBlockwas updated to indent every line of a multi-line action, not just the first. NewMailScripter.diagnoseJunkMailboxes()enumerates each account'sjunk mailbox of accproperty + every Spam/Junk-named mailbox; exposed via Tools → "Diagnose Junk mailboxes…".MailScripter.buildScriptSourcefactored out as internal soFMailTests/MailScripterTests.swift(13 cases) can pin the script invariants without invoking Mail.app — junk-status-first, junk-mailbox lookup, name-search fallback, set-mailbox, correct account variable per context, multi-line indentation, action grouping for same-mailbox batches.
Remaining (see "Open work" below).
These are intentional choices made during implementation; the spec hasn't been edited to match (it's the design-intent doc). Cross-referenced for review.
| Spec § | Said | Shipped | Reason |
|---|---|---|---|
| §6.1 | Index path under ~/Library/Containers/<bundle-id>/... (sandboxed) |
~/Library/Application Support/FMail/index.sqlite |
Non-sandboxed v1; sandbox attempt deferred (FSEventStream + sandbox interaction unproven). |
| §6.1 | Incremental indexing per FSEvent | Each FSEvent triggers a full re-mirror of Apple's Envelope Index. Cheap with WAL but wasteful. | Simplicity. True incremental sync deferred. |
| §6.3 | Natural-language fallback (heuristic translator) | Not yet | Deferred to Phase 5. The DSL covers the common cases. |
| §6.4 | Saved searches (sidebar virtual mailboxes) | Not yet | Deferred to Phase 5. |
| §7 | "Address Book Overrides" pane in settings | Inline in reply confirmation sheet only | Deferred Settings UI to Phase 5. |
| §8 | Two-column thread reader option | Single-column stacked only | Single-column was simpler; user preference for two-column hasn't surfaced. |
| §8 | N next-unread shortcut |
Not yet | Deferred. |
| §8 | Quote folding (> > > blocks collapsed) |
Not yet | Deferred. |
| §8 | Inline images | cid: (attachment-bundled) images: not yet wired (CSP allows them, resolver TBD). External images: opt-in per message via "Load remote images" button (Phase 5). |
Privacy: external images are tracking signals; opt-in only. |
| §10 | GRDB.swift | Raw SQLite3 C API |
Zero new deps. Schema migrations + FTS5 work fine via prepare/step/finalize. |
| §10 | NSAttributedString(html:) for HTML→text |
Two paths: custom HTMLStripper for indexing (FTS body extraction — WebKit-per-message would tank perf at 150k); WKWebView for display (in the reader, with strict CSP that blocks all network). |
Different requirements: indexing must be cheap; display must be faithful. |
| §10 | NSAppleScript for compose |
mailto: URL via NSWorkspace |
Simpler, no Automation permission, RFC 6068 covers our needs. |
| §11 | Sandboxed (try first, fall back) | Not sandboxed | Deferred to Phase 5. |
| §11 | Automation permission "for the future AppleScript path" | Required now for Mark as Read / Unread | Phase 5 added the AppleScript path. UI shows a Settings deep-link when -1743 is seen. |
| §12 | 5 phases | 5 phases — ordering shifted: spec P2 split into our P2 (index+threading) and P3 (search); spec P3 split into our P2 (threading) and P4 (contacts) | Threading is FTS-adjacent (affects index design); doing it in P2 was cheaper than retrofitting later. |
Things the spec didn't mention but proved necessary or useful:
- Apple
labelstable mirroring (Schema v3). Gmail's data model uses labels, not folders. Without mirroring, Gmail's INBOX/Sent/Important all appeared empty. during:operator. Granular date-range query with auto-width based on precision of the input.- No-colon DSL shortcuts (
hasattachment,isunread,isread,isflagged). - Recipient-heuristic account naming. Sent-mailbox heuristic doesn't cover accounts with no Sent mailbox; fallback queries the most common To-recipient.
- Sync coalescing. FileWatcher fires per
.emlxchange; without coalescing, Mail.app's IMAP sync would put us in a constant-resync loop. - Body-indexer pause-during-sync. Avoided a SQLite memory access fault from concurrent connection use.
- Persistent search-result selection (List(selection:) on
selectedSearchResultId).
| Question | Decision |
|---|---|
| Sandbox or not? | Non-sandboxed for v1. Phase 5 may attempt. |
| GRDB vs raw SQLite3? | Raw SQLite3. |
| Single window or document-style? | Single window, three-pane. |
[Gmail]/All Mail handling? |
Hidden by default; eye toggle reveals. |
iCloud aliases (@me.com / @icloud.com / @mac.com)? |
Not handled yet. Treat as separate identities currently. |
Bundled .emlx parser vs reuse Apple's? |
Hand-rolled parser. |
Roughly in order of value-to-cost.
Quick wins (each ~1 evening):
- Saved searches (star a query → sidebar virtual mailbox).
- Keyboard shortcuts:
J/Knext/prev message,Nnext-unread within thread then across. - Quote folding in reader (
> > >blocks collapsed by default). - Quick Look on attachments.
- "Pause indexing" toggle in settings.
- Bottom-of-list "Show more" / load more than 500 threads / search results.
Real cleanup (each ~weekend):
- True incremental sync — currently every FSEvent triggers a full re-mirror.
- Body indexer that picks up new mail discovered by FSEvents (currently only sweeps the initial backlog).
- Settings pane for address overrides (review/edit
contact_prefsrows). - DSL tests (snapshot for parser, property-based for boolean operators). UI pure-helper tests are now in place; the DSL parser/evaluator is still untested.
- Sandbox attempt + verify FSEventStream still fires.
- AppleScript compose path for "send from this account" precision (currently Mail.app picks).
- Schema-fingerprint test against live Envelope Index (catches Apple changing column names in a future macOS).
- iCloud alias unification (
@me.com≡@icloud.com≡@mac.com) forto:meand account matching.
Deferred (spec §14): iOS companion via iCloud Drive sync, after Mac v1 has been in daily use for 1+ month.
FMail/
├── FMailApp.swift Entry point + Tools menu (Diagnose Mail.app structure)
├── Compose/
│ ├── ComposeRequest.swift ReplyBuilder (reply / reply-all / forward → ComposeRequest)
│ └── MailComposer.swift mailto: URL builder + MailAppOpener (message:// scheme)
├── Contacts/
│ └── ContactsService.swift CNContactStore wrapper, address→contact map
├── Core/
│ ├── Logging.swift os.Logger namespace (Log.sync / .mailScripter / …)
│ ├── Emlx/
│ │ ├── EmlxParser.swift .emlx file parser (length prefix + RFC 822 + flag plist)
│ │ ├── EncodedWord.swift RFC 2047 (Q + B encoding)
│ │ ├── HeaderParser.swift RFC 5322 headers + line folding
│ │ └── MIMEParser.swift Multipart, base64, quoted-printable
│ ├── HTML/
│ │ └── HTMLStripper.swift Non-WebKit HTML → text
│ ├── Index/
│ │ ├── Schema.swift Versioned schema (v1..v6: contact_prefs, message_labels,
│ │ │ rfc_message_id, body_indexed reset, imap_uid)
│ │ ├── IndexDB.swift Actor wrapping our SQLite handle (read API + bulk writes)
│ │ ├── IndexDB+ContactPrefs.swift Actor extension: contact_prefs CRUD
│ │ ├── IndexModels.swift Wire types (IndexedMessage, IndexedRecipient, …,
│ │ │ ThreadSummary, ContactPrefs)
│ │ ├── Indexer.swift Mirrors Apple's Envelope Index → our DB
│ │ └── BodyIndexer.swift Background sweep that fills FTS body content
│ ├── QueryDSL/
│ │ ├── Token.swift Token kinds
│ │ ├── Lexer.swift String → tokens
│ │ ├── AST.swift Boolean tree + Term cases
│ │ ├── Parser.swift Tokens → AST + field-term mapping
│ │ ├── DateExpression.swift ISO / relative / month-name dates with granularity
│ │ └── Evaluator.swift AST → SQL boolean WHERE clause + bindings (text leaves
│ │ use `apple_rowid IN (… messages_fts MATCH ?)` subqueries;
│ │ pure-text subtrees fuse into one MATCH)
│ └── Threading/
│ └── ThreadGrouper.swift Union-find over message_references
├── MailStore/
│ ├── BodyLoader.swift Actor: per-mailbox rowid→.emlx URL cache + parse on demand
│ ├── EnvelopeIndexReader.swift Two readers: EnvelopeIndexReader (smoke / Phase0Tests
│ │ — messageCount / mailboxCount only); EnvelopeReadOnly
│ │ (production sync reader: mailboxes / messages /
│ │ recipients / labels / references)
│ ├── FileWatcher.swift FSEventStream wrapper (logs setup failures via os.Logger)
│ ├── MailboxFilter.swift Hide rules ([Gmail]/All Mail, Recovered, SendLater)
│ ├── MailboxURL.swift Parses Apple's `imap://<uuid>/<path>` mailbox URLs
│ ├── MailStoreEnumerator.swift Locates ~/Library/Mail/V<N>; envelope index URL helper
│ └── Models.swift MailAccount, Mailbox, MessageHeader, MessageBody,
│ MailboxKind enum (with viewScope helper)
├── Permissions/
│ └── FullDiskAccessFlow.swift First-run FDA prompt + System Settings deep-link
└── UI/
├── AppShell.swift Top-level shell + states (loading / FDA / indexing /
│ ready) + bulkActionError alert
├── MailModel.swift Main @Observable @MainActor view-model. Sync
│ orchestration delegated to SyncCoordinator;
│ Mark Read / Unread to ReadStatusController; the
│ on-demand body retry loop to BodyFetchPoller
│ (all ObservationIgnored).
├── SyncCoordinator.swift Owns file watcher + body-indexer task lifecycle +
│ runIncrementalSync + sync coalescing + post-sync
│ missing-body prefetch + skipSyncsUntil window.
├── ReadStatusController.swift Owns Mark Read / Unread: optimistic flip across
│ threads/messages/search-results, AppleScript
│ dispatch, batched DB write via setIsReadBatch.
├── BodyFetchPoller.swift 8s retry-with-invalidate poll loop after the
│ AppleScript `source of msg` IMAP fetch.
├── DateFormats.swift Date.listFormat() — shared row date format.
├── Components/
│ ├── BulkActionHeader.swift Selection-count + Mark Read/Unread/Clear header
│ │ (shared by threads list and search results).
│ └── ListSelectionGesture.swift Plain/⌘/⇧ click resolver.
├── MessageList/
│ └── MessageListView.swift Search bar + threads list / search results list
├── Reader/
│ ├── ReaderView.swift Stacked thread reader (+ TimeDeltaFormatter)
│ ├── HTMLBodyView.swift WKWebView wrapper (locked-down CSP, height auto-measure)
│ └── ReplyConfirmationSheet.swift Address-picker dialog
├── Search/
│ ├── SearchBar.swift With interpreted-query strip
│ └── SearchResultsView.swift
└── Sidebar/
└── SidebarView.swift Accounts → mailboxes with unread counts
FMailTests/
├── Phase0Tests.swift Smoke tests (skip when test runner lacks FDA)
└── UILogicTests.swift Pure-helper unit tests: MailboxKind view-scope,
Date.listFormat, ReplyKind.subjectPreview,
TimeDeltaFormatter, MailModel selection/sort
(24 cases, no FDA needed).
Top-level:
├── FMailSpec.md Original design spec (intent)
├── IMPLEMENTATION.md This file (status)
├── project.yml xcodegen project definition
├── .gitignore Includes generated FMail.xcodeproj + Info.plist