@bobfrankston/rmfmail 1.0.473 → 1.0.475

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +46 -44
  2. package/TODO.md +1105 -0
  3. package/package.json +1 -1
package/TODO.md ADDED
@@ -0,0 +1,1105 @@
1
+ <a id="top"></a>
2
+
3
+ # mailx TODO
4
+
5
+ *Last updated: 2026-04-27 21:10 local · v1.0.431 (contact-list junk filter + sent/received split + Esc-during-attach guard; build clean, uncommitted)*
6
+
7
+ > **Policy: completed items live at the end of this file.** Active sections (autonomous queue, priority tables, quick wins, categorized) show OPEN / PARTIAL / PENDING only. When an item ships, strike it through *and move the row* to the [Done section](#done-recent) (or [Done](#done) for older items). The version-tagged completion log is `DONE.md`; this file's Done section is the in-TODO archive of items that used to appear in the active tables.
8
+
9
+ ## Autonomous-work queue (Claude works top-down when idle) [↑ top](#top)
10
+
11
+ These items don't need user input — source-only changes, compile-verified before claiming done. Ordered by expected impact. Anything here that becomes blocking moves out; anything new that matches the shape gets added at the bottom.
12
+
13
+ | Order | Item | Scope | Status |
14
+ |---|---|---|---|
15
+ | 11 | **[P116](#ext116) — Signature edit in Settings menu** | M | Deferred v1.0.401 — turns out to be M (multi-account modal + save path + JSONC round-trip), not S. Real work when Settings UI gets its proper pass. |
16
+ | 13 | **S56 full AbortController plumbing** | L | Thread `AbortSignal` through `getMessage → fetchMessageBody → provider.fetchOne`. Touches IMAP + Gmail + Outlook providers. |
17
+ | **18** | **[P115](#ext115) — register mailx as Windows default `mailto:` handler** | M | User-priority. Click any `mailto:` link in browser/Word/Outlook → opens mailx compose. Registry-only (no MSIX). Owed: (a) HKCU\\SOFTWARE\\Classes registry write at install/setup, (b) `--mailto <url>` CLI in `bin/mailx.ts` that parses the URL and forwards to the running daemon's compose IPC, (c) `bin/mailx.ts -register-mailto` / `-unregister-mailto` admin commands, (d) "Default mail" toggle in Settings UI that calls the same. Split out of [C46](#ext46) — only the mailto half; Share-target + MSIX stay blocked. |
18
+
19
+ *Items 1–10, 12, 14–17 shipped — see [Done section](#done-recent).*
20
+
21
+ ### Deferred (not autonomous — blocked on user input or external system)
22
+
23
+ - Open questions awaiting your input (see table below): **Q101** (FTS5 body index) · **Q109** (drag-reparent) · **Q110** (menu bar) · **Q113** (per-message metadata shape) · **Q111-AI** (writing assistance back-end)
24
+ - Blocked on external: **C23** Outlook Graph API (Azure app registration) · **S64/S65** pinned-taskbar icon + unread badge when not running (msger change / tray process) · **C28** popup custom-protocol (msger change) · **C32** Linux schema deploy (non-repo)
25
+
26
+
27
+ **Annotation markers:** `✓` = Claude can do this autonomously in a single session (small, no design Q, no runtime-test required). `❓` = has an open design question that needs user input before the item can be worked (flagged in the detail section). Unmarked = too large for a single session, or blocked on something outside the repo (msger change, Azure app registration, Android APK rebuild, schema migration requiring real data).
28
+
29
+ **Companion file**: chronological completed log with date-time + version tags lives in `DONE.md`. This file (`TODO.md`) is the forward-looking backlog.
30
+
31
+ ## Resume here (post-reboot)
32
+
33
+ **Current version**: v1.0.388 (in source — needs `npm run build` to land in the running app). v1.0.388 slice:
34
+ - IMAP reconciliation safeguards — same 3 guards as Gmail API path (empty-list transient, 50% threshold refusal, per-deletion log with `msgid` + subject). Motivation: "letter I replied to disappeared" user report 2026-04-23 evening — most plausible cause is a partial Dovecot `UID SEARCH` wiping rows with no audit trail. `packages/mailx-imap/index.ts:1015+`. Gmail API path at `index.ts:1418` also gained the per-msgid `[reconcile-delete]` log so future reports have data on both providers.
35
+ - Startup fast-path — `bin/mailx.ts` fires `quickInboxCheckAccount` for every enabled account in parallel with `syncAll()`. Motivation: "take a long time to see new letters on startup" — `syncAll` step 1 is a folder-LIST that adds 1–3 s on bobma before INBOX even starts. Fast-path uses the DB-cached folder list, so INBOX UID SEARCH runs immediately.
36
+ - Outbox badge self-heal — (a) `listQueuedOutgoing` shows unreadable files instead of silently dropping them (fixes "outbox shows red 1 but modal is empty" where badge-count > listed-count), (b) first outbox-worker tick at 500 ms instead of 3 s so a crashed-PID claim file clears within half a second of startup.
37
+
38
+ **Answer needed from Bob** (short prompts, answer whichever you care about; full context in `Q*` rows below):
39
+ - **Q101** — full-body text in FTS5? Auto-rebuild on upgrade or explicit `mailx -reindex`?
40
+ - **Q104** — shared alarm subsystem (one queue for mail/calendar/tasks) or per-feature with a thin dispatcher?
41
+ - **Q109** — folder drag-to-reparent: full IMAP RENAME vs stay-in-parent rename-only?
42
+ - **Q110** — replace toolbar-dropdown with full File/Edit/View/Message menu bar?
43
+ - **Q112** — Android drain sync_actions directly via Gmail API, or rely on desktop reconcile?
44
+ - **Q113** — per-message metadata: JSON column on `messages` vs separate `message_meta` table keyed by uuid?
45
+ - **Q114** — keep the full-screen calendar modal alongside the sidebar, or retire it?
46
+ - **Q111-AI** — writing assistance back-end pick (LanguageTool / custom AI via ghost-text path / native WebView2 spellcheck only / something else)?
47
+
48
+ **Plan items still open** (numbered list further down at "[Plan](#plan)"):
49
+ - Plan 10 / [S56](#ext56): row-objects-own-preview refactor (kills `gen` token band-aid).
50
+ - Plan 11: Google Calendar service-side proxy so the sidebar shows the user's actual events.
51
+ - Plan 12 / P17: shared alarm subsystem (open Q104).
52
+ - Plan 13 / S57-S62: Android parity debt (each sub-item has its own ID now — INBOX-first render, rail, hamburger, keyboard lag, double-compose-on-send, prefetch priority).
53
+
54
+ **Concerns to watch (not priority, monitor)**: S7 IMAP delete (likely moot); S49 body comingling (FIXED); S50 accounts.jsonc banner (quiet).
55
+
56
+ **Active priorities (2026-04-27):** (1) ~~S64 taskbar pin~~ — **DONE** v1.0.419 + msger 0.1.357. (2) ~~S66 Tasks quota flood~~ — **PARTIAL** (overnight 2026-04-27): root cause found (refreshTasks/refreshCalendarEvents had unconditional `changed=true`, feeding a UI→service→API self-poll loop). Loop killed via row-equality check. Plus 429 cooldown, in-flight dedup, typed `GoogleHttpError`, sidebar `quotaError` banner. **Build verify still owed** — bash session-env was wedged overnight, tsc not run. Same overnight slice: C32 verifySchema extended to calendar_events; C48 audit closed (atomic .ltr claim + instance.json already present); Q49 Bcc-history extension wired through store/service/jsonrpc/api-client/compose. (3) **Contacts incremental sync** — Google People API was previously startup-only with non-persisted in-memory token; rewired to use per-account `nextSyncToken` persisted in new `kv` table, `requestSyncToken=true` on first call, deletion handling via `metadata.deleted=true` → `deleteContactByGoogleId`, in-flight dedup, 15-min poll alongside calendar/tasks in bin/mailx.ts. Cheap incremental after first sync. Build verify still owed (same bash issue).
57
+
58
+
59
+
60
+ **Sections:** [Open questions](#questions-open) · [AI features](#ai) · [Summary](#summary) · [Glossary](#glossary) · [P0 Architecture](#priority-0) · [P1 Basics](#priority-1) · [P2 UX](#priority-2) · [P3 Polish](#priority-3) · [Near-term](#near-term) · [Done](#done-recent) · [Decided questions (archive)](#questions-decided) · [Not needed](#not-needed)
61
+
62
+ <a id="questions"></a>
63
+ <a id="questions-open"></a>
64
+
65
+ ## Open questions for Bob [↑ top](#top)
66
+
67
+ Answer any at `Q100`..`Q114` (AI-specific ones live in the [AI section](#ai) below, not here). Once a question is decided, it moves to the [Decided questions archive](#questions-decided) at the end of the file, and any corresponding implementation work lives in the normal priority tables or Done. Integers follow the "never reused, never renumbered" rule — Q-prefix here is the tag ("question"); existing `Q49/Q52/Q64/Q66/Q67` (quick-win tag) are distinct items by their integers.
68
+
69
+ | # | Question |
70
+ |---|---|
71
+ | **Q101** | **Full body text in the search index.** SQLite has a built-in full-text-search feature called FTS5 — a virtual table that indexes words across multiple text columns so queries are faster and fuzzy-friendly than `LIKE '%term%'`. mailx already uses it for subject/from/to/cc/preview. Question is whether to also index message **bodies** (would let search find text inside emails, not just headers + the first snippet). Adding bodies means a one-time rebuild that scans every cached `.eml`. Rough byte estimate per message: plain-text body after HTML-strip averages 1–5 KB on typical mail, up to ~50 KB for long threads; FTS5 stores tokens + positions at roughly 0.3–1× the source size. So ~500 B – 5 KB of index per message. For 50 K messages: 25–250 MB of index, on top of the existing headers-only index. Auto-build on first startup after the upgrade vs explicit `mailx -reindex`? |
72
+ | **Q109** | **Move folder drag-to-reparent.** Want full IMAP RENAME (server support varies, destructive if it fails mid-op), or stay-in-parent rename-only (safer, less power), or neither? |
73
+ | **Q110** | **Full menu bar.** Replace the current toolbar-dropdown model with a File / Edit / View / Message menu bar with keyboard shortcuts? Worth the UI churn, or is the dropdown enough? |
74
+ | **Q113** | **Per-message metadata (custom annotations, priority, categories).** JSON column on `messages` (simple, migrates with the row), vs separate `message_meta` table keyed by `uuid` (survives message re-imports and body comingling fixes better)? |
75
+
76
+ **Decided 2026-04-24** (moved to [archive](#questions-decided) — implementations tracked in backlog):
77
+ - **Q104** — alarms: Thunderbird/Outlook-style popup with snooze (variable period) + dismiss. Shared subsystem for mail/cal/tasks reminders.
78
+ - **Q112** — Android standalone: needs prefetch of its own, drains `sync_actions` directly via Gmail API (mirrored provider methods). All cross-device reconciliation goes through the servers, not desktop↔Android.
79
+ - **Q114** — full-screen calendar modal temporarily retired; sidebar is the single cal/tasks view.
80
+ - **Q111-AI** — remains in [AI section](#ai), confirmed as the right home.
81
+
82
+ <a id="ai"></a>
83
+
84
+ ## AI features [↑ top](#top)
85
+
86
+ Everything AI-shaped collected here so Bob can think about it on its own terms, apart from the mail-mechanics backlog. Current posture: nothing AI-adjacent ships automatically until Bob is comfortable with the failure modes. The existing AI ghost-text autocomplete (Ollama / Claude / OpenAI) and AI translate / AI proofread Settings toggles are opt-in and off by default.
87
+
88
+ ### AI — Questions
89
+
90
+ | # | Question |
91
+ |---|---|
92
+ | **Q103-AI** ❓ | **Rules / extensions engine shape (C39).** Parked 2026-04-23 while Bob experiments with imail. Revisit once imail findings settle. Candidate shapes when it's time: declarative JSONC rules (`if from: foo then move to bar`), TypeScript plugins under `~/.mailx/extensions/`, AI-classified categories (newsletter / priority / action-required), or a hybrid. |
93
+ | **Q111-AI** ❓ | **Writing assistance.** LanguageTool (API or self-hosted), custom AI via the existing ghost-text path (Ollama / Claude / OpenAI), in-WebView2 native spellcheck only (current), or something else? |
94
+ | **Q113-AI** ❓ | **AI-flavored per-message metadata.** The schema shape in Q113 above directly affects where AI-assigned priority / categories / summaries live. If Q113 lands on a separate `message_meta` table the AI tags have a natural home; if it's a JSON column on `messages`, the AI writer mutates the same row. |
95
+
96
+ ### AI — Backlog
97
+
98
+ | # | Status | Item |
99
+ |---|---|---|
100
+ | [**C39**](#ext39) ❓ | PARKED | Rules / extensions engine + AI classification. See Q103-AI. |
101
+ | [**P19**](#priority-daily) ❓ | OPEN | Screener — imail rules + AI classifier. Elevated under daily-driver; same classifier question as C39. |
102
+ | **AI-writing** ❓ | OPEN | Proofread / rewrite / tone-adjust in compose. Currently a Settings toggle that's off; needs the shape from Q111-AI before wiring. |
103
+ | **AI-extract-contact** | OPEN | Right-click an email → Add contact auto-fills Name / Organization / Phone from the letter body. "In the future" per user 2026-04-23. Depends on a working AI back-end choice from Q111-AI. |
104
+ | **AI-extract-calendar** | OPEN | Detect "let's meet Tuesday at 3" in a letter body and offer a one-click "Add to calendar" with the extracted datetime. |
105
+ | **AI-translate** | SHIPPED-OFF | Right-click in the body iframe → Translate. Setting-controlled, off by default. |
106
+ | **AI-proofread** | SHIPPED-OFF | Right-click in compose → Proofread. Setting-controlled, off by default. Needs back-end choice from Q111-AI before the action does anything useful. |
107
+ | **AI-ghost-text** | DONE | Autocomplete in compose via Ollama / Claude / OpenAI. Setting-controlled. |
108
+
109
+ ### AI — Why it's corralled here
110
+
111
+ Keeping AI items in their own section lets Bob (a) decide on the back-end shape once, for all AI consumers (Q111-AI) rather than per feature; (b) keep the rest of the mail-mechanics backlog moving without every task acquiring an "and what does AI do here?" subclause; (c) defer AI work behind imail experiments without tangling it with ready-to-ship items.
112
+
113
+ ---
114
+
115
+ Numbers are stable, globally unique IDs — cite them (`#3`, `#22`, `#51`) in conversation. Letters are category tags (S=showstopper, P=priority, C=categorized, Q=quick win); the number alone identifies the item. Numbers don't get reused and don't get renumbered — gaps appear where items were retired to Done, that's intentional. Detail sections use `ext<n>` as the anchor/heading label (e.g. `ext4` is the long-form writeup of S4). Status: **OPEN** · **PARTIAL** · **DIAG** (shipped diagnostics, awaiting user repro).
116
+
117
+ <a id="summary"></a>
118
+
119
+ ## Summary
120
+
121
+ **Within Summary:** [Priority](#priority-sum) · [Plan](#plan) · [Open questions](#open-questions) · [Daily-driver](#priority-daily) · [Categorized](#categorized) · [Concerns to watch](#concerns) · [Quick wins](#quick-wins) · [↑ top](#top)
122
+
123
+ <a id="sum"></a>
124
+ <a id="priority-sum"></a>
125
+
126
+ ### Priority
127
+
128
+ None of these are truly showstoppers — mailx is usable — but they're the next-priority items. Numbers keep their `S`-prefix for stable cross-reference (see [numbering rule](#top) in the header).
129
+
130
+ | # | Status | Item |
131
+ |---|---|---|
132
+ | [**S56**](#ext56) | OPEN | Row-objects own the preview pane — the last open slice of the old "S1 local-first refactor" umbrella. All S1 sub-shipments (tombstones, opaque UUIDs, stable row UUID, Message-ID move-detection, pink rows, viewer-reacts-to-list-replace, unified-inbox pink) are in Done under their version tags; this is what remains. ✓ (can start the `focus()`/`unfocus()` seam; full migration is more than one session) |
133
+ | [**S9**](#ext9) | PARTIAL | Predownloading gaps — 60s prefetch landed v1.0.321; per-account prefetch guard already in mailx-imap (`prefetchingAccounts` Set). Remaining: batch body prefetch (C24) for 10–50× speedup. |
134
+ | [**S51**](#ext51) ❓ | PARTIAL | Calendar Thunderbird-style sidebar. Sidebar UI + visible-by-default + 4-column grid reflow shipped v1.0.375/376. **Still owed**: Google Calendar live fetch via service-side proxy. Open Q: keep the full-screen calendar modal, or retire in favor of sidebar-only? |
135
+ | [**S57**](#ext57) | OPEN | Android: INBOX-first local render (don't block UI on serial label sync). |
136
+ | [**S58**](#ext58) | OPEN | Android: rail on narrow — surface Inbox / compose as primary action, not hidden behind broken hamburger. |
137
+ | [**S59**](#ext59) | OPEN | Android: hamburger no-op — CSS backdrop/transform residue on close. |
138
+ | [**S60**](#ext60) | OPEN | Android: keyboard → input lag (wa-sqlite writes on main thread). |
139
+ | [**S61**](#ext61) | OPEN | Android: Send leaves a second compose open / double-opens. |
140
+ | [**S62**](#ext62) | OPEN | Android: prefetch priority/speed — most-recent-first within INBOX before any label. |
141
+ | [**S63**](#ext63) ❓ | OPEN | Desktop compose pop-out — separate OS window, drag to another monitor. Blocked on msger popups (C28). Open Q: window mechanism. |
142
+ | [**S65**](#ext65) | OPEN | Unread badge visible when mailx isn't running. Blocked — needs tray process, Windows 11 Notification Center, or accepting badge-only-while-running. |
143
+ | [**S66**](#ext66) | **PARTIAL — needs build verify** | Google Tasks API 429 flood. **Root cause found**: `refreshTasks`/`refreshCalendarEvents` set `changed = true` unconditionally on every upsert, so every poll emitted `tasksUpdated`/`calendarUpdated`, which the UI listened to and re-called `getTasks`/`getCalendarEvents`, which fired another refresh — tight loop bound only by network RTT. Patches (uncommitted, awaiting tsc verify): (a) `refreshTasks` and `refreshCalendarEvents` skip no-op upserts via new `taskRowEquals` / `calendarRowEquals` helpers (etag + field comparison), so `changed` only flips when data actually differs; (b) new `quotaCooldown` Map per feature → cooldown until-ms; `getCalendarEvents`/`getTasks` short-circuit while cooldown is in effect; (c) new `refreshingCalendar` / `refreshingTasks` Maps dedup concurrent calls so the UI's `tasksUpdated` event can't restart a refresh while one's in flight; (d) `googleFetch` now throws typed `GoogleHttpError` (status code), so the unified `handleGoogleRefreshError` distinguishes 429 from 401/403; 429 → 1 hr cooldown + `quotaError` event (sticky-emit); (e) `calendar-sidebar.ts` listens for `quotaError` and renders "tasks unavailable" banner (idempotent, no flash). Files: `packages/mailx-service/google-sync.ts`, `packages/mailx-service/index.ts`, `client/components/calendar-sidebar.ts`. **Still owed**: `npm run build`, version bump, runtime test on bobma. Bash blocked tonight (session-env EEXIST), so build verification deferred. |
144
+
145
+ <a id="open-questions"></a>
146
+
147
+ <a id="plan"></a>
148
+
149
+ ### Plan
150
+
151
+ Short-form; the next few shippable slices, in the order I'd take them. Slices 1–9 shipped — see the Done table at the bottom of this file for version-tagged entries. Only open slices live in this table:
152
+
153
+ | # | Slice |
154
+ |---|---|
155
+ | 10 | **Full row-objects refactor** (Slice D proper, = [S56](#ext56)). ✓ (can start the focus()/unfocus() seam — full migration too big for one session) |
156
+ | 11 | **S51 slice 2 — Google Calendar fetch via service-side proxy** so the sidebar shows the user's actual events, not just local-only. ✓ (service method is one file; needs user to verify OAuth scope at runtime) |
157
+ | 12 ❓ | **P17 — shared alarm subsystem** for mail + calendar reminders. Open Q: shared vs per-feature-with-dispatcher. |
158
+ | 13 | **Android narrow-tier layout** — hamburger that opens folders, rail collapse that surfaces folder navigation, very-narrow density tier. (Compose full-screen already handled by `isSmall` check in `showComposeOverlay`.) ✓ (CSS-only fixes; native Android debug requires APK rebuild) |
159
+
160
+ ### Open questions
161
+
162
+ Items blocked on a design decision. Short-form question here; full context in the linked detail section and/or the Priority table above.
163
+
164
+ | # | Question |
165
+ |---|---|
166
+ | [**S51**](#ext51) | Calendar surface: full-modal (current) vs. Thunderbird-style right docked sidebar vs. both-via-toggle? |
167
+ | [**S52**](#ext52) | One `primary: boolean` per account vs. per-feature flags (`primaryCalendar`, `primaryTasks`, `primaryContacts`)? |
168
+ | [**S4**](#ext4) | Dally TTL; interaction with server-side EXPUNGE; does Discard share the dally buffer with Delete? (Formerly S4+S5, merged — same mechanism.) |
169
+ | [**C27**](#ext27) | Unified outbox multi-device claim protocol — IMAP flag-based vs. a `$Sending` keyword with heartbeat timestamps? |
170
+ | [**C25**](#ext25) | Gmail label-native model — drop synthesized UIDs entirely (deeper) vs. retain for back-compat (leakier)? |
171
+ | [**P17**](#ext17) | Popup reminders: one alarm subsystem shared by mail/calendar/tasks, or per-feature with a thin dispatcher? |
172
+
173
+ <a id="priority-daily"></a>
174
+
175
+ ### Priority — daily-driver parity
176
+
177
+ | # | Status | Item |
178
+ |---|---|---|
179
+ | **P13** | PARTIAL | Multi-window / additional view — floating-overlay popout via double-click (Q64 v1.0.344); real OS processes need msger custom-protocol (C28). **Blocked on msger.** |
180
+ | **P16** | PARTIAL | Calendar/Tasks sidebar (elevated) — local-only Calendar + Tasks panes shipped v1.0.341 via rail buttons; Google Calendar + Google Tasks sync pending. ✓ (sidebar-now-default shipped v1.0.375; live fetch needs service proxy + runtime test) |
181
+ | **P17** ❓ | OPEN | Popup reminders — shared alarm subsystem for mail / calendar / tasks. Open Q: shared vs per-feature-with-thin-dispatcher. |
182
+ | **P18** | PARTIAL | Inline thread expansion — thread_id + popup done; inline-tree UI owed. ✓ (inline tree in message-list — moderate but self-contained) |
183
+ | **P19** ❓ | OPEN | Screener — imail rules + AI classifier. **Moved to [AI section](#ai)** (Q103-AI covers the classifier design). |
184
+ | [**P115**](#ext115) | OPEN | Register mailx as Windows default `mailto:` handler — click any mailto link → mailx compose opens. Registry-only, no MSIX. ✓ (single session: registry write + `--mailto` CLI + Settings toggle) |
185
+
186
+ <a id="categorized"></a>
187
+
188
+ ### Categorized / infrastructure
189
+
190
+ | # | Status | Item |
191
+ |---|---|---|
192
+ | **C23** | PARTIAL | Outlook Graph API driver — full wiring — `docs/azure.md` has the registration recipe; dispatcher branch + provider methods owed. **Blocked on Azure app registration.** |
193
+ | **C24** | OPEN | Batch body prefetch (10–50× speedup) — one UID FETCH per folder instead of N. ✓ (IMAP side per design; Gmail batch needs runtime verification) |
194
+ | **C25** ❓ | OPEN | Gmail label-native model — drop hash-UID, store provider_id, labels as tags. Open Q: drop synthesized UIDs entirely vs retain for back-compat. |
195
+ | **C26** | PARTIAL | Send-pending virtual folder — pink-row list modal via `status-queue` click landed v1.0.344; full virtual-folder-in-tree + pre-append-to-IMAP listing still owed. ✓ (virtual folder entry in tree is small) |
196
+ | **C28** | OPEN | Popup windows "Cannot GET /index.html" — msger custom protocol not inherited by popups. **Blocked on msger change.** ✓ (mailx-side workaround: route via openExternal) |
197
+ | **C31** | OPEN | Live reload on config changes — swap account configs in-place. |
198
+ | **C32** | EXTENDED — needs build verify | mailx on Linux schema migration crash — workaround: `mailx -rebuild`. Loud-crash-early was already shipped for `messages` columns (v1.0.376); tonight extended `verifySchema()` to also cover `calendar_events.recurring_event_id` + `calendar_events.html_link` so Linux deploys with stale store hit a clear error before any sync runs. Linux deploy fix still needs user. |
199
+ | **C33** | PARTIAL | Dovecot-style responsive layout — rail + wide/medium/narrow done; very-narrow tier + rail badges owed. ✓ (very-narrow tier CSS + rail badges) |
200
+ | **C34** | OPEN | Restore `--server` mode + login. ✓ (flag re-wire; needs runtime test) |
201
+ | **C37** | OPEN | Email completion ranking. ✓ (ranking formula + sort) |
202
+ | **C38** | PARTIAL | Auto-reconnect on dead socket — `getOpsClient` now pre-checks liveness v1.0.344; stale-socket catches in `withConnection` already existed; watch for any remaining "Not connected" repros. |
203
+ | **C39** ❓ | OPEN | Rules / extensions engine + AI classification. **Moved to [AI section](#ai)** (Q103-AI). |
204
+ | **C40** | OPEN | Full context menus — link menu landed as C29 v1.0.344; full-message / per-field menus still partial. ✓ (per-field: message body, search selection) |
205
+ | **C41** | PARTIAL | Reply quoting — already wraps in `<blockquote>`; read-only + non-editable-above-the-line still owed (needs Quill module work). |
206
+ | **C43** | OPEN | Message list column controls + threading flags. ✓ (sort-by-column-click is small) |
207
+ | **C44** | PARTIAL | Real OS popup windows / pop-out — floating-overlay popout landed (Q64 v1.0.344); real OS processes still blocked on msger custom-protocol fix (C28). **Blocked on msger.** |
208
+ | [**C46**](#ext46) | PARTIAL | Mailto handler split out as [P115](#ext115) (registry-only, not blocked). Remaining (Share target, tray, notifications, MSIX) still blocked on MSIX packaging. |
209
+
210
+ <a id="concerns"></a>
211
+
212
+ ### Concerns to watch
213
+
214
+ Previously shown as showstoppers; moved here because they haven't recurred on recent builds. Monitor next repro.
215
+
216
+ | # | Status | Item |
217
+ |---|---|---|
218
+ | [**S49**](#ext49) | FIXED v1.0.361 | Body comingling — root cause eliminated: disk filenames are now opaque UUIDs, never reused. |
219
+ | [**S50**](#ext50) | DIAG v1.0.338 | `accounts.jsonc` banner firing repeatedly — quiet on recent builds. |
220
+ | [**S7**](#ext7) | LIKELY MOOT | IMAP delete "neither trash nor deleted" — user says probably resolved by recent fixes; demoted from Priority to monitor. |
221
+
222
+ <a id="quick-wins"></a>
223
+
224
+ ### Quick wins — do autonomously when idle
225
+
226
+ Small, self-contained items. Pick them up between higher-priority blocks without asking. Bump version per fix.
227
+
228
+ - **Verify rail-view / rail-settings dropdowns in narrow mode (paused 2026-04-30).** Added `rail-view` icon (👁) and `openMenuFromRail()` in `client/app.ts` that reparents the toolbar dropdown to `<body>` and switches to `position:fixed` anchored to the rail icon — so the toolbar's `display:none` in narrow mode no longer hides the menu. Compile not yet re-verified after the edits because Bash kept wedging; user paused to start the monorepo reorg (mailx → mailxapp). When resuming: run `tsc` in `client/`, sanity-check that opening View / Settings from the rail in narrow mode shows the dropdown anchored next to the icon, and that toolbar buttons in wide mode still work (they just toggle `hidden` on the same dropdown DOM, which is now a body child but still works).
229
+
230
+ ---
231
+
232
+ **Detail sections use `ext<n>` labels** — e.g. the long-form writeup of S4 is anchored as `ext4`. Summary tables above stay one-line; anything longer goes in the `ext<n>` section in Priority 0 / 1 / 2 / 3 below.
233
+
234
+ <a id="glossary"></a>
235
+
236
+ ## Glossary — design vocabulary [↑ top](#top)
237
+
238
+ Design-time terms used throughout this doc. Kept here (rather than README) because they evolve with the design and the TODO is where we argue about them.
239
+
240
+ - **Rail** — narrow vertical strip on the far left with icon buttons (compose, inbox, calendar, tasks, settings, theme, help, logout). Material Design's term. Dovecot/Roundcube call it "taskbar," VS Code calls it "activity bar"; we use "rail" because it's short and unambiguous.
241
+ - **Folder list** (aka **folder tree**) — the list of IMAP folders (or Gmail labels presented as folders) that sits between the rail and the message list in wide mode.
242
+ - **Folder icon** — a small 📁-like glyph in the header that toggles the folder list as an overlay in medium/narrow tiers.
243
+ - **Hamburger** — the ≡ menu button that replaces the rail in narrow tier and pops a slide-in sidebar.
244
+ - **Message list** — the middle column showing envelope rows (from, subject, date, downloaded-indicator).
245
+ - **Viewer** (aka **message viewer**, **preview**) — the pane that shows a selected message's body. "Preview" is the reading-pane style; "viewer" is the mailx code's term.
246
+ - **Wide / medium / narrow** — responsive layout tiers. Defined by width alone, platform-independent. See "Dovecot-style responsive layout" TODO for the current breakpoints (1200 / 768).
247
+ - **Provider** — abstraction over how a mail account talks to its server. `GmailApiProvider` (REST), `ImapProvider` (IMAP via iflow-direct), `OutlookApiProvider` (Graph API, skeleton). The sync code calls provider methods so the back-end can swap.
248
+ - **UID** — IMAP's per-folder message identifier (integer, monotonic within a folder). For Gmail accounts we currently synthesize a UID by hashing the Gmail message ID's hex — see "Gmail label-native model" TODO for why this is a design limitation.
249
+ - **Label** — Gmail's native tag-on-message concept. Many-to-many with messages. Not a folder, though mailx currently pretends it is.
250
+ - **Outbox** — the queue of messages waiting for SMTP delivery. Today it's a mix of `~/.mailx/outbox/<acct>/*.ltr` files (local fallback) and the server-side `Outbox` IMAP folder. The "Unified send / outbox / multi-device design" TODO proposes collapsing these.
251
+ - **Drafts / Sent / Trash / Junk** — server-side IMAP folders with RFC 6154 SPECIAL-USE flags. mailx auto-detects by flag first, leaf-name second.
252
+ - **IDLE** — IMAP RFC 2177 push-notification command. mailx keeps a per-account IDLE connection for the inbox so new-mail events arrive in real time.
253
+ - **Prefetch** — background body download so messages are readable offline. See the batch-prefetch TODO for the current implementation story.
254
+ - **Rail icon badges** — (proposed) unread counts on inbox / calendar / tasks icons in the rail, visible even when those views aren't active.
255
+ - **Local-first** — the local store is the source of truth for what the user sees and does; the server is a secondary, asynchronously-reconciled mirror. UI reads come from the local DB; UI writes ACK from local commits and queue the server push for a background reconciler. This is the architectural rule — see "CRITICAL: Local-First" in CLAUDE.md and the Priority 0 reconciliation refactor item below for what it requires. (Earlier framing as "thick client over IMAP, server as source of truth" is retired — it described the original implementation, not the target design.)
256
+ - **Transport** — the TCP byte-stream abstraction used by iflow-direct (IMAP) and smtp-direct (SMTP). Implementations: `NodeTcpTransport` (node:net/tls), `BridgeTcpTransport` (msgapi.tcp in WebView). Lives in `@bobfrankston/tcp-transport`.
257
+ - **msgapi** — the shell-provided API surface (tcp, udp, fs, etc.) exposed by msger-family WebView hosts. Distinct from **mailxapi**, which is mailx's app-specific JSON-RPC bridge injected via msger's initScript.
258
+
259
+ <a id="priority-0"></a>
260
+
261
+ ## Priority 0 — Independence & Architecture [↑ top](#top)
262
+
263
+ <a id="ext1"></a><a id="ext2"></a><a id="ext3"></a><a id="ext9"></a>
264
+
265
+ ### S1 / S2 / S3 / S9 — Local-first reconciliation refactor (shared root cause) [↑ summary](#sum)
266
+
267
+ **Status as of v1.0.334:**
268
+ - ✅ **saveDraft** — local-first ACK. Writes `sending/<acct>/editing/<ts>.eml` + DB row + returns draftId synchronously. IMAP APPEND is fire-and-forget. (v1.0.316)
269
+ - ✅ **send: GDrive stall removed.** `service.send()` no longer calls `loadSettings()` on every send — accounts cached in `_accountsCache`, invalidated by `configChanged`. A stalled `G:\My Drive\…\accounts.jsonc` read no longer parks the send for 120s. (v1.0.330)
270
+ - ✅ **send: contact recording off critical path.** `recordSentAddress` loop moved inside `setImmediate` so the IPC ACK fires as soon as `queueOutgoingLocal` commits to the DB. (v1.0.330)
271
+ - ✅ **Compose populate fast** — reads editor type from localStorage, populates from `composeInit` sessionStorage immediately. (v1.0.315)
272
+ - ✅ **Prefetch** — periodic 60s background task, decoupled from sync success. (2026-04-21)
273
+ - ⚠️ **Remaining inline work in send():** recipient validation (fast, regex), MIME assembly (fast for text), **attachment base64 + wrap76** (can take seconds for MB-scale attachments — still on the critical path). `queueOutgoingLocal` itself is a sync DB write that kicks off background `processSendActions.catch(noop)`.
274
+ - ⚠️ **Reply-opens-blank alert band-aid** still in `app.ts:openCompose`. Unconditional populate from local DB not yet done.
275
+ - ⚠️ **Row-objects-own-preview** refactor not done — still two parallel paths in `message-list.ts:544-545`.
276
+ - ⚠️ **UUID / tombstones / third-party-move detection** design below — not yet implemented.
277
+ - ⚠️ **Visible reconciliation state (pink rows)** — rows that are local-only and not yet reconciled with the server render in a distinct color (pink proposed). Same idea extends to drafts queued for APPEND, local moves awaiting server replay, local deletes awaiting EXPUNGE. Today the "queued" state is invisible — user hits Send, toast fires, row disappears; if IMAP fails silently the user has no clue. Pink keeps the row on screen and flagged until the server acknowledges. This is load-bearing for user trust in the local-first model — not a cosmetic add-on. "You don't want to live in fear of whether it went." — user 2026-04-20.
278
+
279
+ **Test for "send works reliably":** compose a small text message → Send → toast/close should be instant (no 120s wait). Large attachment → still may take seconds (attachment base64 assembly), but not 120s.
280
+
281
+ **Original design notes (the full refactor that ties all this together):**
282
+
283
+ - [ ] **Local-first reconciliation refactor (load-bearing — multiple bugs are symptoms)** — Current code violates the local-first / cache-as-source-of-truth design in several user-facing paths by making IPC calls block on synchronous IMAP ops. Symptoms collected so far: `saveDraft` 30/120s timeout (mailx-imap `saveDraft` at index.ts:2125+ does open-client → delete-prev-UID → IMAP UID SEARCH for orphans → delete each → IMAP APPEND, all serialized); `sendMessage` 120s timeout (similar — local queue write IS local but earlier steps in service.send aren't); Reply/Forward bailing with "still loading" alerts because they were treated as needing fresh server data when the message is already in the local DB; prefetch only firing on successful sync; list/preview can be out of sync indefinitely. The design:
284
+ - **All UI actions ACK immediately from local writes.** saveDraft = write `.eml` to local Drafts dir + DB row + emit `_action: pushDraft` into a sync queue. Return local UUID synchronously. send = write `.ltr` to local Outbox dir + DB row + queue. Reply = read-only from local DB, never blocks on server.
285
+ - **Sync queue is the only IMAP-touching path.** A single background worker drains the queue with per-action retry/backoff. UI gets status events.
286
+ - **Stable local UUIDs separate from server UID.** Each message gets a UUID at first sight; sync maintains a UUID↔(folder, server-UID) mapping per account. A server-side move/copy that changes the UID re-binds the UUID; user-driven local moves keep the UUID and queue a server-side move.
287
+ - **Detect third-party moves by Message-ID; never re-fetch their bodies.** When sync sees a new UID in folder B whose Message-ID matches an existing UUID elsewhere AND that UID just disappeared from the source folder → re-bind the UUID to (folder=B, UID=new), keep the body file. Falling back to delete+create costs a body re-fetch per moved message — looks like prefetch is broken. UIDVALIDITY change → broaden the Message-ID search since UIDs become meaningless. (delete/create is OK for now until refactor lands.)
288
+ - **Tombstones for deletes.** Deleted = row stays as tombstone, UUID retained, body file unlinked. Suppresses re-fetch from server until age-out (~30 days). Moves do not create tombstones — the UUID rebinds.
289
+ - **Reconciliation is two-way.** Server changes pulled via sync; local changes pushed via the queue; conflicts detected by comparing UID + ETag/internaldate.
290
+ - **Each list row is an object that owns its preview.** Currently `message-list.ts:544-545` fires both `state.select(msg)` AND a separate `onMessageSelect(...)` callback ending in `showMessage(...)` — two parallel paths, two race opportunities. User-reported: "list shows Quora row highlighted, preview shows Re:Update" (`screenshots/Screenshot 2026-04-20 214724.png`). Each row should be an object with `focus()` / `unfocus()` methods that atomically manage the preview pane — `unfocus()` clears the preview as part of itself, `focus()` renders it from the row's own local-DB-backed copy. The `gen` token cancellation in `showMessage()` is a band-aid that should disappear with the row-object refactor.
291
+
292
+ - [ ] **Send IPC timing out at 120s (symptom of refactor above)** — band-aid in `client/compose/compose.ts:482-516` (friendlier "probably queued, check Outbox before re-clicking" message on `mailxapi timeout: sendMessage`); the real fix is `service.send()` acks as soon as the local write completes. Same pattern for `saveDraft` 30s timeout. Suspects for what's hanging in current code: (1) `recordSentAddresses` iterating a huge contact DB at `service/index.ts:474-476`, (2) base64 encoding of large attachments, (3) another service call serializing.
293
+
294
+ - [ ] **Reply opens blank / "alert and bail" band-aid (symptom of refactor above)** — `client/app.ts:openCompose()` now alerts when `getCurrentMessage()` returns null or a stub. Local-first principle: if the message rendered, it's in the local DB; Reply should populate unconditionally from local DB (To/From/Subject from headers always present; body from `body_path` or "[body not yet downloaded]" placeholder). Revert the alert once refactor lands.
295
+
296
+ <a id="ext4"></a><a id="ext5"></a>
297
+
298
+ ### S4 — Dally mechanism (RETIRED 2026-04-23) [↑ summary](#sum)
299
+
300
+ Dropped for now. The Ctrl+Z undo window for delete and the Save/Discard/Cancel prompt for compose close cover the majority of "wait I didn't mean that" recovery cases. Revisit if a specific loss pattern surfaces. Historical description preserved in earlier versions of this file.
301
+
302
+ <a id="ext10"></a>
303
+ - [ ] **Paste-link-with-text double-inserts** — pasting a clipboard item that has BOTH selected text and a URL results in the URL appearing twice in the compose editor: once as literal text, once as the href of a link wrapping (or positioned behind) that same text. Likely the paste-URL-auto-link handler conflicts with Quill's built-in HTML paste flow. Repro: copy a link with anchor text from a browser/Word, paste into compose.
304
+
305
+ <a id="ext7"></a>
306
+
307
+ ### S7 — IMAP delete "neither trash nor deleted" (DIAG v1.0.333) [↑ summary](#sum)
308
+
309
+ `trashMessage` now logs the resolved target folder on every delete: `[trash] <accountId> UID <n>: queued MOVE to "Deleted Items" (id=84, specialUse=trash)`. `processSyncActions` no longer silently skips when `fetchMessageByUid` returns null — logs loud and drops the action instead of looping.
310
+
311
+ Most likely cause based on the bobma folder layout: Dovecot designates "Deleted Items" (id=84) as \Trash, while "Trash" (id=99) is a separate regular folder. If the user clicks on "Trash" expecting deleted messages, they'll find it empty — the messages are in "Deleted Items". The new log line confirms which folder each delete went to. Once confirmed, the fix is either (a) UI: hide the non-\Trash "Trash" folder or relabel it, or (b) account config: rename "Deleted Items" to "Trash" on the server side. Awaiting user repro with the new logs.
312
+
313
+ - [ ] **Detect elevated run + warn** — mailx running as Administrator can poison `%LOCALAPPDATA%\msger\webview2\` (see `y:/dev/utils/msgx/msger/notes.md` WebView2 profile playbook) and create admin-owned files under `~/.mailx/` that later non-admin runs can't write to. Detect at `bin/mailx.ts` entry (e.g. `child_process.execSync('net session', { stdio: 'ignore' })` throws on non-admin, succeeds on admin). If elevated and `--allow-elevated` not passed, show prominent warning dialog via `showMessageBox` with two buttons — "Quit" (default) / "Continue anyway" — and exit on Quit. No de-elevate-mid-process.
314
+
315
+ - [ ] **Concurrent prefetch sessions racing** — log shows ~11 `[prefetch] gmail: X bodies cached (done)` lines in the same 15-millisecond window, each with a different count (3543, 2665, 1872, ...). Multiple `prefetchBodies` runs are firing simultaneously for the same account because `startOutboxWorker`/periodic-sync kicks off a new one each tick without checking if one is still in flight. Each concurrent session double-fetches messages, races on disk writes (EBUSY earlier), and blurs progress reporting. Fix: add a per-account `prefetchingAccounts: Set<string>` guard (mirror the `sendingAccounts` pattern at `index.ts:1832`) so only one prefetch runs per account at a time. Next request piles up as a "run again after current finishes" flag.
316
+
317
+ - [ ] **Gmail empty-raw body diagnostics** — users see "Message body not cached locally and the server fetch returned nothing" for multiple messages. On-demand `fetchMessageBodyViaApi` gets `msg.source === ""` and returns null silently. Confirmed causes to distinguish:
318
+ - Gmail message larger than format=raw cap (~10MB Gmail HTTP limit).
319
+ - UID hash collision: `idToUid` is the lower 48 bits of the hex Gmail ID. Two messages whose last-12-hex matches collide — `listMessageIds` returns both but `ids.find(id => idToUid(id) === uid)` picks the first. Fetched message exists but isn't the one the user clicked.
320
+ - listMessageIds truncated at 1000 and the target UID isn't in the top 1000 of the folder (old messages).
321
+ First pass: better logging (done in v1.0.257) — now tells us which case. Proper fix: drop the UID-hash translation entirely and store Gmail IDs natively (tracked under "Gmail label-native model").
322
+
323
+ - [ ] **Extract `mailx-sync` package — code-sharing between desktop service and Android Worker** — Today `packages/mailx-imap/` (Node) and `packages/mailx-store-web/` (browser) have parallel implementations of the same logic: Gmail provider, sync orchestration, prefetch loops, parsers. Every fix has to be written twice (current example: today's Gmail 403-quota retry, prefetch concurrency guard, false-prune bug — each lives in two places). Android is moving fetch to a Web Worker for UI-thread reasons; desktop already gets that property for free via the Node-service-vs-WebView process split. So Workers don't bring desktop new benefit — but they do open the door to actual code-sharing.
324
+
325
+ **Design:** new `packages/mailx-sync/` containing platform-agnostic logic only:
326
+ - All `providers/*.ts` (Gmail API, Outlook API, IMAP) — one copy.
327
+ - Sync orchestration (`syncFolderViaApi`, `prefetchBodies`, periodic loops, batch grouping, dedup).
328
+ - Pure parsers (RFC 2822, MIME, address lists, MIME assembly for compose).
329
+ - Outbox claim logic (the unified design above).
330
+
331
+ Dependencies: only the protocol packages (`iflow-direct`, `smtp-direct`) + `tcp-transport` interface + a small **storage interface** + a **fetch adapter** (both fetch and Storage are standard in Node 18+ and browsers — no shim needed for most paths).
332
+
333
+ **Two consumers** instantiate it with different I/O:
334
+ - Desktop service (`mailx-imap` becomes a thin shim): `mailx-sync` + NodeTcpTransport + node:sqlite + Node fs.
335
+ - Android Worker (`mailx-store-web` becomes a thin shim): `mailx-sync` + BridgeTcpTransport + sql.js + IndexedDB.
336
+
337
+ **Effort:** 1–2 days, mostly mechanical (move files, swap imports, add a Storage interface). High ROI — every future fix goes in once.
338
+
339
+ **Non-goal:** moving desktop *to* Workers. Desktop's Node-service-process model already provides the UI-isolation Workers give Android. Workers on desktop would add complexity without benefit. The shared code just runs in different host environments.
340
+
341
+ - [ ] **Right-click on links → Open / Save with per-file-type default** — Today links go straight to the system browser via `mailxapi.openExternal` / `linkClick` postMessage. Should offer a context menu instead: **Open** (browser), **Save As...** (download to disk), **Copy URL**. After picking Open/Save the first time for a given file extension (or MIME type), offer "Always do this for `.pdf`" / "Always do this for `application/zip`". Stored in **global settings** (preferences.jsonc on cloud) so the choice follows the user across machines, with a **per-system shadow** in the local config for overrides where the per-machine app association differs (e.g. PDF reader exists on desktop, not on phone). Same pattern as the settings-cascade in CLAUDE.md (global → account → device). Useful for: attachments, links to .pdf/.zip/.docx, mailing-list archive links that download files. UX: Thunderbird's Helpers tab is the reference. mailx context menu already exists for messages — extend it to body-iframe links.
342
+
343
+ - [ ] **Popup windows fail with "Cannot GET /index.html"** — When the user does WebView2's "Open link in new window" (native context menu), or any path that creates a popup via `window.open`, the popup loads `msger.localhost/index.html#` and gets a Cannot-GET error. Cause: msger registers its custom-protocol handler on the primary WebView only; popup WebViews don't inherit it, so requests for `/index.html` 404. Two fix paths:
344
+ 1. **msger fix (proper)** — `msger-native/src/main.rs` should register the custom-protocol handler on popup WebViews too. Wry's `WebViewBuilder` has `with_new_window_req_handler`; the popup's builder needs the same `with_custom_protocol` call.
345
+ 2. **mailx workaround** — intercept right-click "Open in new window" and middle-click on links in the email-body iframe, route through `mailxapi.openExternal`/`linkClick` postMessage so the system browser handles them. Skip the popup creation entirely.
346
+ Recommend (1) since it fixes the underlying broken assumption (popups can't render content). Track in msger's TODO too.
347
+
348
+ - [ ] **Send-pending folder — always-visible message during the SMTP→Sent gap** — Today's send flow: user hits Send → message appears in Outbox → SMTP submits → DELETE from Outbox → APPEND to Sent → sync eventually surfaces in Sent UI. Between the DELETE and the APPEND-arrives-back-via-sync the message is **invisible in mailx** even though it was sent successfully. If the APPEND fails (auth blip, connection dropped, Sent-folder not detected), it's invisible forever. User quote: "I don't want to live in fear of whether it went."
349
+
350
+ **Design:** keep a **Sending** virtual folder (under Outbox or as a sibling in the folder tree) that mirrors the local `~/.mailx/sending/sent/` debug directory. Lifecycle:
351
+ 1. Message in **Outbox** (local or IMAP folder) — waiting for SMTP.
352
+ 2. SMTP succeeds → move to **Sending** (local disk + `sent_pending` DB row with Message-ID + sent-at timestamp + account).
353
+ 3. APPEND to server's Sent folder (best-effort; if it fails we still have the Sending entry).
354
+ 4. Sync sees a message land in server Sent with matching Message-ID → **match + clear** the Sending row.
355
+ 5. UI transitions: Outbox → Sending → Sent. Every step has a visible message.
356
+
357
+ **DB addition:** `sent_pending` table (message_id, account_id, subject, recipients, raw_path, sent_at). Tiny.
358
+
359
+ **Match rules:**
360
+ - Primary: Message-ID from the RFC 2822 `Message-ID:` header (already generated at compose time).
361
+ - Fallback: (from, to, subject, sent_at±N minutes) fuzzy match.
362
+ - Expiry: rows older than 30 days auto-cleared; assume the match was missed (Gmail users with auto-delete-sent, etc.).
363
+
364
+ **UI:**
365
+ - "Sending" shows under Outbox in the folder tree (synthesized, not a real IMAP folder — sourced from `sent_pending`).
366
+ - Rows look like real messages (envelope from the saved raw).
367
+ - Clicking a Sending row renders normally; note "Sent at HH:MM, waiting for server confirmation" in the header.
368
+ - When matched, animate a transition to Sent (optional polish).
369
+
370
+ **Notes:**
371
+ - Same principle applies to Gmail (where users see similar uncertainty when Gmail's UI is busy syncing), but we have less control there. For our own UI, we can make the invariant absolute: **every message is visible somewhere, always.**
372
+ - Reconciliation on startup: scan `sent_pending`, for any row older than 2 minutes, check server Sent for matching Message-ID and clear if present.
373
+
374
+ Files: new `sent_pending` table in `mailx-store`, new virtual-folder source in `mailx-service`, UI hook in `client/components/folder-tree.ts` and `client/components/message-list.ts`, sync-side matcher in `mailx-imap`.
375
+
376
+ - [ ] **Gmail label-native model** — Today mailx treats each Gmail label as a folder and maps an IMAP-style (folderId, uid) tuple onto it. Gmail's actual model: messages are a flat space with N labels each. Consequences of the translation layer:
377
+ - **Duplicate body fetches** — a message with 3 labels appears under 3 "folders" in the DB and the prefetch loop downloads it 3 times.
378
+ - **UID is a hash** of the Gmail message ID (lower 48 bits of the hex), so UID collisions are possible and the UID isn't derivable in reverse — we always have to listMessageIds + scan to turn a UID back into a Gmail ID.
379
+ - **Moves/labels are a UI lie** — "moving" a Gmail message between folders is actually adding/removing labels, but the folder metaphor makes this confusing to reason about.
380
+ - **Threading is broken** for cross-label threads — each label has its own folder_id and the thread spans them opaquely.
381
+
382
+ Proper fix (large, discrete project):
383
+ 1. Store Gmail's internal message ID as a first-class column (`gmail_id` or generic `provider_id`) — not a UID hash. Drop the hash UID for Gmail accounts.
384
+ 2. Model labels as *tags* on messages (many-to-many table `message_labels`) rather than folders.
385
+ 3. UI changes: the "folder tree" for Gmail becomes a label list with chips; sidebar still shows inbox/sent/drafts as synthesized views.
386
+ 4. Sync path uses Gmail's native `history.list` + `labelIds` diffs — see separate "Gmail History API" TODO.
387
+ 5. Batch prefetch then dedupes on `gmail_id`, skipping already-cached bodies regardless of which labels they have.
388
+
389
+ Non-Gmail IMAP accounts keep the folder model. Abstraction is per-provider: IMAP providers report folders, Gmail reports labels.
390
+
391
+ - [ ] **PRIORITY: Batch body prefetch (desktop + Android)** — today's `prefetchBodies` does one SELECT + one FETCH per message; on a 20k-message Dovecot account that's 20,000 round-trips. IMAP lets `UID FETCH` take a comma-list or range in a single command on a single connection; Gmail has HTTP batching at `POST /batch` up to 100 sub-requests per call. Rough 10–50× speedup. Design:
392
+ - Group the `getMessagesWithoutBody` result by folder.
393
+ - Per folder: one `SELECT`, one `UID FETCH uid1,uid2,…,uid100 (BODY[])`, then stream each returned message to disk as it arrives (iflow-direct's parser already handles multi-message FETCH responses).
394
+ - After each body lands: `updateBodyPath` + emit `folderCountsChanged` (incremental teal-dot fill — the batch-emit we just wired).
395
+ - Gmail API path: switch from N× `messages.get` to one multipart POST to `https://www.googleapis.com/batch/gmail/v1` with up to 100 sub-requests; parse multipart response; write each body to disk.
396
+ - Chunk size: 100 messages per batch (conservative — IMAP servers vary, Gmail's batch cap is 100).
397
+ - Same code path both platforms: desktop via NodeTcpTransport, Android via BridgeTcpTransport. No worker-thread needed — Node/WebView I/O is already non-blocking, and the existing prefetch loop doesn't stall the UI. The perceived sluggishness is purely wall-clock latency, which batching collapses.
398
+ - Non-goal: full parallelism. Gmail rate-limits (HTTP 429) and Dovecot connection caps mean one batched connection is better than N parallel unbatched ones.
399
+ - Files touched: `packages/mailx-imap/index.ts` (`prefetchBodies`, new `batchFetchFolder`), `packages/mailx-imap/providers/gmail-api.ts` (add `batchFetch`), corresponding web-side `packages/mailx-store-web/gmail-api-web.ts`. iflow-direct already supports multi-UID FETCH via the `UID FETCH` command builder.
400
+ - Prerequisite work on the protocol layer is tracked in `Y:\dev\email\MailApps\iflow-direct\TODO.md` under "Priority — Prefetch throughput" (streaming per-message callback, folder-scoped fetch helper).
401
+
402
+ - [x] **Passwordless install** — done via `npmglobalize`. New machines run `mailx` after install with no npm login or password prompts; OAuth handles per-account auth at first run.
403
+
404
+ <a id="ext46"></a>
405
+
406
+ ### C46 — Windows OS integration (PARTIAL) [↑ summary](#sum)
407
+
408
+ - [ ] **mailto: handler** → see [P115](#ext115) — split out as standalone priority item, NOT blocked on MSIX (registry-only).
409
+ - [ ] **Windows Share target** — register as Share target (requires MSIX packaging)
410
+ - [ ] Native right-click context menus, system tray, file associations, notifications
411
+
412
+ <a id="ext115"></a>
413
+
414
+ ### P115 — Default mailto handler (Windows registry) [↑ summary](#sum)
415
+
416
+ Make mailx the OS-level handler for `mailto:` URLs so links in browsers, Word, Outlook calendar invites, etc. open mailx compose instead of whatever Windows currently has wired up (Outlook, or "no default" → nothing happens).
417
+
418
+ **Not blocked on MSIX.** Windows mailto handling is purely registry-based via `HKCU\SOFTWARE\Classes` — same mechanism Thunderbird uses. MSIX is only required for Share target, push notifications, and the system tray; those stay parked under [C46](#ext46).
419
+
420
+ **Owed work**:
421
+ 1. **CLI**: `bin/mailx.ts` — parse `--mailto <url>`. Decode `mailto:` per RFC 6068 (to, cc, bcc, subject, body, in-reply-to). If a daemon is already running, forward to its compose IPC and exit; otherwise launch the daemon and queue the compose for after-startup.
422
+ 2. **Compose IPC**: `mailx-service` already has compose actions; add `composeFromMailto({ to, cc, bcc, subject, body, inReplyTo })` that the CLI hands off to. The compose UI's `init` shape already supports these fields (used by Reply / forward).
423
+ 3. **Registry registration**: new `bin/mailx.ts -register-mailto` (and `-unregister-mailto`) that writes:
424
+ - `HKCU\SOFTWARE\Classes\mailx\shell\open\command` → `"<mailx.exe>" --mailto "%1"`
425
+ - `HKCU\SOFTWARE\Classes\mailto\shell\open\command` (only if user opts in to taking the protocol)
426
+ - `HKCU\SOFTWARE\Clients\Mail\mailx\Capabilities\URLAssociations` → `mailto=mailx` (so it appears in Settings → Default apps → Email)
427
+ - `HKCU\SOFTWARE\RegisteredApplications` → `mailx=SOFTWARE\Clients\Mail\mailx\Capabilities`
428
+ 4. **Settings UI**: a "Default mail handler" toggle that calls register/unregister. Detect current default by reading `HKCU\SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\mailto\UserChoice\ProgId`; show "Default ✓" / "Make default" accordingly. Note: Windows 10+ won't let an app *take* the default silently — best we can do is register the capability and tell the user to confirm in Settings → Default apps once.
429
+ 5. **Cross-platform note**: Linux uses `xdg-mime default mailx.desktop x-scheme-handler/mailto`. macOS uses `LSSetDefaultHandlerForURLScheme`. Both are out of scope for this slice — Windows-only first.
430
+
431
+ **Files touched**: `bin/mailx.ts`, `packages/mailx-service/index.ts`, `packages/mailx-service/jsonrpc.ts`, `client/components/settings.ts` (or wherever the settings modal lives), new `bin/mailto-register.ts` (registry helper).
432
+
433
+ **Why this is M, not S**: registry writes via Node need either `reg.exe` shell-out (cross-arch, no native deps) or a regedit npm package; either is straightforward but adds a path. The mailto URL parser is one function. The IPC plumbing reuses existing compose path. Settings UI is one toggle.
434
+
435
+ <a id="ext116"></a>
436
+
437
+ ### P116 — Signature edit in Settings menu [↑ summary](#sum)
438
+
439
+ Per-account email signatures, edited in-app instead of by hand-editing `accounts.jsonc`. Originally tagged Small; deferred from v1.0.401 once the real shape became clear. Folds into the Settings UI rework rather than landing as a one-off dialog.
440
+
441
+ **Owed work**:
442
+ 1. **Multi-account modal** — list accounts on the left (radio or tab strip), signature textarea on the right. Selecting an account loads its current signature; changing the textarea marks dirty but doesn't auto-save. Save button persists all dirty accounts in one round-trip.
443
+ 2. **Save path** — wire through `mailx-service` to mutate `accounts.jsonc` and call `cloudWrite`. Reuse the JSONC-editor service infrastructure already shipped for the file editor (`writeJsoncFile`).
444
+ 3. **JSONC round-trip** — naive `JSON.stringify` would flatten the user's hand-formatting (comments, key order, spacing). Use `jsonc-parser`'s `modify` API to set `accounts[N].signature` in place; preserve everything else.
445
+ 4. **Compose integration** — already partial: compose's "From" field selects the account, and the send path looks up the signature. Verify the lookup picks up an updated signature without a daemon restart (file-watch already invalidates `_accountsCache` on `configChanged`).
446
+
447
+ **Why it stays M, not S**: items 1–3 each have a non-trivial gotcha (focus management in the modal, atomic multi-field save, JSONC modify-not-stringify). One-off-able as a dialog, but the right place is inside the Settings UI rework so a Settings-level Save/Cancel/Apply pattern handles all account-scoped edits uniformly (signature today, identity domains tomorrow, etc.).
448
+
449
+ <a id="ext49"></a>
450
+
451
+ ### S49 — Body comingling (DIAG v1.0.338) [↑ summary](#sum)
452
+
453
+ User-observed: Peter Hoddie's letter showed with an old, unrelated message's body after a retrieve-from-trash. Outlook displays the same Message-ID correctly. Not an IMAP-server bug.
454
+
455
+ Root cause candidate: body cache is keyed `(accountId, folderId, uid)` in `FileMessageStore`. IMAP UIDs are stable within a `(folder, UIDVALIDITY)` pair. Server-side recreations of a folder (rare) or some Dovecot quirks can reset UIDVALIDITY and make the integer UID point at a different message — but the on-disk `.eml` named after that UID doesn't get invalidated. `hasMessage` returns true, `getMessage` returns the old bytes.
456
+
457
+ Shipped: Message-ID-match guard in `fetchMessageBody` (`mailx-imap/index.ts:1655`). If the cached `.eml`'s Message-ID header doesn't match the DB row's `messageId`, the cache is dropped and the body is re-fetched. Logs `[body] COMINGLING DETECTED <uid> expected=<id> cached=<id>`. Band-aid — still owed: UIDVALIDITY-aware cache path.
458
+
459
+ **Open question**: do we want to (a) key the cache `(acct, folderId, uid, uidvalidity)`, (b) key by Message-ID instead of UID, (c) keep Message-ID validation as the permanent guard (accept the cost of one extra fetch on mismatch)? Each has trade-offs on cache hit rate, reconcile behavior on UID renumber, and disk usage.
460
+
461
+ <a id="ext50"></a>
462
+
463
+ ### S50 — `accounts.jsonc` banner won't stop firing (OPEN) [↑ summary](#sum)
464
+
465
+ User sees the "config changed — restart to apply" banner repeatedly, even after no edits. Shipped v1.0.338:
466
+ 1. `fs.watch` callback hash-compares file content before emitting; metadata-only fires (atime bumps, rename-aside etc.) don't flip the banner.
467
+ 2. Cloud-poll normalization (BOM/CRLF/trailing-newline strip + JSONC-semantic compare) so a download that formats differently but parses identically doesn't fire.
468
+ 3. First-diff snippet logging on real fires — the log now shows `[watch] accounts.jsonc changed: size X→Y, first diff at byte N "<old>" → "<new>"`, identifying the writer.
469
+
470
+ Awaiting user log capture on next spurious fire. If the log shows a real change we know something (us? GDrive sync client?) is mutating the file; if it still fires with `fs.watch fired but content unchanged — no banner` it's a GDrive API timestamp mismatch, not a real change.
471
+
472
+ <a id="ext51"></a>
473
+
474
+ ### S51 — Calendar Thunderbird-style sidebar (PARTIAL — sidebar UI done, live Google data still owed) [↑ summary](#sum)
475
+
476
+ Sidebar UI shipped v1.0.372 (`client/components/calendar-sidebar.ts`): right-docked panel, day-grouped event list, "+ New event", tasks section, "Show completed Tasks" checkbox. View-menu toggle persists to `localStorage.mailx-calendar-sidebar-on`. Narrow/mid-width tiers (< 1100px) hide automatically. v1.0.376 fixed the grid-area ghost column + flipped default to ON + made Calendar ask `getPrimaryAccount("calendar")` instead of the catch-all primary.
477
+
478
+ **Still owed (S51 slice 2)**: Google Calendar live data. The `fetchUpcoming` seam at `calendar-sidebar.ts:78` has the hook but the service-side proxy (`service.fetchGoogleCalendarEvents(accountId, from, days)`) isn't implemented — sidebar currently shows local-only events. Needs: service method that authenticates via the existing OAuth token manager, paginates `events.list` on the primary-calendar account, merges with local. Also needs OAuth scope verification at runtime (scope was added to credentials; confirm it's actually in the token).
479
+
480
+ **Open question (still unresolved)**: keep the full-screen calendar modal (`client/components/calendar.ts`) alongside the sidebar, or retire it? Two views of the same data isn't harmful but is maintenance overhead.
481
+
482
+ <a id="ext52"></a>
483
+
484
+ ### S52 — Generalize the `primary` account flag (DONE v1.0.376 — option (a)) [↑ summary](#sum)
485
+
486
+ Shipped option (a) — per-feature flags with back-compat. `AccountConfig` now has optional `primaryCalendar` / `primaryTasks` / `primaryContacts`. `service.getPrimaryAccount(feature?)` resolves per-feature → catch-all `primary` → first-account. Client callers pass the feature name (`getPrimaryAccount("calendar")`); existing callers with no arg still work. Android bridge mirrors the desktop semantics locally. JSONC remains the source of truth; UI for flipping the flag is deferred (edit the config file for now via Settings → Edit config files…). See Done table below.
487
+
488
+ <a id="ext54"></a>
489
+
490
+ ### S54 — (retired 2026-04-23) [↑ summary](#sum)
491
+
492
+ Split into S63 (desktop pop-out) + Android-narrow-half (shipped — `isSmall` branch in `showComposeOverlay` already makes compose full-viewport on screens ≤768px or ≤600px tall).
493
+
494
+ <a id="ext63"></a>
495
+
496
+ ### S63 — Desktop compose pop-out (separate OS window) (OPEN ❓) [↑ summary](#sum)
497
+
498
+ Today compose is an iframe overlay docked inside the mailx main window. Goal: let the user open compose/reply in a *separate OS window* — drag off, another monitor, keep it open while still navigating the main inbox. Needs either (a) msger's "new window" capability wired through `mailx-host` (pending — see C28), or (b) a `window.open` fallback that inherits the custom protocol (today's popups hit "Cannot GET /index.html" — C28). Prior art for the double-click popout is the floating in-window overlay (Q64 v1.0.344) — reuse that shape but make it detachable.
499
+
500
+ **Open question**: window mechanism — real OS child process (via msger) vs. `window.open` with a custom-protocol fix, vs. stick with the floating in-window overlay and call it good?
501
+
502
+ <a id="ext55"></a>
503
+
504
+ ### S55 — (retired 2026-04-23) [↑ summary](#sum)
505
+
506
+ Split into S64 (icon identity) + S65 (badge-when-not-running). Both blocked on changes outside this repo.
507
+
508
+ <a id="ext64"></a>
509
+
510
+ ### S64 — Pinned-taskbar icon shows mailx identity (OPEN, blocked on msger) [↑ summary](#sum)
511
+
512
+ Pinned exe path is `%APPDATA%\npm\node_modules\@bobfrankston\msger\msger-native\bin\msgernative.exe` — shared across every msger-hosted app. Windows groups pins by exe path + AUMID; since mailx has its own AUMID but a shared exe, the pin ends up using msger's icon metadata.
513
+
514
+ The plan is captured as `C:\Users\Bob\.claude\plans\right-now-because-everything-streamed-dawn.md`: lazy `provisionAppBinary` in msger's `shower.ts` creates `%LOCALAPPDATA%\mailx\bin\mailx.exe` hardlinked to the msger native exe, so Windows has a per-app exe path. **Fix lives in msger, not mailx.** See `y:/dev/utils/msgx/parity.md` ("Per-app taskbar identity (deferred)").
515
+
516
+ <a id="ext65"></a>
517
+
518
+ ### S65 — Unread badge visible when mailx isn't running (OPEN ❓, blocked) [↑ summary](#sum)
519
+
520
+ Unread count is set via `ITaskbarList3::SetOverlayIcon` while mailx is running. When the user pins the running button and then closes mailx, the overlay disappears (by design) — Windows has no persistent notion of a taskbar badge for a not-running app.
521
+
522
+ **Open question**: which mechanism? (a) tiny background tray process that polls the DB, (b) Windows 11 Notification Center integration, (c) accept that unread count is only accurate while mailx is running. (c) is the easy answer and matches Outlook-desktop behavior; (a)/(b) are real investments.
523
+
524
+ <a id="ext53"></a>
525
+
526
+ ### S53 — Android parity debt (umbrella retired 2026-04-23 — split into S57–S62) [↑ summary](#sum)
527
+
528
+ The original S53 lumped eight sub-items together. Per the numbering rule ("separate items should have separate numbers"), each open item now has its own stable ID below. Two entries retired inline: compose-full-screen is already shipped via the `isSmall` check in `showComposeOverlay`; Word-paste sanitizer lives in Near-term.
529
+
530
+ <a id="ext57"></a>
531
+
532
+ ### S57 — Android: INBOX-first local render (OPEN) [↑ summary](#sum)
533
+
534
+ `web-service.ts:377` serial `for (folder of sorted) { await syncFolder(...) }` blocks UI on every Gmail label. INBOX sorts first but the UI doesn't render until all labels finish. Need: INBOX sync + render immediately, other folders non-blocking in the background. (Same local-first rule as desktop — UI reads from DB, sync is a side effect.) AndroidSyncManager's `syncAll` Phase 1 already does this for INBOX; the gap is web-service.ts's serial loop in the main-thread-host path.
535
+
536
+ <a id="ext58"></a>
537
+
538
+ ### S58 — Android: rail on narrow (OPEN) [↑ summary](#sum)
539
+
540
+ On phone-narrow, compose ✏ pencil is visible but folder navigation is behind the broken hamburger. Surface an Inbox button as a primary action, not hide folders. Coordinates with S59 (hamburger close).
541
+
542
+ <a id="ext59"></a>
543
+
544
+ ### S59 — Android: hamburger no-op (OPEN) [↑ summary](#sum)
545
+
546
+ Hamburger currently does nothing, or doesn't fully close when it was open. CSS backdrop/transform residue on close. Click outside should dismiss; same rule the folder overlay already follows at desktop medium width (2026-04-20 fix).
547
+
548
+ <a id="ext60"></a>
549
+
550
+ ### S60 — Android: keyboard → input lag (OPEN) [↑ summary](#sum)
551
+
552
+ wa-sqlite writes (autocomplete lookups, draft checkpoints) happen on the main thread. Each keystroke in compose hits sql.js synchronously. Fix: off-thread via Worker or rAF-debounce the input→DB path. Worker path was reverted 2026-04-14 (stuck at "Initializing..."); rAF-debounce is the lower-risk first pass.
553
+
554
+ <a id="ext61"></a>
555
+
556
+ ### S61 — Android: Send leaves a second compose open (OPEN) [↑ summary](#sum)
557
+
558
+ Close path doesn't fully unmount, or Reply/Forward is double-opening. After Send the compose frame persists — user has to dismiss manually. Check `showComposeOverlay` close handler + `closeCompose` path + any Reply/Forward re-entry.
559
+
560
+ <a id="ext62"></a>
561
+
562
+ ### S62 — Android: prefetch priority + speed (OPEN) [↑ summary](#sum)
563
+
564
+ Serial labels + a slow one (`[Gmail]/Jerrry`) stalls the rest. Needs: concurrency cap (2-way), most-recent-first within INBOX before any label at all. Batch body prefetch (C24) dovetails with this — pass-through when it lands.
565
+
566
+ <a id="ext56"></a>
567
+
568
+ ### S56 — Row-objects own the preview pane (OPEN; last slice of the old S1 umbrella) [↑ summary](#sum)
569
+
570
+ Each message-list row should be an object with `focus()` / `unfocus()` methods that atomically manage the preview pane. Today two parallel paths exist in `message-list.ts:544-545`: `state.select(msg)` AND a separate `onMessageSelect(...)` callback ending in `showMessage(...)`. That's two race windows; `showMessageGeneration` in `message-viewer.ts` is the band-aid that cancels stale fetches. With row-objects, `unfocus()` clears the preview as part of itself, `focus()` renders it from the row's own local-DB-backed copy — the generation-token band-aid disappears.
571
+
572
+ Prior bug this fixes: "list shows Quora row highlighted, preview shows Re:Update" (`screenshots/Screenshot 2026-04-20 214724.png`). The lightweight fix in v1.0.365 (viewer clears when list-replace deselects) closes the most common race; S56 is the real structural cleanup.
573
+
574
+ Was part of S1 "local-first reconciliation refactor" until 2026-04-23 when S1's other slices had all shipped (tombstones, opaque UUIDs, stable row UUIDs, Message-ID move-detection, pink rows, unified-inbox pink — all in Done). Renumbered to S56 so this last slice has its own ID.
575
+
576
+ <a id="priority-1"></a>
577
+
578
+ ## Priority 1 — Get basics working [↑ top](#top)
579
+
580
+ ### External content security
581
+ - [ ] **Allowlist editor** — button in message header to view/edit the remote content allowlist (senders, domains, recipients)
582
+ - [ ] **Spam buttons** — "Sender is spam" and "To-address is spam" in the remote content banner. Marks sender/recipient, blocks future remote content, adds spam marker to message.
583
+ - [x] **One-click unsubscribe (RFC 8058)** — done 2026-04-21. `mailx-service/unsubscribeOneClick(url)` at `index.ts:282-290` POSTs `List-Unsubscribe=One-Click` (form-encoded) to the parsed https URL when both `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` headers are present. Viewer shows "Unsubscribe (1-click)" button instead of opening a browser tab.
584
+ - [ ] **Finer control** — Thunderbird-style: per-image allow, manage in control panel
585
+ - [ ] **Allowlist reads from cloud API** — loadAllowlist() must read via Drive API, not stale local cache. Local cache is offline fallback only.
586
+
587
+ ### Sync polish
588
+ - [ ] **Sync status per message** — ↻ indicator on messages with pending local changes
589
+ - [ ] **Offline indicator** — status bar shows online/offline state
590
+ - [ ] **Conflict resolution** — last-write-wins or merge flags
591
+
592
+ ### Config & Deploy
593
+ - [ ] **Local appearance.jsonc** — move theme, font sizes, window position/size into a per-machine `~/.mailx/appearance.jsonc`. Per-system, not shared.
594
+ - [ ] **In-app settings page** — edit local/per-device settings (theme, font size, sync interval, historyDays) from the UI.
595
+ - [ ] **Settings cascade** — general pattern: global default (preferences.jsonc) → per-account override (accounts.jsonc) → per-device override (config.jsonc). Applies to: signature, historyDays, sync interval, theme, prefetch, editor type. Same pattern as `getHistoryDays()` already uses. Extend to all settings.
596
+ - [ ] **Outlook Graph API driver — full wiring** — Today any `@outlook.com` / `@hotmail.com` / M365 account falls through to plain IMAP over `outlook.office365.com` via iflow, same path as any other non-Gmail server. The `OutlookApiProvider` at `mailx-sync/outlook-api.ts` is a skeleton that the dispatcher never instantiates. Missing pieces (do all of these together — an Azure app without the dispatcher wiring is useless):
597
+ - **Azure app registration** — one-time, by the mailx author; the client ID is *public* (public-client OAuth) and ships with mailx the same way the Google client ID does. Not per-user. Full step-by-step in `docs/azure.md`. Short version: portal.azure.com → App registrations → "Accounts in any org directory + personal Microsoft", delegated Graph scopes (`Mail.ReadWrite`, `Mail.Send`, `offline_access`, `User.Read`), allow public client flows, commit the client-id GUID into oauthsupport's provider constants next to the existing Google entry. Per-user refresh tokens still cache under `~/.mailx/tokens/<user-email>/`.
598
+ - **Dispatcher branch.** `ImapManager` today has `isGmailAccount()` / `getGmailProvider()` and branches on it in ~8 sync paths (`syncFolderViaApi`, `quickCheck`, `fetchMessageBody`, `setFlagsOnServer`, `moveMessage`, etc.). Add an `isOutlookAccount()` mirror — check `imap.host.includes("outlook") || email ends-with outlook/hotmail/live` — and a `getOutlookProvider(accountId)` that reuses the account's tokenProvider. Route Outlook accounts through the API provider just like Gmail.
599
+ - **OutlookApiProvider complete the MailProvider interface**: `listFolders()` → `/me/mailFolders`, `fetchSince(folder, sinceUid)` → `/me/mailFolders/{id}/messages?$filter=receivedDateTime ge ...` paginated via `@odata.nextLink`, `fetchByDate`, `fetchByUids` (Graph uses string IDs not integer UIDs — same hash-to-integer trick as Gmail to stay interface-compatible), `getUids` for reconciliation, `setFlags` → `PATCH /me/messages/{id}` with `isRead` / `flag`. Move → `POST /me/messages/{id}/move`. Send → `POST /me/sendMail`. All the batch patterns from `mailx-sync/gmail.ts` (token bucket, 429 retry with Retry-After, quota-reason parsing) apply verbatim — Graph's throttling mirrors Gmail's closely enough that the rateState/acquireToken machinery should lift-and-shift. Biggest drift: Graph uses opaque string IDs everywhere, so the `providerId` field in ProviderMessage finally earns its keep.
600
+ - **Delta sync bonus.** Graph has `/me/mailFolders/{id}/messages/delta` which returns only what changed since a sync token. Gmail's `History API` is the equivalent, also unimplemented. Both are tracked as a separate optimization item — first get Outlook to working parity, then harvest delta for both providers in one pass.
601
+ - **Production app verification** isn't needed for a personal-use app; unverified apps hit a consent-screen warning but still work. Skip until/unless mailx is distributed.
602
+ - [ ] **Add Account wizard** — enter email, auto-detect settings (already have ISPDB/SRV), prompt for password or OAuth, test connection, save.
603
+ - [ ] **Contacts sync UI** — show sync status, manage which accounts sync contacts, manual sync button
604
+ - [x] **`mailx -test`** — implemented at `bin/mailx.ts:52,655`. Sends a test message via SMTP per account and reports pass/fail. (Could grow into a richer round-trip verifier — IMAP fetch back, etc. — but the basic flag works.)
605
+ - [ ] **In-app account management** — add/edit/delete accounts from UI. Work with zero accounts (show setup).
606
+ - [x] **README** — substantively complete (401 lines, refreshed with all recent features + editor + preview shortcut tables). Will keep growing as features land.
607
+ - [x] **File watches on shared config** — `ImapManager.watchConfigFiles()` watches accounts.jsonc / allowlist.jsonc / clients.jsonc / config.jsonc and emits `configChanged`. Client shows a "restart to apply" banner. True hot reload is still a separate item.
608
+ - [ ] **Multi-instance "do no harm"** — empirically two `mailx` processes coexist (IMAP handles cross-session coherence, deletes propagate, SQLite WAL serializes writes). Audit and harden the sharp edges so it stays safe: outbox file scan must not double-send when both instances see the same `.ltr` (atomic rename or per-instance lock file), WebView2 user-data dir contention, periodic-sync overlap, OAuth token cache writes, log file collisions. Goal: no data loss / double-send if user opens a second instance, even though we recommend multi-window over one service.
609
+
610
+ - [ ] **Swap resender to smtp-direct** — `MailApps/resender/resender.ts:36/158` still uses nodemailer; `togmail.ts` likely too. Drop-in candidate now that smtp-direct is verified live (mailx desktop + Android already migrated). **No urgency** — resender is server-side and stable. Switch when convenient to drop the last nodemailer runtime dep across the family.
611
+
612
+ - [ ] **mailx on Linux: dies on schema mismatch before showService runs** (reported 2026-04-13) — Linux log at `~/.mailx/logs/mailx-YYYY-MM-DD.log` shows: `Starting mailx service...` then immediately `ERROR Error: no such column: thread_id`. msger itself is fine; the Node service crashes before it gets to `showService`. Cause: the `addColumnIfMissing("messages", "thread_id", "TEXT")` migration at `mailx-store/db.ts:147` doesn't run on Linux — almost certainly because the Linux global install is an older version of mailx-store that predates the migration. Quick user fix: `mailx -rebuild` (wipes DB, re-syncs) or manual `sqlite3 ~/.mailx/mailx.db "ALTER TABLE messages ADD COLUMN thread_id TEXT;"`. Real fix: ensure the Linux deploy actually pulls current mailx-store. Also worth: (1) `MailxDB` constructor should crash loud + early with a clear "schema migration failed; run mailx -rebuild" message instead of letting the first downstream query throw a cryptic SQLite error; (2) a `child.on("error", ...)` handler in `msger/shower.ts:433 showService` so a failed spawn surfaces (independent of this bug, defensive).
613
+
614
+ - [ ] **Dovecot-style responsive layout (4 tiers, platform-independent)** *(rail done 2026-04-21; tier breakpoints partially done)* — Mimic Roundcube/Dovecot's layout model, driven purely by viewport width. Desktop and Android use the **same** rules; the platform is irrelevant — only width matters. See reference screenshots `Phone Link/Screenshot_20260414-105749.png` (medium) and `Screenshot_20260414-105814.png` (very narrow). **Status update 2026-04-21:** the icon rail itself is in (`client/index.html` `<aside class="icon-rail">`, `client/styles/layout.css` `.icon-rail` block, `client/app.ts` rail-* handlers). Wide tier shows `[rail | folders | list | viewer]`; medium tier `[rail | list | viewer]` (folders overlay with the rail offset baked in); narrow hides rail (hamburger remains). Remaining: very-narrow density tier (~400px), proper rail collapse-into-hamburger on narrow, calendar/tasks/contacts buttons becoming live (currently disabled placeholders), rail icon badges for unread counts.
615
+
616
+ **Three tiers (breakpoints chosen to match real device classes):**
617
+ 1. **Wide** (≥1200px — desktop/laptop): `[rail | folder list | message list | viewer]`. Everything visible; folder list permanent.
618
+ 2. **Medium** (768–1199px — small tablet, Pixel Fold unfolded portrait, narrow laptop window): `[rail | message list | viewer]`. Rail stays, folder list hidden by default. A **folder icon** in the header toggles the folder list as an overlay/slide-in. (Matches `Phone Link/Screenshot_20260414-105749.png`.)
619
+ 3. **Narrow** (<768px — phone portrait, Pixel Fold folded): `[hamburger + folder icon] + single panel`. Rail collapses into the hamburger menu. Folder icon stays in header for the folder overlay. Selecting a message swaps list → viewer via existing `narrow-active` overlay; back button returns to list. (Matches `Phone Link/Screenshot_20260414-105814.png`.)
620
+
621
+ Density tweaks (two-line rows, smaller font, hidden column headers, etc.) at an even smaller breakpoint (~400px) are content-level tuning, not a separate structural tier.
622
+
623
+ **The key shift from today's desktop behavior:** stop unconditionally showing the folder list on desktop. Show it only when width justifies it, regardless of whether we're on Windows, Linux, macOS, or Android. Same CSS breakpoints across platforms.
624
+
625
+ **Icon rail contents** (vertical, far left; matches Dovecot's pattern): compose, inbox, unified inbox, contacts, calendar, settings at top; theme toggle, help, logout at bottom. Gives Phase 4 features (calendar, screener) a natural home.
626
+
627
+ **Implementation:**
628
+ - Extend `client/styles/layout.css` grid from the current two-regime setup to four: wide / medium / narrow / very-narrow.
629
+ - Add `.icon-rail` component to `client/components/` and wire to existing actions (`btn-compose`, folder-tree toggle, etc.).
630
+ - Folder panel's `open`/`closed` state becomes meaningful in both medium and narrow tiers (currently only thin).
631
+ - Reuse the existing hamburger + folder overlay for narrow/very-narrow.
632
+ - Single `.html` + CSS change applies to both `index.html` (desktop) and `android.html`.
633
+ - ~half day for rail + 3-tier CSS, another half for wiring icons + ensuring folder panel toggles cleanly in each tier.
634
+
635
+ **Calendar + tasks in the rail** — rail icons for calendar and tasks must fit the same responsive model:
636
+ - Wide: calendar opens as a right-side pane (Thunderbird Lightning-style "Today Pane"), coexisting with the viewer. Tasks are a sub-view of calendar or a separate chip.
637
+ - Medium: calendar icon replaces the message list+viewer with a full-width calendar view (mail drops to rail-icon state). Same for tasks. A small "back to mail" affordance in the header.
638
+ - Narrow: calendar/tasks are full-screen panels accessed via hamburger menu (no rail). Each becomes its own top-level view with the same back-to-list pattern as messages.
639
+ - Data model: Google Calendar + Google Tasks are the primary sources. Alarm/snooze UX shared across mail-reminders + calendar-events + task-reminders (one alarm subsystem). Tracked separately under the existing "Calendar/Tasks sidebar" TODO and the "Tasks support" design TODO.
640
+
641
+ - [ ] **Thread highlighting (2/3 done v1.0.376 — "Filter this conversation" still owed)** — (1) ✅ `.is-reply` left-border accent shipped — rows with `inReplyTo` get a subtle blue left-edge so threaded replies stand out even with grouping off. (2) ✅ 💬 "View thread" button shipped in viewer toolbar — opens the thread popup on any message with a `threadId`. (3) **Still owed**: View-menu toggle "Filter: this conversation" that filters the message list to the selected message's `thread_id`. Needs the MessageQuery plumbing to accept a `threadId` filter, which is why it didn't land with the other two.
642
+
643
+ - [ ] **Unified send / outbox / multi-device design** — Apply the principle "every folder is bidirectionally synced; local actions queue up and reconcile, online vs offline is just latency." The outbox is currently the only folder that breaks the principle (it's a local file directory, not an IMAP-mirrored folder for Gmail; for IMAP accounts it does APPEND to `Outbox` already). Unify so Drafts / Outbox / Sent are all server-side folders, and any device can claim and send a queued message.
644
+
645
+ **Current state:**
646
+ - Non-Gmail (IMAP) accounts: APPEND to server `Outbox`, then `processOutbox` claims via `$Sending-<hostname>` flag, sends via SMTP, deletes from Outbox + APPENDs to Sent. Has a TOCTOU race in the read-flag/add-flag/re-read-flag pattern (line ~2125 of `mailx-imap/index.ts`); collision is rare but possible.
647
+ - Gmail accounts: local file queue at `~/.mailx/outbox/<acct>/*.ltr`, sent directly via SMTP, no IMAP outbox. Two processes scanning the same dir can both grab the same `.ltr` and both call SMTP; the `db.hasSentMessage` Message-ID dedup is best-effort (race-prone — both check before either records). Multi-device Gmail = no shared outbox at all (phone can't see desktop's queued mail and vice versa).
648
+ - ~~Android: send is stubbed~~ — **corrected 2026-04-23**: Android send has been real for a while (Gmail sendRaw + smtp-direct/BridgeTransport). v1.0.376 adds persistent queueing via `sync_actions` so crash mid-send no longer drops the message. Remaining work on this outbox design is still owed (IMAP-mirrored Outbox folder, UID MOVE claim, heartbeat, etc.).
649
+
650
+ **Target design:**
651
+ 1. **Outbox is always an IMAP-mirrored folder.** Even Gmail uses an IMAP folder (or Gmail label) named `Outbox` or `[Mailx]/Outbox`. APPEND on submit. Drafts pattern, applied to outbox.
652
+ 2. **Atomic claim via UID MOVE** (RFC 6851, universal) to a per-device folder `Outbox.Sending-<deviceId>`. Server picks one winner; loser sees UID gone. Replaces the flag-claim TOCTOU race.
653
+ 3. **Heartbeat** — claimer sets `$Mailx-Sending-<ISO-ts>` flag, refreshes every 30s during send. Other devices see advancing timestamp → leave alone. Stale (no advance for 5+ min) → fair game.
654
+ 4. **Recovery sweeper** — every 5 min, each device scans `Outbox.Sending-*` for entries older than threshold; owning device on startup retries its own; foreign devices wait longer threshold (1 hour) before rescuing to avoid stealing in-flight sends.
655
+ 5. **Drafts↔Outbox is a flag toggle**, not separate UI/storage — `\Draft` flag means "still editing," absence + presence in Outbox folder means "queued."
656
+ 6. **Local-file fallback only when IMAP unreachable.** While offline, message goes to `~/.mailx/outbox/`. On reconnect, gets APPEND'd to server Outbox (where any device can then claim). Local file is staging, not source of truth.
657
+ 7. **Android: wire actual SMTP send.** Currently `queueOutgoingLocal` is a stub. Either (a) Android does its own SMTP via a Node-bridged socket, or (b) Android only APPENDs to server Outbox and lets the desktop send (acceptable when desktop is online; problematic when desktop is offline). Probably (a) for true autonomy.
658
+ 8. **Servers without custom-folder support** — fall back to using `Drafts` with a custom flag (`$Mailx-Queued`) as the queue indicator.
659
+
660
+ **Reconciliation glue** (for offline → online transitions): every locally-queued message carries a stable `Message-ID` + `X-Mailx-Local-Id` header. After server APPEND succeeds and APPENDUID returns, link local entry to server UID; drop the local-only flag. Drafts already do this; outbox should follow the same pattern.
661
+
662
+ **Conflict cases worth thinking through before coding:**
663
+ - Two devices race on draft edit → APPEND-then-EXPUNGE pattern means whichever lands second wins; older copy is gone. Acceptable.
664
+ - Device A claims + crashes mid-SMTP; Device B's sweeper rescues at 1-hour mark; Device A comes back online → sees its claim folder empty, no action. OK.
665
+ - Device A claims + sends + deletes from Outbox; Device B's IDLE missed the EXPUNGE → next sync sees the gone-UID and resyncs. OK.
666
+ - SMTP reports success but mailx loses ack: X-Mailx-Retry header (already exists) prevents same-device duplicate; cross-device dedup via Message-ID in `sent_log` (already exists) catches the rest.
667
+
668
+ This unifies the multi-instance + multi-device + offline stories into one mechanism. Replaces today's split between Gmail file-queue and IMAP folder-queue. The `~/.mailx/sending/` debug-directory complexity becomes unnecessary (audit trail moves into IMAP folders).
669
+ - [ ] **Restore `--server` mode + login** — `bin/mailx.ts:701` currently goes straight to IPC mode; the documented `--server` flag isn't wired (README.md:18,236,240,392,393 and CLAUDE.md still claim it works). The `mailx-server` package still exists. Re-wire the flag and pair it with a login mechanism (token / OAuth / basic) so it's safe to expose beyond localhost — useful for phone access pre-MAUI, debugging from another machine, browser devtools.
670
+ - [ ] **Address book UI** — view/edit/search contacts synced from Google Contacts. Add from message header. Merge duplicates. Currently contacts sync for autocomplete but no standalone address book view.
671
+ - [ ] **Contact integration** — right-click email address to add/update Google Contact. Show contact card on hover (photo, org, phone). Create contact from email signature (AI-assisted). Link messages to contacts.
672
+ - [ ] **Calendar/Tasks sidebar** *(elevated priority — see cheat-sheet at top)* — Thunderbird-Lightning-style Today Pane (View checkbox): mini month + upcoming events (Google Calendar API / gcal), task list (Google Tasks / CalDAV VTODO). Alarm popups with snooze (5min/15min/1hr/custom) for upcoming events and reminders — OS notification when minimized, in-app popup when focused. **Goal: better task handling than Thunderbird** — start by mirroring Thunderbird's structure, then evolve task UX (likely areas to improve: rich task notes inline, message↔task conversion, recurrence beyond what Google Tasks offers, snooze/defer-to-date that integrates with mail follow-up reminders). Reuse one alarm subsystem across mail-reminders, calendar events, and task reminders (see "Popup reminders" item).
673
+ - [ ] **Email completion** — auto-suggest email addresses as you type in To/Cc/Bcc from Google Contacts + recent correspondents + message history. Currently works but needs better ranking and fuzzy matching.
674
+
675
+ ### Server-side changes
676
+ - [ ] **Detect server-side message deletions** — when messages are deleted from another client (webmail, phone, Thunderbird), sync must detect the missing UIDs and remove them from local DB + store. IMAP: compare local UIDs against server UIDs. Gmail API: use `history.list()` which reports deletions directly. Currently partial — reconciliation runs but may miss deletions during incremental sync.
677
+ - [x] **Detect server-side folder deletions** — done 2026-04-21: `syncFolders` at `mailx-imap/index.ts:543` now prunes local folders whose exact path isn't returned by the server (safety: never prunes INBOX; only prunes when server returned a non-empty list so an auth/network glitch returning `[]` doesn't wipe the tree). Also hardened `service.deleteFolder` to treat "server says not found" as success + clean local DB anyway — user can't get stuck on a stale ghost folder from a past rename.
678
+
679
+ ### Connection resilience
680
+ - [ ] **Auto-reconnect on dead socket** — "Not connected" errors on bobma are transient (server dropped idle connection). The `_dead` flag detection was added but isn't catching all cases. Need: detect write errors, immediately discard client, reconnect transparently on next operation. No error shown to user for transient reconnects.
681
+
682
+ ### Remaining basics
683
+ - [ ] Standalone `mailsend` CLI keeps filesystem queue for non-IMAP use
684
+
685
+ <a id="priority-2"></a>
686
+
687
+ ## Priority 2 — Core UX [↑ top](#top)
688
+
689
+ ### Right-click / context menus
690
+ - [ ] **Full context menus** — message (reply, forward, delete, move, flag, copy .eml), email address (copy, add contact, compose to, search, allow remote), message body (copy, search, AI parse), links (show/copy URL, open in browser)
691
+
692
+ ### Compose editor
693
+ - [ ] **Editor improvements** — extend Quill or evaluate alternatives (tiptap). Needs: auto-linkify URLs on paste/type, native spellcheck with "Add to dictionary" (Quill blocks right-click), shared custom dictionary across machines. tiptap doesn't intercept right-click so spellcheck works natively. **Status 2026-04-30:** tiptap CDN bundle in our env doesn't expose `window.tiptapCore` so creating the editor throws — fallback contenteditable kicks in but loses the toolbar. Deferred; revisit when Quill alternatives are seriously evaluated. Setting `editor=quill` in user settings is the working path.
694
+ - [x] **Compose close prompt** — X / Escape / close all ask Save / Discard / Cancel when there's content to save.
695
+ - [ ] **Reply quoting** — don't modify the quoted HTML. Wrap original as-is in a read-only `<blockquote>` below the editable area (like Thunderbird/Outlook). Editor only edits above the quote line. Avoids mangling complex HTML (tables, inline styles from automated emails).
696
+ - [ ] **Signature blocks** — HTML signatures auto-appended on compose/reply. Per-account (`signature` field in accounts.jsonc), global default in preferences.jsonc, or contextual (per-list, per-recipient). Start with per-account in accounts.jsonc as manual approach. Editable in compose before send.
697
+ - [ ] **Attachment handling** — full attachment management: drag-to-attach (drop zone, not inline), click-to-browse, remove individual attachments before send. On received messages: save one or all to a folder, drag attachment out to file system. Show attachment size in chip.
698
+ - [ ] **Writing assistance** → **moved to [AI section](#ai)** (Q111-AI). LanguageTool vs custom AI vs native WebView2 spellcheck — decision lives there alongside the other AI back-end picks.
699
+
700
+ ### Compose / From address
701
+ - [ ] **Custom From history** — persist manually-entered From addresses
702
+ - [ ] **From address mapping** — rules to auto-set From based on mailing list, recipient domain
703
+ - [x] **Auto-detect reply From** — Reply auto-From detects the right identity address based on the received recipient (handles aliases / +tag addresses).
704
+
705
+ ### Folder management
706
+ - [ ] **Move folder** — drag to reorder/reparent
707
+ - [ ] **Incremental folder tree updates** — patch badge counts in place, no full DOM rebuild
708
+ - [ ] **Detect case-duplicate folders** — highlight duplicates, prevent creating new ones
709
+
710
+ ### Message list
711
+ - [ ] **Control panel / Settings UI** — accounts, sync, view defaults, contacts, OAuth management
712
+ - [ ] **Cloud provider icon** — show Google Drive / OneDrive / Dropbox logo icon in toolbar or status bar to indicate active cloud provider
713
+ - [ ] **Color schemes / theming** — configurable colors, remove debug blue toolbar
714
+ - [ ] **Full menu bar** — File, Edit, View, Message menus with keyboard shortcuts
715
+ - [x] **Validate email addresses on send** — To/Cc/Bcc/From all vetted against `local@domain.tld` shape before send.
716
+ - [x] **Undo move (Ctrl+Z)** — works for last move and last delete (whichever is more recent), 60s window.
717
+ - [ ] **Message list management** — column resize (drag headers), column reorder, sort by clicking column headers (from/date/subject/size with secondary sort by date), show/hide columns (size, account) via View menu
718
+ - [ ] **Duplicate Message-ID handling** — detect messages with the same Message-ID (copies from multiple accounts, re-delivered mail). Options: deduplicate in unified inbox, show indicator, or let user choose which to keep.
719
+
720
+ ### msger / mailx factoring
721
+ - [ ] **Separate mailx identity from msger** — mailx should have its own window title, taskbar icon, AppUserModelID, and branding independent of msger. Short term: pass title/icon via showService options (done). Long term: build mailx-specific launcher that uses msger as a library, or factor msger into a shared WebView component library that both msger and mailx consume. See msger-plan.md.
722
+
723
+ ### Pop-out & Multi-window
724
+ - [ ] **PRIORITY: Real OS popup windows** — compose, message popout, multi-window all need to spawn real msger child processes instead of in-iframe overlays. Iframe is constrained to parent window — can't use full screen, multiple monitors, drag outside. Requires msger to support shared IPC across instances OR new msger flag for "child window" mode that connects to parent's IPC channel. Blocking real desktop usage. See msger-plan.md.
725
+ - [ ] **Pop-out message** — double-click or right-click "Open in new pane" on a message to pop it out as a separate floating iframe (like compose). Reply/Reply All/Forward from a pop-out compose inline (replace the pop-out content) instead of opening another compose frame.
726
+ - [ ] **Multiple instances** — open additional mailx windows to view different folders simultaneously. Each window is an independent view into the same data (shared DB, shared sync). Window management: list open windows, switch between them, open folder in new window from context menu.
727
+ - [ ] **Tab view** — option to use tabs within a single window instead of separate windows. Switch between tab and window mode. Each tab is an independent folder/message view.
728
+
729
+
730
+ ### Search
731
+ - [ ] **Search status indicator** — show "Searching server..." spinner when IMAP server search is active vs local FTS5. Visual distinction so user knows which engine is running.
732
+ - [ ] **More qualifiers** — date:, has:attachment, is:flagged, folder:
733
+ - [ ] **Gmail gmraw passthrough** — use Gmail's native search syntax for Gmail accounts
734
+ - [ ] **Full body text in FTS5** — currently only indexes preview snippet
735
+ - [ ] **Search results show folder origin** — indicate folder/account per result
736
+
737
+ <a id="priority-3"></a>
738
+
739
+ ## Priority 3 — Polish [↑ top](#top)
740
+
741
+ - [ ] **Zoom message preview** — Ctrl+scroll or right-click menu to zoom the message body iframe independently of the main UI. Persist zoom level per-session or per-preference.
742
+ - [ ] **Show link URL on hover** — display target URL in status bar
743
+ - [ ] **Sanitizer link corruption** — some URLs get corrupted by regex sanitizer. Consider DOMPurify.
744
+ - [ ] Threading / conversation view
745
+
746
+ ## Message Metadata & External Integration
747
+
748
+ - [ ] **Per-message metadata** — JSON column for custom annotations (AI ratings, categories, priority)
749
+ - [ ] **Metadata API** — REST endpoint to get/set per-message metadata
750
+ - [ ] **Priority/Importance display** — show ! icon for high priority messages
751
+ - [ ] **Message highlighting** — visual emphasis on important messages in the list. Based on: sender importance (configurable VIP list), read/unread status, AI-suggested priority (urgent, action-required, FYI). Highlight styles: bold, color accent, icon badge. VIP senders could auto-flag or sort to top.
752
+
753
+ ## Rules, Filtering & Extensions
754
+
755
+ - [ ] **Rules/extensions engine** → **moved to [AI section](#ai)** (C39 / Q103-AI). Rule mechanics and AI classification design live together since the shape of the rule engine depends on whether AI predicates are first-class.
756
+
757
+ <a id="near-term"></a>
758
+
759
+ ## Near-term [↑ top](#top)
760
+
761
+ - [ ] **Android/MAUI** — WebView + native bridges (msga pattern). MAUI shell built, TCP/FS/HTTP bridges working, wa-sqlite + IndexedDB bundled, bootstrap module loading being debugged (import map path resolution in WebView). See android.md. Android parity debt lives as S53.
762
+ - [ ] **Word-paste sanitizer** — paste from Word into compose currently drags in `mso-*` styles, fixed widths, and Office-specific fonts that render poorly in other clients' email viewers. Add a clipboard-paste filter: detect `MsoNormal` class / `mso-*` styles / `urn:schemas-microsoft-com` XMLNS and strip. ~1 day. Stepping-stone to any real Word integration (see "Edit in Word…" below) — sanitized paste handles 80% of the motivation for ~5% of the work.
763
+ - [ ] **"Edit in Word…" button** (design decision — not started) — save current compose body as `.docx`, shell-open in Word, watch for changes, convert back on save. Main risks: Word's HTML output is dirty and lossy; round-trip not reversible; OLE/COM embed would need abandoning WebView for compose (weeks of work, Windows-only). Word paste sanitizer above is the pragmatic 80% alternative.
764
+
765
+ ## Phase 2+ (future)
766
+
767
+ - [ ] **iOS** — same WebView + native bridges pattern as Android/MAUI
768
+ - [ ] **Wear OS (Android Watch)** — companion app for quick triage. Possible features: new mail notifications with sender/subject, mark read/archive/delete from wrist, quick reply (voice or canned responses), unread count on watch face complication. Syncs with phone app via Wear Data Layer API — watch doesn't connect to mail servers directly.
769
+ - [ ] **Inline compose** — refactor from navigation to inline panel (no page switch)
770
+ - [ ] READMEs for all packages
771
+ - [ ] **Multiple emails per contact** — show all in autocomplete
772
+
773
+ ---
774
+
775
+ <a id="done-recent"></a>
776
+
777
+ ## Done (recent — version-tagged) [↑ summary](#sum)
778
+
779
+ | Item | Version |
780
+ |---|---|
781
+ | **Migrated from active tables — autonomous queue** — items 1–10, 12, 14–17 from the autonomous-work queue: C24 batch body prefetch (IMAP half via iflow-direct `fetchBodiesBatch`), Android keyboard input lag (rAF yield), Send leaves second compose open (parent postMessage close), prefetch priority (2-way + INBOX-first), custom From history (localStorage), multi-email contacts, duplicate Message-ID indicator (⇆ badge), per-field context menus (search-selected + search-from-sender), attachment drag-out (Chromium DownloadURL), elevated-run warning (`isElevated` + `--allow-elevated`), Send-pending virtual folder (pink-accented top-of-tree row), Copy-as-quoted in viewer menu, `MAILX_SERVER_TOKEN` for non-loopback `--server`, Gmail HTTP `/batch` for body prefetch (100 sub-requests), Android open native Contacts/Calendar via `mailxapi-intent://`. | v1.0.400–407 |
782
+ | **Migrated from active tables — priority** — S52 per-feature primary flag (`primaryCalendar` / `primaryTasks` / `primaryContacts`); S64 pinned-taskbar icon + relaunch (msger PKEY_AppUserModel_RelaunchCommand/Icon/DisplayName via VT_LPWSTR PROPVARIANT + per-window AUMID); S54 narrow half (compose full-screen via `isSmall`); compose full-screen on narrow; word-paste sanitizer (tracked separately in Near-term). | 2026-04-26 / v1.0.376 |
783
+ | **Migrated from active tables — categorized** — C27 unified outbox PARKED 2026-04-23 (stays on filesystem queue with atomic-rename claim); C47 Android send wired + persistent queue (`sync_actions` rows with raw RFC 2822, drained on startup + 2-min poll); C48 multi-instance audit (atomic-rename `.ltr` claim, `instance.json` PID lock, stale-claim sweeper); S4 dally retired 2026-04-23 (Ctrl+Z + compose Save/Discard/Cancel cover loss-recovery). | v1.0.376 |
784
+ | **Migrated from active tables — quick wins** — Q49 Cc/Bcc auto-expand (`hasCcHistoryTo` + new `hasBccHistoryTo`, reply expands rows independently based on Sent-folder usage); Q52 View menu "Reset column widths"; Q66 long-press → contextmenu synthesize on Android; Q67 setup form provider preview (icon + auto-detect message). | v1.0.376–407 |
785
+ | **OAuth reauth flow** — diagnosed 2026-04-23 from the log: tasks API was 403ing with "Request had insufficient authentication scopes". Cached refresh_token predates the scope widening (added `tasks`, upgraded `contacts.readonly` → `contacts`), and Google's silent refresh returns a token for the old scope set rather than forcing re-consent. Three fixes: (1) new `mailx -reauth` CLI command clears `~/.mailx/tokens/<user>/oauth-token.json` so the next start opens the browser consent with the current scope set; (2) service-side 403 on calendar/tasks now emits `authScopeError` with a clear message and the sidebar surfaces it inline (amber banner in the affected pane) instead of showing an empty list; (3) added `[calendar] pulled N events` and `[tasks] pulled N tasks` log lines so future diagnostic reads the current pull count directly. | v1.0.387 |
786
+ | **"Show completed Tasks" checkbox is sticky** — state persists to `localStorage.mailx-task-show-done`. Defaults off; once checked, stays checked across restarts. User-reported 2026-04-23. | v1.0.387 |
787
+ | **Dovecot connection-cap handling** — log analysis 2026-04-23 showed bobma sync failing with "Maximum number of connections from user+IP exceeded (mail_max_userip_connections=20)". The existing 60 s account-level backoff wasn't enough — mailx would race right back into the cap. Three fixes: (1) on `max_userip_connections` detection, now force-close ALL pooled clients (ops, body, openClients set) so our server-side slot count drops to zero; (2) extend the backoff to 5 min (Dovecot tracks slots with a decay window, 60 s leaves stragglers); (3) prefetch's `batch fetch failed` path now routes connection-cap errors through `handleSyncError` so backoff fires from that entry too — previously prefetch just counted it as one more generic error and kept trying. | v1.0.387 |
788
+ | **Contacts autocomplete: query fallback** — `searchContacts` ranked query is now wrapped in try/catch; on any SQLite edge case it falls back to the simple LIKE query so autocomplete can never leave the dropdown blank from a query failure. Defensive only — no behavior change on the happy path. | v1.0.387 |
789
+ | **Calendar + Tasks periodic poll (5 min)** — background tick in `bin/mailx.ts` pulls `getCalendarEvents(now, now+90d)` + `getTasks(false)` every 5 minutes so server-side changes land without a sidebar nav click. Well under the 1M/day quota and 500/100s rate limit. Emits `calendarUpdated` / `tasksUpdated` on changes → sidebar re-renders. Decided 2026-04-23: stay on poll (webhooks need a public HTTPS endpoint; overhead not worth it). | v1.0.387 |
790
+ | **Calendar sidebar: click-to-open + recurring flag + horizon + live refresh** — (1) clicking an event opens its Google Calendar web URL via `mailxapi.openExternal` (interim until in-app editor). Event rows store `htmlLink` in a new column. (2) "Show recurring" checkbox in the sidebar actions area filters expanded recurring-series instances; `recurringEventId` is now a first-class column and propagates from Google Calendar's `singleEvents=true` response. (3) "Horizon" number input (bounded 1..365, default 30) controls how many days ahead to list — previously hardcoded to 30. (4) Service emits `calendarUpdated` / `tasksUpdated` when a background Google pull upserts/reconciles rows; sidebar subscribes via `mailxapi.onEvent` and re-renders. Previously fire-and-forget — UI showed stale empty list even after the refresh had merged events. Also: server-side deletion reconciliation (non-dirty local rows whose `provider_id` didn't appear in the response get purged) + global dedup via new `getCalendarEventByProviderId`. | v1.0.387 |
791
+ | **Theme select: System / Light / Dark** — Settings menu gains a radio group. Rail icon still cycles; menu lets you pick directly. Persists to `localStorage.mailx-theme`. Defaults to System. | v1.0.384 |
792
+ | **Spam-report button (Q100 placeholder)** — new `🚫` button in message-viewer header appends a row to `~/.mailx/spam.csv` (timestamp_ms,date,time,account,delivered_to,from,subject,eml_path). CSV self-describing with a header written on first touch. No folder move, no flag change, no auto-delete — user-flagged 2026-04-23 "make it smart later, address safety first." | v1.0.384 |
793
+ | **Add-contact form with duplicate check** — right-click an email address → "Add to contacts" now opens a small modal with Name / Email / Organization fields instead of silently inserting. `listContacts` pre-check surfaces an "already in address book" banner so re-adding becomes an explicit Update. Routes through `upsertContact` so the two-way cache queues the push to Google People. | v1.0.384 |
794
+ | **Gmail move/delete/trash via API** — previously only flags drained on the Gmail API path; every move/delete/trash action accumulated in `sync_actions` and kept rendering messages as pink-pending forever. Gmail provider now has `trashMessage` + `moveMessage`. `processSyncActions` routes delete/trash/move to them. Unsupported actions drop after 5 attempts instead of re-queueing. On next start, stale rows drain on the first tick and bogus pink clears. | v1.0.382 |
795
+ | **S49 body comingling — permanent fix confirmed** — Message-ID-match guard shipped v1.0.361 is the permanent defense per user 2026-04-23 ("keep the guard"). No separate cache-key redesign. | v1.0.382 |
796
+ | **Prefetch: per-folder error cooldown + INBOX-first ordering** — log analysis 2026-04-23 showed bobma prefetch was timing out (300s) on specific large Dovecot folders (`Added2.organizations`, `Added2.technews`, `_Spam`, `"Prefirst.Jerry's Retreat"`, `Added2.zines`). Each timeout counted against a global 20-error budget and aborted the whole prefetch run before the INBOX could finish — which is why most rows showed `○`. Now: each folder with 2+ errors in the last 15 minutes is skipped until the cooldown passes; successful fetch clears its error history. Folder iteration is also INBOX-first so the folder the user is actually looking at gets its bodies even when a later folder blows up. | v1.0.382 |
797
+ | **Gmail move/delete/trash sync drain + stale-pink cleanup** — previously only `flags` actions drained on the Gmail API path; `move`/`delete`/`trash`/`undelete` entries sat in `sync_actions` forever, which meant the pink-dot LEFT-JOIN still fired for those messages long after the user did the action. Rows that weren't "pending" at all appeared pink (user reported 2026-04-23 with screenshot). Gmail provider now has `trashMessage` (`POST /messages/{id}/trash`) and `moveMessage` (swap labels via `POST /messages/{id}/modify`). processSyncActions routes delete/trash/move to them on Gmail; unsupported actions drop after 5 attempts instead of re-queueing forever. | v1.0.382 |
798
+ | **Calendar/task by-uuid lookup** — `updateCalendarEventLocal` / `deleteCalendarEventLocal` / `updateTaskLocal` / `deleteTaskLocal` were calling `getCalendarEvents("", ...)` / `getTasks("", true)` to find the row by uuid — empty accountId meant the `WHERE account_id = ?` returned nothing, so every update or delete would throw `No calendar event/task <uuid>`. Added `getCalendarEventByUuid` / `getTaskByUuid` and the service methods use them. | v1.0.382 |
799
+ | **Edit allowlist shortcut in remote-content banner** — "Edit allowlist…" button in the expanded banner opens the JSONC editor pre-selected to `allowlist.jsonc`. Covers the "button in message header to view/edit the remote content allowlist" item from P1. | v1.0.382 |
800
+ | **Case-duplicate folder warning** — folder tree compares paths per account lowercased; ⚠ appears on any folder whose case-folded form matches another. Tooltip explains. Prevents losing mail to `Archive` vs `archive` confusion. | v1.0.382 |
801
+ | **Offline indicator** — new `⚡ offline` pill in the status bar shows when `navigator.onLine` is false. Doesn't gate anything (local-first is fine offline); tells the user queued actions will replay on reconnect. | v1.0.382 |
802
+ | **Search qualifiers: date: / has: / is: (web DB path)** — `parseSearchQuery` in `@bobfrankston/mailx-types` now handles `date:` (exact day / month / >date / <date / date..date / today / yesterday / lastN), `has:attachment`, `is:flagged|unread|read|starred` on top of the existing `from:` / `to:` / `subject:`. Desktop already had a richer inline parser in mailx-store; this brings Android/web parity. | v1.0.382 |
803
+ | **Email completion ranking** — `searchContacts` two-tier: exact prefix on name or email-local-part ranks above substring matches, then sort by (use_count × 0.5^(age/30 days)). Recently-messaged contacts float up even when older contacts have more total sends. | v1.0.382 |
804
+ | **JSONC config editor: line-number gutter** — left-side gutter shows line numbers synced to the textarea scroll; validation errors highlight the matching gutter line in red so "Line N, col M" error messages point at a visible marker. Requested 2026-04-23. | v1.0.382 |
805
+ | **Sort by column header click** — From / Date / Subject columns in the list header are now clickable; cycles asc/desc, shows a ▲ or ▼ marker next to the active column. Date defaults desc (newest first), text columns default asc on first click. Per-folder lists reload with server-side sort; unified inbox and search results sort client-side on the fetched page. | v1.0.382 |
806
+ | **`--server` mode restored (C34)** — `mailx --server` now imports `@bobfrankston/mailx-server`, which self-initializes an Express HTTP + WebSocket server on the miscinfo port and skips the WebView2 IPC host. Loopback-only by default; `MAILX_SERVER_HOST=0.0.0.0` (or the package's own `--external` flag) binds publicly. Useful for remote access pre-MAUI and JSON-RPC debugging. | v1.0.382 |
807
+ | **Periodic `drainStoreSync` tick** — `bin/mailx.ts` now runs `svc.drainStoreSync()` every 30s so calendar/tasks/contacts pushes that failed their first attempt (network blip, token refresh, 5xx) retry automatically. Local edits still drain immediately; this just catches stragglers. | v1.0.382 |
808
+ | **Android S57 partial — INBOX-first syncAccount** — `web-service.syncAccount` no longer blocks on a serial `for (folder of sorted) { await … }`; awaits INBOX, then fires remaining folders in the background. UI gets new INBOX mail fast even when a slow label (`[Gmail]/Jerrry`) would previously hold everything up. | v1.0.382 |
809
+ | **Rail unread badges** — Inbox and All-Inboxes rail buttons show a small red pill with the total unread count. Part of C33 "rail icon badges for unread counts." Visible even when those views aren't active. | v1.0.382 |
810
+ | **Task delete button** — each task row in the calendar sidebar has an × button that appears on hover; click deletes via the two-way cache (pushes delete to Google Tasks). | v1.0.382 |
811
+ | **"Only this conversation" View-menu toggle** — new View-menu checkbox filters the current message list to rows sharing the selected message's `threadId`. Client-side only (no IPC round-trip). Completes the thread-features trio from the P1 item (reply accent + View thread button already shipped v1.0.376). | v1.0.382 |
812
+ | **Android two-way cache tables** — Android's sql.js DB now has the same `calendar_events`, `tasks`, and `store_sync` tables as desktop. Groundwork for Android calendar/tasks parity — the service/API wiring to drive them is still desktop-only, but a device that drops and re-seeds its DB picks up the schema. | v1.0.382 |
813
+ | **Two-way cache for calendar / tasks / contacts (desktop)** — new `calendar_events` and `tasks` tables in the local SQLite store with the same shape as messages (`uuid` + `provider_id` + `etag` + `dirty` + `deleted` + `last_synced`). Contacts two-way uses the existing contacts table. New generic `store_sync` queue drains push-to-Google actions (create/update/delete) for all three domains. `google-sync.ts` wraps Google Calendar / Tasks / People APIs (listCalendarEvents / createCalendarEvent / updateCalendarEvent / deleteCalendarEvent and the equivalents for tasks and contacts). Service methods `getCalendarEvents` / `createCalendarEventLocal` / `updateCalendarEventLocal` / `deleteCalendarEventLocal` + same for tasks commit locally and enqueue the server push. `drainStoreSync` retries failed pushes. OAuth scope broadened from `contacts.readonly` to `contacts`, and `tasks` added. Calendar sidebar rewritten off `localStorage` onto the two-way cache, so cal events and tasks live in the service DB and survive browser-data clears, and other devices can see them on sync. New "+ Task" button in the sidebar. Addresses the principle "all local stores must be two-way caches." Android side (web-service.ts) still reads local-only — same table shapes need to go into the web DB for full parity; desktop is the template. | v1.0.382 |
814
+ | **Rail darkened (Thunderbird Supernova style)** — icon rail uses a dark oklch tone with light icons instead of the previous subtle light-gray. Contrasts visibly against content in both light and dark themes; the rail now reads as "chrome" like Thunderbird's. Hover/active/disabled states darkened/lightened accordingly. | v1.0.382 |
815
+ | **S52 — per-feature primary flag** — `AccountConfig.primaryCalendar` / `primaryTasks` / `primaryContacts` (all optional, all boolean) alongside the catch-all `primary`. `service.getPrimaryAccount(feature?)` now resolves per-feature → primary → first-account. Back-compat: existing callers that pass no `feature` get the old single-flag behavior. Settings normalize + JSON-RPC dispatcher + client `api-client.getPrimaryAccount("calendar")` + mailxapi bridge all pass through. Calendar sidebar now asks for `"calendar"` so users with different calendar and contacts accounts can split the sources. Android bridge mirrors the desktop semantics locally (no IPC round-trip). | v1.0.376 |
816
+ | **C32 half — loud schema-migration guard** — `MailxDB` constructor now calls `verifySchema()` after all `addColumnIfMissing` migrations complete. If any required column (`messages.thread_id`, `messages.provider_id`, `messages.uuid`) is still missing, throws with message naming `mailx -rebuild` as the fix. Replaces the cryptic "no such column: thread_id" error that stalled the Linux bring-up (2026-04-13) with a clear one-line diagnosis. | v1.0.376 |
817
+ | **Reply-row accent + "View thread" button in viewer + long-press context menu on phone** — message rows with `inReplyTo` set get a subtle left-edge blue accent so threaded replies stand out even with thread-grouping off (no full "conversation view" work needed to see conversation structure at a glance). Viewer toolbar gains a 💬 "View thread" button that opens the thread popup from any message whose `threadId` is known — previously the only path was clicking the thread-size pill on the list head row. Long-press on touch now synthesizes a `contextmenu` event on message-list rows so the existing right-click menu (Mark read/unread, Flag, Reply, Forward, Move, Delete) works on Android without needing a separate touch UI. (Q66 + P18 partial.) | v1.0.376 |
818
+ | **Android send now persistently queued** — `AndroidSyncManager.queueOutgoingLocal` now writes the raw RFC 2822 message to the `sync_actions` table (action="send", unique neg-timestamp uid, rawMessage column — same table as move/flag/delete queue) BEFORE kicking off Gmail API / SMTP. On success: `completeSyncActionByUid`. On failure: `failSyncActionByUid` (attempts++, last_error stored). New `processSendQueue(accountId)` drains stranded entries on startup AND every 2-minute periodic poll. User-flagged 2026-04-23 — desktop persists to `~/.mailx/outbox/<acct>/*.ltr` before SMTP; Android now has the equivalent via sql.js → IndexedDB. Crash / offline / killed-process mid-send no longer drops the message. | v1.0.376 |
819
+ | **Calendar sidebar: visible by default, no reserved-column ghost when hidden** — body grid no longer silently reserves a `cal-side` column (the old `body.calendar-sidebar-on` rule was empty — CSS placed the aside in an implicit track, showing as a blank upper-right strip with a small calendar at the bottom). `body.calendar-sidebar-on` now actually redefines `grid-template-columns` + `grid-template-areas` to include a 4th column on the right; when off, grid stays 3-column and the aside is `display:none`. Default flipped from off → on — the user must explicitly uncheck to hide. Narrow/mid-width tiers shadow the rule so the grid doesn't break below 1100px. | v1.0.376 |
820
+ | **Pink-row reconciliation state shows in All Inboxes + dot-style presentation** — `getUnifiedInbox` was missing the `LEFT JOIN sync_actions` that `getMessages` has (copy-paste from before S1 slice C landed); unified view never showed pending rows. Added. Also changed presentation: pink now colors the existing date-column download-state dot (same "circle" as the teal/blue download dot) instead of painting the whole row. Whole-row pink fought the blue accent on selection. User feedback 2026-04-22. | v1.0.375 |
821
+ | **Overlapping View / Settings dropdowns + About-dialog close ✕** — clicking View while Settings was open left both dropdowns visible (each `e.stopPropagation`'d so the document-level closer never fired). Each toolbar-menu button now closes its siblings before toggling. About dialog gained a top-right ✕ close (matching Calendar/Tasks modals); primary modal button hover now has a visible border-ring so it can't blend into the background on any theme. | v1.0.373 |
822
+ | **S52 — primary-account flag plumbed** — `AccountConfig.primary?: boolean`; settings normalize + service.getAccounts pass through. New `service.getPrimaryAccount()` (first `primary:true` account, fallback to first). IPC + `api-client.getPrimaryAccount()`. Calendar / Tasks / Contacts can ask "which Google source?" without grep'ing accounts.jsonc. Per-feature flags / multi-calendar comingling deferred. | v1.0.372 |
823
+ | **S51 (slice 1) — calendar sidebar UI** — new `client/components/calendar-sidebar.ts` Thunderbird-Lightning-style right-docked panel. View menu adds "Calendar sidebar" checkbox; persists to localStorage. Auto-hides on screens narrower than 1100px (Android uses native calendar). Day-grouped event list ("Today" / "Tomorrow" / "Friday April 24"), prev/today/next navigation, "+ New event" prompt, "Show completed Tasks" + tasks list at the bottom. Local events work; Google Calendar live-data is the seam (`fetchUpcoming` has the hook for service-side `fetchGoogleCalendarEvents` proxy — not yet implemented). | v1.0.372 |
824
+ | **EML Source path fixed + Unsubscribe always tries POST** — viewer's "Source" path no longer synthesized from `(folderId, uid)` (legacy layout); now reads `envelope.bodyPath` (real UUID file). Unsubscribe button always attempts POST when an HTTPS URL is present, not only when `List-Unsubscribe-Post: One-Click` was advertised — many senders skip the header but accept POST. Fallback to opening in a tab on failure. | v1.0.371 |
825
+ | **Mark-as-spam UNIQUE-constraint error fixed** — `db.updateMessageFolder` now checks for an existing row at the target before UPDATEing; if one exists (Gmail's hash-UID collisions across labels, or move-already-applied state) it deletes the source row instead. The move is logically already done; raising "UNIQUE constraint failed" was hostile UX. | v1.0.369 |
826
+ | **emptyFolder badge bug** — emptying a folder removed the rows but the folder-tree unread badge stayed at the pre-empty count because `service.emptyFolder` wasn't calling `recalcFolderCounts` or emitting `folderCountsChanged`. Both added. | v1.0.367 |
827
+ | **Plan 4 — S1 slice C: pink-row reconciliation state** — `getMessages` now does a LEFT-JOIN-style EXISTS against `sync_actions`; rows with a queued local action carry `pending: true` in the envelope. Message-list adds `.pending-reconcile` class; CSS paints pink (deeper pink when also selected). User can see "this row hasn't been ACK'd yet" without opening any panel. | v1.0.365 |
828
+ | **Plan 5 — Slice D lightweight: viewer reacts to list mutations** — when `setMessages` swaps the list and the prior-selected message is no longer in it, `setMessages` already deselected; viewer now also notices "messages" change and clears the preview pane. Closes the search-shows-stale-preview bug class without the full row-objects refactor. The bigger refactor (true row objects with `focus()` / `unfocus()`) is still on the list, but the practical race is fixed. | v1.0.365 |
829
+ | **Auto-seed contacts from received messages** — `db.seedContactsFromMessages()` now runs after the initial sync settles AND every 30 minutes. Address autocomplete in compose (already wired with 200ms debounce, Tab/Enter accepts, ArrowUp/Down navigates) now works on first compose after a fresh DB wipe instead of returning empty. | v1.0.365 |
830
+ | **Diagnostics badge** — per-account `{inactivityTimeouts, connCapHits, rateLimitWaits, lastCommand}` collected by `recordError` in mailx-imap, exposed via `getDiagnostics` IPC, polled every 15s by the client and rendered as a ⚠ in `#status-diag` next to the sync status with full per-account breakdown in the tooltip. Inactivity timeouts indicate Dovecot dropped a socket mid-FETCH — used to be buried in the log; now visible. | v1.0.364 |
831
+ | **Plan 1+2 — sync no longer lies, parallel folder sync** — Step 3 sync replaced with a 2-worker parallel pool, per-folder 60s wall-clock cap. New per-folder progress event format ("folders:&lt;path&gt;" + "folders-done") — UI status pill renders as "Syncing &lt;acct&gt;: folders — &lt;path&gt; (47%)" so the user sees forward motion instead of a frozen "Syncing...". Stuck Dovecot socket no longer blocks the rest. | v1.0.363 |
832
+ | **Plan 3 — S1 slice B: Message-ID move-detection** — `upsertMessage` now checks for an existing row with the same Message-ID in any folder of the account before inserting. If found → rebind: update `folder_id` + `uid` on the existing row, keep UUID + body_path + flags. Saves a body re-fetch on every server-side move; preserves any local references that point at the UUID. Logs `[move-detect]` on each rebind. | v1.0.363 |
833
+ | **Search-clears-preview band-aid** — when a search starts, dispatches `mailx-clear-viewer` so the prior selection's preview doesn't linger over the new results. Will be subsumed by Slice D (row-objects-own-preview). | v1.0.363 |
834
+ | **S1 slice A — stable local message UUID** — new `uuid` column on `messages` (unique index), minted once at first-sight in `upsertMessage`. Backfill pass on startup for pre-existing rows (no-op after first run). Envelope now exposes both `uuid` (stable local identity) and `bodyPath` (authoritative on-disk location). New `getMessageByUuid(uuid)` lookup. `(account_id, folder_id, uid)` stays as the server-binding metadata — they'll diverge from uuid on moves/UID renumbers, which is exactly the point. Foundation for slice B (Message-ID move detection) and slice C (pink-row visible reconciliation). | v1.0.362 |
835
+ | **Body-store disk filenames decoupled from semantics (S49 real fix)** — `FileMessageStore` used to write `{base}/{account}/{folderId}/{uid}.eml`. UID reuse + folder moves meant two messages could point at one filename; S49 Message-ID guard was a patch on the read side. Now every `putMessage` mints a fresh UUID (`{base}/{account}/<xx>/<uuid>.eml`) — filenames never reused. DB's `body_path` is the sole authority on body location. All read/delete callers migrated to `readByPath` / `hasByPath` / `unlinkByPath` (path-addressed, with directory-traversal guard). Legacy `(folderId, uid)` methods throw so any stray caller surfaces loudly. User will wipe `~/.mailx/` to start fresh. | v1.0.361 |
836
+ | **Tombstones — slice 1 of the reconciliation refactor** — new `tombstones` table (`account_id`, `message_id`, `deleted_at`, `subject`); `db.addTombstone` / `hasTombstone` / `removeTombstone` / `pruneTombstones`. Delete/deleteMessages write a tombstone before the IMAP move; undelete removes it; `storeMessages` skips any Message-ID with a tombstone on the next sync. 30-day age-out. Closes the "deleted messages sometimes reappear after sync" class. Stable-UUID and move-detection come in subsequent slices. | v1.0.360 |
837
+ | **Dead per-provider `spam` path removed** — `mailx-settings/index.ts`'s PROVIDERS table had hard-coded `spam: "Junk Email"` / `"SPAM"` / `"Bulk Mail"` / `"Junk"` per domain. Dead since v1.0.352 when `markAsSpamMessages` switched to `specialUse === "junk"` from the DB (tagged by mailx-imap via iflow-direct's `getSpecialFolders()`, which uses RFC 6154 `\Junk`/`\Spam` flags and falls back to iflow's own `defaultFolders` for servers like Dovecot). Field removed from ProviderDefaults, AccountConfig passthrough, and service.getAccounts. | v1.0.359 |
838
+ | **Spell-check re-asserted** — Quill was clearing `spellcheck=true` on its root asynchronously after init; our one-shot setAttribute lost the race. Now set + re-asserted via requestAnimationFrame + setTimeout(100) + MutationObserver that re-applies on any clear. | v1.0.358 |
839
+ | **S10 FIXED** Paste-URL-twice — Quill's clipboard module is a parallel paste listener; our `preventDefault` didn't stop it. Capture-phase + `stopImmediatePropagation` when we handle. URL-as-text-in-html shortcut for Chrome address-bar copies. | v1.0.357 |
840
+ | **saveDraft / all iframe IPCs** — generic parent-relay for every IPC from the compose iframe. `ipc()` in api-client auto-detects iframe context and returns a Proxy bridge that posts every method call via `mailx-ipc` to the parent. | v1.0.356 |
841
+ | **S2 FIXED** Compose sendMessage IPCs were being dropped by msger's iframe WebView bridge. Parent-relay: iframe posts `mailx-compose-send`, parent invokes real sendMessage on its working bridge, posts result back. End-to-end 5ms IPC, 16ms click-to-close. | v1.0.354-355 |
842
+ | **`sending/<acct>/attempted/`** — unconditional debug backup copy the moment `queueOutgoingLocal` runs. Every send attempt that reaches Node leaves a durable `.eml` trace. | v1.0.350 |
843
+ | **Client-side tracing** — `logClientEvent` IPC + postMessage relay. `[client] <tag> <data>` lines in Node log; `(via-relay)` suffix diagnoses iframe bridge failure. | v1.0.352-353 |
844
+ | **Cc/Bcc toggle actually hides rows** — `.compose-field { display: flex }` was overriding HTML `hidden` attribute. Added `.compose-field[hidden] { display: none }`. Cross-platform fix. | v1.0.355 |
845
+ | **Tasks modal Esc-to-close** — earlier guard blocked Esc when quickadd input had focus. | v1.0.352 |
846
+ | **Sent column From→To regression** — case-insensitive specialUse match, handles nested paths. | v1.0.352 |
847
+ | **markAsSpamMessages** via `specialUse` — no longer requires explicit `account.spam`. | v1.0.352 |
848
+ | **Compose Send waits for ACK** — keeps compose open with inline error on IPC failure instead of silently dropping. | v1.0.350-352 |
849
+ | **Android APK build script** — `android-maui/build-apk.cmd`: one-command dotnet build + copy + versions.json bump. Reads version from csproj. | v1.0.350 |
850
+ | **P20** Server-search orthogonal checkbox beside search bar; scope → server when checked. | v1.0.345 |
851
+ | **Q50** About dialog Version row links to GitHub release tag. | v1.0.345 |
852
+ | **Q63** Compose Cc/Bcc toggle buttons `tabindex="-1"` — Tab walks From→To→Subject→body. | v1.0.345 |
853
+ | **Visible outbox** (pink-row view) — click `status-queue` pill → modal listing every `.ltr` with From/To/Subject/Date/retry-badge/sending-badge/Cancel. | v1.0.344 |
854
+ | **C26/S1** `listQueuedOutgoing` + `cancelQueuedOutgoing` IPC methods. | v1.0.344 |
855
+ | **Q64** Double-click message row → popout floating overlay, draggable + resizable + close. | v1.0.344 |
856
+ | **C29** Right-click links in body iframe → Open / Save-as / Copy URL / Copy link-text. | v1.0.344 |
857
+ | **C38** `getOpsClient` pre-checks socket liveness — catches Dovecot silent-IDLE-drops earlier. | v1.0.344 |
858
+ | **C36** Compose right-click selection → "Proofread selection" via `aiTransform`. Gated by Settings toggle. | v1.0.343 |
859
+ | **C42** Per-account signature in `accounts.jsonc`; appended on new/reply/forward (not draft). | v1.0.343 |
860
+ | **C45** Search qualifiers: `date:` / `after:` / `before:` / `has:attachment` / `is:flagged\|unread\|read\|answered\|draft` / `folder:name`. | v1.0.343 |
861
+ | **P15** JSONC editor Del key — global handler skips when focus inside INPUT/TEXTAREA/contenteditable. | v1.0.343 |
862
+ | **Q53** Per-account last-sync timestamps in `status-sync` tooltip, refreshed every 30s. | v1.0.343 |
863
+ | **Q54** Account-header right-click: Mark all read / Expand·Collapse all / Sync now. | v1.0.343 |
864
+ | **Q56** Viewer "Details" rows each have Copy (⧉) button. | v1.0.343 |
865
+ | **Q65** Alert banner auto-dismiss after 30s for non-critical banners. | v1.0.343 |
866
+ | **Q68** Compose unsaved-draft `•` marker in window title; cleared on save. | v1.0.341 |
867
+ | **P14** Auto-id/label from email local-part in `normalizeAccount`. | v1.0.341 |
868
+ | **C30** Concurrent prefetch guard — `prefetchingAccounts` Set. | v1.0.341 |
869
+ | **Q3** Paste image inline — clipboard image → `data:` URL. | v1.0.341 |
870
+ | **Q55** Ctrl+Enter in compose triggers Send. | v1.0.341 |
871
+ | **Q57** Right-click folder → Copy folder path. | v1.0.341 |
872
+ | **Q58** Message-list keyboard nav: Home / End / PgUp / PgDn. | v1.0.341 |
873
+ | **Q59** Compose autosave on window blur. | v1.0.341 |
874
+ | **Q61/Q62** Settings → Open mailx folder / Open log. | v1.0.341 |
875
+ | Address book / Calendar / Tasks panes — rail buttons enabled, local-only stores. | v1.0.341 |
876
+ | `windowsHide:true` on every spawn/execSync — no cmd-window flash. | v1.0.341 |
877
+ | Hover link tooltip — 500ms delay, suppressed when compose/modal open. | v1.0.341 |
878
+ | IPC timing instrumentation — `[ipc] → <action> ok in Nms`, `[send] +Nms <step>`, `[outbox] WROTE <path>`. | v1.0.341 |
879
+ | `saveDraft` + `markAsSpamMessages` use cached accounts — no GDrive stall on critical path. | v1.0.341 |
880
+ | `queueOutgoingLocal` kicks `processLocalQueue` via `setImmediate` — IPC ack returns before queue scan. | v1.0.341 |
881
+ | Auto-create `~/.mailx/sending/README.md` on startup. | v1.0.339 |
882
+ | Outbox status indicator in status bar — event-driven from `outboxStatus` event. | v1.0.338 |
883
+ | `getOutboxStatus()` service method scans outbox + sending-queued dirs. | v1.0.338 |
884
+ | Body-comingling guard (S49 DIAG) — Message-ID match in `fetchMessageBody`. | v1.0.338 |
885
+ | accounts.jsonc banner fix (S50) — hash-compare + cloud-poll JSONC-semantic compare. | v1.0.338 |
886
+ | Disk-first durable send queue — `.ltr` written synchronously before `processLocalQueue` kick. | v1.0.335 |
887
+ | Compose Ctrl+scroll / Ctrl+=/−/0 zoom, persisted. | v1.0.335 |
888
+ | S3 Reply-opens-blank band-aid — `openCompose` populates unconditionally from local DB. | v1.0.337 |
889
+ | S6 Spam/Move target empty — imap-layer now queue-only, no double-wipe. | v1.0.334 |
890
+ | S8 `history: 0` truncated — Gmail 200-id cap removed, IMAP 90-day cap removed, lazy chunked backfill. | v1.0.331-333 |
891
+ | P11 Server search spans all folders + materializes unknown UIDs. | v1.0.331 |
892
+ | P12 Unsubscribe fallback + diagnostic error. | v1.0.332 |
893
+ | P21 JSONC editor X close + resize corner. | v1.0.330 |
894
+ | P22 Windows taskbar per-app icon + unread badge. | v1.0.322 |
895
+
896
+ ## Done
897
+
898
+ - [x] **Prefetch "downloaded" indicator never updated** (2026-04-14, mailx v1.0.254, mailx-imap v0.1.6) — `prefetchBodies` was silently writing `body_path` to the DB but never emitting any event. The client's open-circle (○) → filled-teal-circle (●) indicator only refreshes when a `folderCountsChanged` event arrives, and prefetch wasn't sending one. So even though bodies were cached on disk, the UI kept showing them as "not downloaded" until the user navigated away and back. Fix: emit `folderCountsChanged` (a) per batch when progress was made, and (b) once at the end of the prefetch loop. Reuses the client's existing debounced silent-reload path so scroll position and selection are preserved.
899
+
900
+ - [x] **OAuth "Waiting for localhost" hang on Linux fixed** (2026-04-13, oauthsupport v1.0.23) — `OAuthTokenManager.ts:338` was `server.listen(port, () => ...)` with no host. Node bound to `::` (IPv6 unspecified) which on Linux is IPv6-only by default (`IPV6_V6ONLY`), while Linux resolves `localhost` to `127.0.0.1` first via `/etc/hosts` — so the browser's GET to 127.0.0.1:9326 never reached the server. Windows dual-stacks transparently so it never showed up there. Fix: bind explicitly to `'127.0.0.1'`. Also updated the log line to show the bound address. Test: Linux mailx OAuth callback now responds instead of hanging at "Waiting for localhost".
901
+
902
+ - [x] **Narrow-mode preview-at-bottom bug fixed** (2026-04-13, mailx v1.0.252) — On both desktop and Android, navigating to a folder in narrow mode showed the message-viewer stacked beneath the message list (despite a `@media (max-width:768px) .message-viewer { display: none }` rule in `layout.css`). Cause: stylesheet load order — `layout.css` was loaded BEFORE `components.css`, which has the same-specificity `.message-viewer { display: flex }` default. Same-specificity → cascade order wins → flex won, narrow display:none lost. Back-from-message worked only because the cleared viewer was an empty flex box. Fix: swap link order in `client/index.html` and `client/android.html` so `layout.css` loads after `components.css` and wins on cascade.
903
+
904
+ - [x] **Linux Gmail credentials lookup fix** (2026-04-13, mailx v1.0.250+) — `mailx-imap/index.ts:368` was using `import.meta.resolve(...).replace("file:///", "").replace("file://", "")` to strip the URL scheme. On Windows this works because the path keeps its drive letter (`c:/...`). On Linux, `file:///usr/local/.../index.js` becomes `usr/local/.../index.js` (leading slash eaten) — a relative path. `fs.existsSync` then silently fails, the iflow-credentials fallback never updates `credPath`, and OAuth dies with "Credentials file not found: /home/user/.mailx/google-credentials.json". Fix: use `fileURLToPath` from `node:url` instead of string-replace. Verified the new logic works on Linux via direct test: resolves to absolute path, `fs.existsSync` returns true.
905
+ - [x] **msger MIME bug for query-string URLs fixed** (2026-04-13) — `msger-native/src/main.rs:65 guess_mime` did `path.rsplit('.').next()` to extract the extension. Any URL with `?query` or `#fragment` (e.g. `index.html?account=gmail`) returned `"html?account=gmail"` which didn't match `"html"`, so it fell through to `application/octet-stream` and the browser rendered the response as raw `<!DOCTYPE html>...` text. Fix: split off `?` and `#` before extension extraction. **Source patched only** — msger binary needs `cargo build --release` to take effect, which I haven't run from this session. TODO entry below for the rebuild.
906
+
907
+ - [ ] **Linux deploy hardening (parked 2026-04-13)** — Several unresolved threads from today's Linux session, parked while focus stays elsewhere:
908
+ - **Stale `mailx-server` process pile-up** — found 8+ `node --watch mailx-server/index.js` processes accumulated on rmf69a. Origin unknown — some old systemd/cron/PM2 startup config that keeps spawning them. Mailx -kill on Linux didn't sweep them (matched the regex but the user-owned ones survived for whatever reason). Need to find and disable the spawner.
909
+ - **DB malformed under multi-process writers** — `database disk image is malformed` after the process pile-up wrote concurrently. Manual ALTER + WAL across versions also contributed. Wipe-and-resync recovers. Real fix is the unified outbox / multi-instance design (above).
910
+ - **Cross-platform `mailx -kill` rewrite** — current Windows path uses powershell+taskkill, Linux uses fuser. Replace with pure Node using `process.kill(pid, 0)` + `~/.mailx/instance.json` registry. Captures msger child PIDs so kill cleans up orphan msgernative processes too. See in-conversation discussion 2026-04-13. Same code on Windows / Linux / macOS.
911
+ - **Linux x64 msger native binary not built** — msger publishes Windows + Pi ARM64 (`OK windows / OK pi-arm64 / 2/2 builds successful`). rmf69a is Linux x64 — apparently already has a binary from somewhere, but new fixes (e.g. today's MIME query-string fix) won't reach it via npmglobalize until a Linux x64 builder is added.
912
+ - **OAuth callback robustness** — `EADDRINUSE :::9326` killed the auth flow when port was held. Should fall back to a different port range or surface "port unavailable" up to the UI.
913
+
914
+ - [ ] **Rebuild + republish msger native binary** — Pick up the MIME fix at `msger-native/src/main.rs:65`. Needs `cargo build --release` from `Y:\dev\utils\msgx\msger\msger-native\` then publish via the msger build script (likely `_build-release.cmd`). Without this, any URL with query string served via `msger.localhost://` still renders as raw text.
915
+
916
+ - [x] **Linux thread_id schema crash fixed** (2026-04-13, mailx v1.0.250, store v0.1.4) — `mailx-store/db.ts:65 SCHEMA` had `CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)` baked in. On any DB created before the thread_id column existed, `db.exec(SCHEMA)` would throw "no such column: thread_id" because `CREATE TABLE IF NOT EXISTS` no-ops on existing tables, leaving the column absent — but the index statement runs unconditionally. The constructor died before reaching the `addColumnIfMissing` migration that would have added the column. Fix: removed the index from SCHEMA; the migration block (which runs after the column add) creates it via `CREATE INDEX IF NOT EXISTS`. Discovered via Linux mailx logs at `~/.mailx/logs/mailx-2026-04-13.log` showing `Starting mailx service... ERROR Error: no such column: thread_id` two lines apart and nothing else.
917
+
918
+ - [x] **iflow IDLE auto-suspend** (2026-04-13) — `iflow/imaplib/imap-native.ts` now auto-pauses IDLE (DONE/wait OK) before any other command on the same connection and re-enters IDLE after. Fixes mailpuller's 6m10s hang where a fallback STATUS poll during IDLE silently waited for the 300s inactivity timer. mailx unaffected today (its watchClient never issues commands) but now defensive against future shared-connection use. Also fixed latent races by arming continuation/tagged listeners before writing IDLE/DONE.
919
+ - [x] **TCP transport extracted into its own package family** (2026-04-13, mailx v1.0.249) — `MailApps/tcp-transport/` (`@bobfrankston/tcp-transport` v0.1.0) holds the platform-agnostic `TcpTransport` interface + `TransportFactory` type + `BridgeTcpTransport` (msgapi.tcp WebView impl). `MailApps/node-tcp-transport/` (`@bobfrankston/node-tcp-transport` v0.1.0) holds `NodeTcpTransport` (node:net/node:tls). Mirrors the pre-existing UDP family at `y:/dev/homecontrol/utils/udp/{udp-transport,node-transport,browser-transport}/`. Old packages (`iflow-direct/transport.ts`, `iflow-direct/bridge-transport.ts`, `iflow-node/`) are now back-compat shims that re-export from the new packages with the old names (`ImapTransport` = alias for `TcpTransport`, `BridgeTransport` = alias for `BridgeTcpTransport`, `NodeTransport` = alias for `NodeTcpTransport`). New code uses the new names directly. Updated callers: `bin/mailx.ts`, `mailx-imap`, `mailx-store-web/android-bootstrap.ts`, `smtp-direct`, `imail/iflows.ts`, `puller/Imapper.ts`. iflow-direct → v0.1.14, iflow-node → v0.1.4 (now a shim). Removes the awkward semantic where SMTP code had a runtime dep on iflow-direct just to import a TCP type.
920
+ - [x] **`smtp-direct` package created + mailx swap** (2026-04-13, mailx v1.0.248) — `MailApps/smtp-direct/` (`@bobfrankston/smtp-direct` v0.1.0), sibling to iflow-direct. Transport-agnostic SMTP client built on iflow-direct's `ImapTransport` + `TransportFactory` interface (same TCP byte-stream — IMAP and SMTP can share the transport even though their protocols don't). Implements RFC 5321 (SMTP) + 3207 (STARTTLS) + 4954 (AUTH) with PLAIN/LOGIN/XOAUTH2. Per-recipient RCPT errors collected (partial accept = success). Symlinked into both `MailApps/node_modules/` and `mailx/node_modules/` via `mklink /J`. Then swapped both mailx surfaces:
921
+ - **Desktop** — `mailx-imap/index.ts:sendRawViaSMTP` now uses `SmtpClient` over the same `TransportFactory` (NodeTransport from iflow-node) that IMAP uses. nodemailer dependency dropped from `mailx-imap/package.json`.
922
+ - **Android** — `mailx-store-web/android-bootstrap.ts:queueOutgoingLocal` now sends real SMTP for non-Gmail accounts via `SmtpClient` over `BridgeTransport` (mailxapi.tcp). Gmail accounts still take the simpler REST API path. The previous "throws not-implemented" branch is gone.
923
+ - **resender** still uses nodemailer per "no urgency to switch" — listed as future swap.
924
+ - [x] **Outbox claim races closed** (2026-04-13, v1.0.247) — first concrete pieces of the unified outbox design above. Two changes in `mailx-imap/index.ts`: (1) Local-file (Gmail) queue: `processLocalQueue` now atomically renames each `.ltr` to `<file>.sending-<host>-<pid>` before the SMTP call. Loser of a same-machine race sees ENOENT and skips; previously the Message-ID dedup was best-effort and could let two concurrent processes both pass the check before either recorded. Recovery sweeper at the top of each tick reclaims `.sending-<thishost>-<pid>` files whose PID is dead. (2) IMAP-folder (non-Gmail) queue: `processOutbox` claim flag is now `$Sending-<host>-<sec>` with a stale-sweeper that drops claims older than 1 hour; previously a crashed claimer would pin a message in Outbox indefinitely. The TOCTOU window in the read-add-read-flag dance still exists but fails safe (both racers see ≥2 claim flags and back off; next tick one wins). Full UID-MOVE-based atomic claim is still in the unified-design TODO above.
925
+ - [x] IPC mode activated — msger.localhost custom protocol, navigation handler, compose in-window
926
+ - [x] Gmail API provider — REST sync replaces IMAP for Gmail accounts (faster, no connection limits)
927
+ - [x] Outlook API provider — Microsoft Graph API skeleton (needs Azure app registration)
928
+ - [x] Shared message state — message-state.ts: list/viewer share message objects, auto-select on delete/move
929
+ - [x] Prefetch setting — `sync.prefetch: true` default, background body download after sync
930
+ - [x] IMAP timeout 30s→60s, INBOX retry up to 5 attempts with backoff
931
+ - [x] Window size/position persistence — saved to .msger-window.json on close, restored on open
932
+ - [x] Compose rich text toolbar — font family, size, color, background color, alignment (Quill)
933
+ - [x] Stale viewer/attachment fix — viewer clears on folder switch, delete, move
934
+ - [x] RFC 2047 folding fix — whitespace between encoded words stripped per §6.2
935
+ - [x] URL opening via rundll32 — no cmd.exe quoting issues
936
+ - [x] Honest error messages — no "Authentication may have expired" guessing
937
+ - [x] Fast shutdown — 2s per-connection timeout, 3s hard exit
938
+ - [x] Gmail outbox connection storm fix — respects backoff, skips failing accounts
939
+ - [x] Gmail send via SMTP from local queue (no IMAP outbox needed)
940
+ - [x] Outbox/sending dirs moved to ~/.mailx/ (preserved by ager)
941
+ - [x] Quoted-printable encoding for outgoing mail (readable debug .eml files)
942
+ - [x] Dead connection detection — auto-reconnect on socket close/error
943
+ - [x] Console window hidden after IPC launch (terminal freed, --verbose to keep)
944
+ - [x] Daemon mode — `mailx` returns terminal immediately, re-spawns detached with --daemon
945
+ - [x] Sync doesn't steal focus — folderCountsChanged only updates badges, never reloads message list
946
+ - [x] Identity domain reply-from — per-account `identityDomains` in accounts.jsonc, auto-sets From on reply based on Delivered-To match
947
+ - [x] Reply From includes display name — `Bob Frankston <addr@domain>` not bare address
948
+ - [x] Prefetch runs continuously — fetches all missing bodies in one pass, not 25 per cycle
949
+ - [x] Gmail API 500 retry — retries on server errors with backoff (same as 429)
950
+ - [x] Undefined IPC guard — ignores malformed messages instead of crashing
951
+ - [x] Unsubscribe button fix — uses window.open() click handler for IPC mode compatibility
952
+ - [x] Debug sending copies — ~/.mailx/sending/<accountId>/{editing,queued,sent}/ with timestamped .eml files. Editing keeps last 3. Temporary debug safety net — normal flow uses IMAP Drafts/Outbox/Sent.
953
+ - [x] Mark read/unread — R key toggle + right-click context menu
954
+ - [x] Auto-expand folder on drag hover — 500ms delay, expands collapsed folders
955
+ - [x] Right-click context menu on message list — mark read/unread, flag, reply, forward, delete
956
+ - [x] Right-click context menu on viewer header — copy address, reply, forward
957
+ - [x] Keyboard navigation — arrow keys up/down to navigate message list
958
+ - [x] Show link URL on hover — status bar shows URL when hovering links in email body
959
+ - [x] Status bar debug info — shows account/uid/folder when message is selected
960
+ - [x] Non-modal compose — floating iframe, draggable title bar, resizable, multiple compose windows
961
+ - [x] Gmail API provider platform-independent — no Buffer dependency, uses atob/TextDecoder
962
+ - [x] Client registration — clients.jsonc on GDrive with device info, accounts, version, IP
963
+ - [x] SMTP credential fallback — uses IMAP credentials when SMTP not explicitly configured
964
+ - [x] Local-first move — DB updated immediately on drag-move, IMAP sync in background
965
+ - [x] Folder sort by name — Drafts, Sent Items, etc. sort correctly even without specialUse flag
966
+ - [x] Single install — npm workspaces + npmglobalize
967
+ - [x] Cross-platform launcher — WebView2 (Windows), webkit2gtk (Linux) via msger/wry/tao
968
+ - [x] CI/CD — rust-builder auto-builds per platform
969
+ - [x] Cloud storage — GDrive API for shared settings, local cache for offline
970
+ - [x] Unsubscribe button — List-Unsubscribe header (mailto + https)
971
+ - [x] Move mailsend under mailx — packages/mailx-send
972
+ - [x] mailx-settings publishable with npmglobalize
973
+ - [x] No mlconfig dependency
974
+ - [x] Platform-independent settings path — ~/.mailx/config.jsonc pointer
975
+ - [x] Direct WebView2 launcher (Rust/wry/tao) with -dev/-prod/-restart, lock file
976
+ - [x] Window size/position persistence with DPI scaling
977
+ - [x] Embedded icon in exe for taskbar pin
978
+ - [x] mailxapi injected in WebView2
979
+ - [x] Localhost URLs stay in WebView, external links open system browser
980
+ - [x] Compose with Quill rich text editor (CDN), Bcc field, editable From
981
+ - [x] IMAP IDLE push — watchMailbox() in iflow
982
+ - [x] Compose, Reply, Reply All, Forward — prefilled fields, quoted body in div.reply
983
+ - [x] Address autocomplete — recently-sent + seeded from messages + Google Contacts
984
+ - [x] Google Contacts sync — People API with OAuth2
985
+ - [x] Ctrl+K address completion, smart Tab navigation in compose
986
+ - [x] SMTP send via nodemailer (password + OAuth2)
987
+ - [x] Gmail SMTP OAuth2 — uses iflow tokenProvider for access token
988
+ - [x] IMAP Outbox with multi-machine interlock ($Sending flag), local file fallback
989
+ - [x] Outbox auto-retry on startup (3s delay), outbox badge (red/orange) for pending
990
+ - [x] Local-first compose — always succeeds, worker syncs to IMAP
991
+ - [x] Copy to Sent folder via IMAP APPEND
992
+ - [x] Auto-save drafts every 5 seconds
993
+ - [x] Delete draft after successful send
994
+ - [x] Edit Draft / Resume — button in message header for Drafts/Outbox folders
995
+ - [x] IMAP APPEND/delete/flags support in iflow
996
+ - [x] Full body download during sync (source: true) for offline reading
997
+ - [x] Body fetch on demand with 15s timeout and auto-retry
998
+ - [x] Date column with 24h locale formatting, monospace font
999
+ - [x] Light/dark mode following system theme
1000
+ - [x] Remote content blocking — HTML sanitization + CSP + per-sender/domain allow-list
1001
+ - [x] Remote content banner — collapsible dropdown with sender/recipient details, action buttons
1002
+ - [x] remoteAllowed flag — CSP skipped when allowlist auto-allows (fixes broken images)
1003
+ - [x] View Source button — copies .eml file path to clipboard
1004
+ - [x] Delivered-To, Return-Path, List-Unsubscribe headers exposed in API
1005
+ - [x] Flag/unflag — click star, gold highlight
1006
+ - [x] Delete with undo (Ctrl+Z within 30s), delete stays in place (no scroll jump)
1007
+ - [x] Sync deletions — purge server-deleted messages from local DB
1008
+ - [x] Folder counts recalculated on delete/move
1009
+ - [x] Local-first sync — delete/flag/move update local DB, queue IMAP sync
1010
+ - [x] sync_actions table, ↻ pending indicator in status bar
1011
+ - [x] Nested folder tree — expand/collapse, virtual parents, state in localStorage
1012
+ - [x] INBOX first, special folders sorted, unified "All Inboxes"
1013
+ - [x] Unified inbox — SQL query with folder_id IN (...), not 10000-msg memory load
1014
+ - [x] Message lookup by accountId + uid + folderId (fixes UID collision across folders)
1015
+ - [x] CSS grid layout with subgrid for aligned columns
1016
+ - [x] Infinite scroll
1017
+ - [x] View menu — two-line, preview pane, flagged-only filter (localStorage)
1018
+ - [x] Sent/Drafts/Outbox show To instead of From
1019
+ - [x] Splitter position persistence (localStorage)
1020
+ - [x] Plain HTTP server (removed certsupport dependency, localhost-only)
1021
+ - [x] No body size limit on Express JSON parser
1022
+ - [x] JSON error handler — all server errors return JSON, never HTML
1023
+ - [x] Quoted-printable Content-Transfer-Encoding for sent mail
1024
+ - [x] Graceful shutdown with 3s timeout
1025
+ - [x] Fresh IMAP connection per folder during sync (fixes connection drop after ~10 folders)
1026
+ - [x] Incremental UID-based sync, INBOX prioritized
1027
+ - [x] INBOX poll every 30s (independent of full sync) with retry
1028
+ - [x] Search index rebuild wrapped in single transaction (50x faster)
1029
+ - [x] Restart button with node --watch auto-restart
1030
+ - [x] Server + client version display in toolbar with Sync/Restart labels
1031
+ - [x] No-cache API headers
1032
+ - [x] Full-text search — SQLite FTS5, from:/to:/subject: qualifiers
1033
+ - [x] iflow searchMessages() method + SearchObject export (ready for UI wiring)
1034
+ - [x] Status page at /status — uptime, memory, accounts, pending sync
1035
+ - [x] Request logging middleware
1036
+ - [x] File logging to ~/.mailx/logs/ with 7-day rotation
1037
+ - [x] Smart auto-update — folder counts refresh, message list only reloads when not reading
1038
+ - [x] Abort stale fetch requests when switching folders
1039
+ - [x] Message viewer generation counter (fixes race condition / stale preview)
1040
+ - [x] Settings split — accounts.jsonc, preferences.jsonc, allowlist.jsonc with local cache/overrides
1041
+ - [x] Allow-list supports senders, domains, and recipients
1042
+ - [x] config.json → config.jsonc migration, readJsonc auto-discovers .json/.jsonc
1043
+ - [x] Account label (UI display) separate from name (From header)
1044
+ - [x] Account defaultSend flag for custom From routing
1045
+ - [x] From field as proper select dropdown with label/name split + "Other..." custom
1046
+ - [x] Cross-account message move via iflow moveMessageToServer
1047
+ - [x] Drag-and-drop messages to folders (move), multi-select with Shift/Ctrl+click
1048
+ - [x] Client-side message list filter box (instant text filter, Esc to clear)
1049
+ - [x] Folder filter/search box above folder tree
1050
+ - [x] Startup overlay with spinner + status during initial load
1051
+ - [x] Empty folder clears message viewer (no stale preview)
1052
+ - [x] WebView2 launcher: fresh data dir (~/.mailx/webview2/), 127.0.0.1, no console window
1053
+ - [x] Fonts bumped for readability (preview 17.5px base), line-height 1.45
1054
+ - [x] Tab in compose accepts autocomplete without jumping to next field
1055
+ - [x] Attachment download — clickable chips open PDFs/images in browser
1056
+ - [x] Folder context menu — right-click: mark all read, new subfolder, rename, delete, empty (Trash/Junk)
1057
+ - [x] Create / delete / rename folders via API
1058
+ - [x] Empty Trash/Junk — permanently delete all messages
1059
+ - [x] Search scope dropdown: All folders / This folder / IMAP server
1060
+ - [x] IMAP server search via iflow searchMessages() with qualifier parsing
1061
+ - [x] Regex search: /pattern/ prefix for client-side regex filtering
1062
+ - [x] Scoped FTS5 search (filter by accountId + folderId)
1063
+ - [x] Unicode characters in HTML (no entity escapes)
1064
+ - [x] Keyboard shortcut hints in toolbar tooltips
1065
+ - [x] IPC architecture built — mailx-core with dispatch(), mailxapi.js bridge, api-client auto-detects
1066
+ - [x] Case-insensitive mailbox name lookups (IMAP RFC)
1067
+ - [x] Two-way sync — local-first delete/move/flag/send queue to sync_actions, periodic IMAP sync pulls changes back
1068
+ - [x] Drafts — auto-save to IMAP Drafts every 5s, delete after send, Edit Draft button to resume
1069
+ - [x] Delivered-To / recipient alias — extract real recipient, skip relay domains, strip prefixes
1070
+ - [x] Auto-detect mail server settings — Thunderbird ISPDB, Mozilla autoconfig, DNS SRV records
1071
+ - [x] npm install deployment — npmglobalize publishes all packages
1072
+ - [x] Cloud-authoritative settings — GDrive API is source of truth, local is cache
1073
+ - [x] Google Contacts sync — People API with OAuth2, autocomplete from synced contacts
1074
+ - [x] Configurable OAuth credentials path — checks ~/.mailx/ then iflow package dir
1075
+ - [x] Attachment download — clickable chips open in browser
1076
+ - [x] Single-module architecture — IPC-first, no Express server required, msger serves files via custom protocol
1077
+ - [x] Responsive / small screen — CSS media queries, narrow layout, breakpoint-driven
1078
+
1079
+ ---
1080
+
1081
+ <a id="not-needed"></a>
1082
+
1083
+ ## Not needed [↑ top](#top)
1084
+
1085
+ - ~~Mobile setup via QR code~~ — shared settings on GDrive handle multi-device
1086
+ - ~~**Side door server**~~ — IPC mode is default, `--server` flag for HTTP dev mode
1087
+ - ~~Platform-specific npm packages~~ — msger binary included via npmglobalize postinstall
1088
+ - ~~**Remote Web Access**~~ — each device runs its own mailx instance syncing directly to IMAP/Gmail API
1089
+
1090
+ <a id="questions-decided"></a>
1091
+
1092
+ ## Decided questions (archive) [↑ top](#top)
1093
+
1094
+ History of closed-out Q-items so decisions aren't re-litigated. Every decision is either a **task to complete** (shipped or tracked elsewhere) or an **explicit non-task** (parked / status quo / don't build). The Status column tells which — if it says "→ Done v1.0.x", the task is already done; if "→ [AI section]" or similar, the task lives under that anchor; if "no task", the decision was to not build anything. Integers retain their Q-numbers per the never-reuse rule; Q-prefix is the "question" tag, distinct from the Q-prefix on quick-win items (`Q49/Q52/…`) which share the integer space.
1095
+
1096
+ | # | Decision | Status |
1097
+ |---|---|---|
1098
+ | **Q100** | Spam button = CSV placeholder. Append to `~/.mailx/spam.csv` (timestamp_ms,date,time,account,delivered_to,from,subject,eml_path). No folder move, no flag change, no auto-delete. Smart classifier comes later. | → Done v1.0.383 (🚫 button in viewer header) |
1099
+ | **Q102** | Theme = System / Light / Dark radio select. Menu for now, Settings panel later. | → Done v1.0.384 |
1100
+ | **Q103** | Rules / extensions engine parked pending imail experiments. | → [AI section](#ai) (Q103-AI); no implementation task yet |
1101
+ | **Q105** | Dally (`~/.mailx/dally/`) dropped. Ctrl+Z and compose Save/Discard/Cancel cover loss recovery. | → S4 retired; no task |
1102
+ | **Q106** | Outbox stays on filesystem queue (`~/.mailx/outbox/<acct>/*.ltr`) with per-instance atomic-rename claim. | → Status quo (already shipped); C27 parked; no task |
1103
+ | **Q107** | Gmail label-native model deferred. Keep hash-UID. | → C25 parked; no migration task |
1104
+ | **Q108** | Message-ID-match guard is the permanent body-comingling defense. | → Already shipped v1.0.361; S49 closed |
1105
+ | **Q111** | Writing assistance moved to AI section pending back-end choice. | → [AI section](#ai) (Q111-AI); no implementation task yet |