<a id="top"></a>

# mailx TODO

*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)*

> **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.

## Autonomous-work queue (Claude works top-down when idle)  [↑ top](#top)

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.

| Order | Item | Scope | Status |
|---|---|---|---|
| ~~1~~ | ~~**C118 — STATUS-before-SELECT in syncFolder**~~ — **DONE** (already implemented at `mailx-imap/index.ts:1079`; TODO entry was stale). | S | |
| ~~2~~ | ~~**C119 — Lazy folder sync**~~ — **DONE** (shipped 2026-06-01; TODO entry was stale). `isLazyEligible` in `mailx-imap/index.ts` syncAccount: never-touched folders (highestUid=0, non-special) are not auto-swept at all — they sync on-demand via `syncFolderNow` when opened, then join the periodic set. Previously-seen folders stay in the sweep but C118's STATUS-before-SELECT makes an unchanged folder cost one round-trip. | M | |
| ~~3~~ | ~~**C121 — Counting timer for server connect**~~ — **DONE v1** (2026-07-08, v1.2.115). `newClient` in `mailx-imap/index.ts` wraps the injected transport + `client.connect` and emits `connectProgress` (phases `socket` → `handshake` → `done`/`failed`); bin forwards as an event; `client/app.ts handleConnectProgress` renders `Connecting to <host> (<phase> <purpose>, 3.2s)` with a 100 ms ticker, gated to attempts >1.5 s so healthy connects never flicker. Connects >3 s log per-phase timings (`[conn-slow]`). *Residual*: splitting `socket` into dns/tcp/tls needs an `onConnectPhase` hook inside iflow-node's NodeTransport — do when next touching iflow. | S | |
| ~~4~~ | ~~**C122 — Recent-unread count overlay on taskbar icon**~~ — **DONE** (shipped ~v1.1.x 2026-05-29; TODO entry was stale). `updateBadge` in `client/app.ts` renders the red count pill on a 32×32 canvas (badge-only, transparent field) and pushes it via `mailxapi.setTaskbarOverlay`; hooked to `folderCountsChanged` + startup via `updateNewMessageCount`. Badge shows NEW-since-last-seen, matching the "recent-unread" spec. S65 (badge while not running) remains open/blocked. | S | |
| ~~5~~ | ~~**C126 — Cold-start latency (~28s observed)**~~ — **MOSTLY DONE** (2026-05-11). Root cause IDed from boot log: 43 s of cascading ES module imports through msger's custom protocol IPC (one roundtrip per file). Fixes shipped: (a) bundling via esbuild (`bin/build-bundles.mjs`) collapses the cascade to one fetch per entry point — `client/app.bundle.js` 345 kb, `client/compose/compose.bundle.js` 99 kb; (b) boot-snapshot hydration — bundled app saves `folder-tree` + `ml-body` innerHTML to localStorage every 30 s; inline script in `index.html` restores at cold start before the bundle has fetched. Sub-bullet (c) [500 ms placeholder pause] not in current code at cited line — skipped as stale. Expected cold start now 3-5 s (full WebView2 + bundle parse), with near-instant *feel* via hydrated snapshot after the first run. | M | |
| **6** | **C125 — Unify desktop + Android IMAP code paths** | M | Today's split (`mailx-imap` Node-only + `mailx-store-web` browser-only) duplicates orchestration that's protocol-identical. Differences boil down to (a) transport factory choice (b) whether clients are persistent — both parameters, not architectural divides. Plan: extract `mailx-imap-core` with ImapManager + ops queue + fast-lane + sync orchestration over a `Transport` and a `Storage` interface, no Node-specific imports. `mailx-imap` and `mailx-store-web` become thin shims that wire engine-specific Storage + Transport. `{persistent: true\|false}` flag on ImapManager covers the lifetime split (no fast-lane queue when ephemeral). Pre-req for Android non-Gmail IMAP without forking sync code. |
| **7** | **C156 — Android first-boot/setup progress narration** [M] | M | New-device install looked frozen (Bob 2026-07-22): no indication of "Signing into <gmail>…", "Fetching settings from Google Drive…", "Syncing INBOX (120/2000)…". Surface the android-bootstrap stage breadcrumbs (already vlog'd since v1.2.167) in the startup status area / folder-tree loading slot. |
| **8** | **C157 — Android fresh install: paint folder list before message sync** [S/M] | S/M | First sync ran minutes with an empty tree. Prioritize per-account folder-LIST fetch + paint ahead of message sync so the skeleton appears in seconds; message counts stream in behind. |
| **10** | **C159 — Harper local grammar check** [M] | M | **Desktop SHIPPED v1.2.168** (2026-07-22): daemon-side Harper engine + `grammarLint` IPC, generic squiggle overlay + suggestion popup across ALL THREE editors, Settings toggle default ON, quoted chains + spelling lints skipped. **Remaining: Android** (host LocalLinter in android-bootstrap, harper.js dist in APK assets like sql.js, ~+18 MB) + per-rule config + userdict merge. Details: `docs/harper-integration.md`. |
| **9** | **C158 — Android double-init after fold-triggered reload** [M] | M | 2026-07-22 13:07:49 logit trail: ONE page reload after unfolding produced TWO module loads, duplicate "bridge installed", duplicate GDrive lookups, and THREE "Starting OAuth flow" launches. Activity did NOT recreate (ConfigChanges already declared; no OnCreate in trail) — find what reloads the WebView on fold and make android-bootstrap init idempotent (single-flight guard on the module-level init, dedupe OAuth intent launches). |
| **5** | **C124 — mailto handler on macOS** (Linux ✅ 2026-07-08 v1.2.115) | S | Linux half shipped: `--register-mailto` on linux writes `~/.local/share/applications/rmfmail.desktop` (`MimeType=x-scheme-handler/mailto;`, quoted node+mailx.js Exec, bare `%u`), runs `xdg-mime default` + `update-desktop-database`; `--unregister-mailto` removes it. **Remaining: macOS** — real `.app` bundle (LaunchServices won't register a bare script) with `Info.plist` `CFBundleURLTypes` declaring `mailto` + a tiny launcher binary; `LSSetDefaultHandlerForURLScheme` programmatically sets the default. Rust `rmfmailto-src/` crate already portable — drop the Win32 imports and `cargo build --target x86_64-apple-darwin` produces the macOS launcher. Needs a Mac to build/verify. |
| ~~7~~ | ~~**C154 — Audit Android bridge vs MailxApi contract**~~ — **DONE** (2026-07-08, v1.2.115). Bridge now has a generic contract-backstop: every service method not explicitly adapted gets a positional pass-through, so a missing method can never again fail as `parent bridge has no method "X"`. Wire-shape mismatches adapted explicitly (`readJsoncFile`/`writeJsoncFile`/`formatJsonc`/`readConfigHelp` → `{content}`/`{ok}`, `drainStoreSync`/`syncFolderNow` → `{ok}`). Contract gained the missing entries (`getMessageSource`, `copyMessages`, `syncFolderNow`, `cancelServerSearch`, `getReminderSound`, `getCalendars`, optional `popoutWindow`) with web-service stubs per the policy (reads → graceful default, writes/OS → notImpl). Bonus fixes: bridge `getMessages` was dropping sort/sortDir/search and `searchMessages` was dropping scope/accountId/folderId — Android sort toggles and scoped search silently didn't work. | S | |
~~done — C120 read transport diagnostics~~ shipped v1.0.583 (per-folder timeout error now includes `[conn#X r=YB w=ZB writes=N sinceLastRead=Tms]` snapshot for the doomed socket).
| 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. |
| 13 | **S56 full AbortController plumbing** | L | Thread `AbortSignal` through `getMessage → fetchMessageBody → provider.fetchOne`. Touches IMAP + Gmail + Outlook providers. |

*Items 1–10, 12, 14–18 shipped — see [Done section](#done-recent). P115 mailto handler shipped v1.0.583 (CLI + registry + IPC + client wiring). The Settings-UI "Default mail" toggle (item d in the original brief) is still owed; lands with the wider Settings UI rework alongside [P116](#ext116).*

### Deferred (not autonomous — blocked on user input or external system)

- 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)
- 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)


**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).

**Companion file**: chronological completed log with date-time + version tags lives in `DONE.md`. This file (`TODO.md`) is the forward-looking backlog.

## Resume here (post-reboot)

**Current version**: v1.0.388 (in source — needs `npm run build` to land in the running app). v1.0.388 slice:
- 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.
- 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.
- 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.

**Answer needed from Bob** (short prompts, answer whichever you care about; full context in `Q*` rows below):
- **Q101** — full-body text in FTS5? Auto-rebuild on upgrade or explicit `mailx -reindex`?
- **Q104** — shared alarm subsystem (one queue for mail/calendar/tasks) or per-feature with a thin dispatcher?
- **Q109** — folder drag-to-reparent: full IMAP RENAME vs stay-in-parent rename-only?
- **Q110** — replace toolbar-dropdown with full File/Edit/View/Message menu bar?
- **Q112** — Android drain sync_actions directly via Gmail API, or rely on desktop reconcile?
- **Q113** — per-message metadata: JSON column on `messages` vs separate `message_meta` table keyed by uuid?
- **Q114** — keep the full-screen calendar modal alongside the sidebar, or retire it?
- **Q111-AI** — writing assistance back-end pick (LanguageTool / custom AI via ghost-text path / native WebView2 spellcheck only / something else)?

**Plan items still open** (numbered list further down at "[Plan](#plan)"):
- Plan 10 / [S56](#ext56): row-objects-own-preview refactor (kills `gen` token band-aid).
- Plan 11: Google Calendar service-side proxy so the sidebar shows the user's actual events.
- Plan 12 / P17: shared alarm subsystem (open Q104).
- 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).

**Concerns to watch (not priority, monitor)**: S7 IMAP delete (likely moot); S49 body comingling (FIXED); S50 accounts.jsonc banner (quiet).

**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).



**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)

<a id="questions"></a>
<a id="questions-open"></a>

## Open questions for Bob  [↑ top](#top)

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.

| # | Question |
|---|---|
| **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`? |
| **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? |
| **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? |
| **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)? |

**Decided 2026-04-24** (moved to [archive](#questions-decided) — implementations tracked in backlog):
- **Q104** — alarms: Thunderbird/Outlook-style popup with snooze (variable period) + dismiss. Shared subsystem for mail/cal/tasks reminders.
- **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.
- **Q114** — full-screen calendar modal temporarily retired; sidebar is the single cal/tasks view.
- **Q111-AI** — remains in [AI section](#ai), confirmed as the right home.

<a id="ai"></a>

## AI features  [↑ top](#top)

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.

### AI — Questions

| # | Question |
|---|---|
| **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. |
| **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? |
| **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. *Update 2026-07-16: C155 answers the cross-app half — categories/signals live ON the message as IMAP keywords with a shared registry; Q113's table-vs-column question now only covers rmfmail-internal rich residue.* |

### AI — Backlog

| # | Status | Item |
|---|---|---|
| **C155** ❓ | DRAFT | **Annotation registry + imail companion annotator** [M]. Companion app annotates each letter *before* sorting. Two-layer design (2026-07-16 discussion): data plane = IMAP keywords on the message itself (core RFC 3501, portable — Gmail labels / Graph categories are the same concept per provider); vocabulary plane = one shared `annotations.jsonc` registry with MCP-tool-style entries (`description`/`whenToApply` prompt the AI annotator, `sortAction` drives the sorter, `label` feeds UIs). No shared DB needed — IMAP STORE is the concurrency layer; rich residue (dates, summaries) stays out or comes back later as a service-owned sidecar. Design note: `docs/annotation-registry.md`; starter vocabulary: `docs/annotations.jsonc`. Awaiting Bob's think-through — open questions at the end of the design note. Subsumes the cross-app half of Q113/Q113-AI; feeds Q103-AI/C39/P19 (rules engine shape). |
| [**C39**](#ext39) ❓ | PARKED | Rules / extensions engine + AI classification. See Q103-AI, C155. |
| [**P19**](#priority-daily) ❓ | OPEN | Screener — imail rules + AI classifier. Elevated under daily-driver; same classifier question as C39. |
| **AI-writing** ❓ | OPEN | Proofread / rewrite / tone-adjust in compose. Currently a Settings toggle that's off; needs the shape from Q111-AI before wiring. |
| **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. |
| **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. |
| **AI-translate** | SHIPPED-OFF | Right-click in the body iframe → Translate. Setting-controlled, off by default. |
| **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. |
| **AI-ghost-text** | DONE | Autocomplete in compose via Ollama / Claude / OpenAI. Setting-controlled. |

### AI — Why it's corralled here

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.

---

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).

<a id="summary"></a>

## Summary

**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)

<a id="sum"></a>
<a id="priority-sum"></a>

### Priority

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).

| # | Status | Item |
|---|---|---|
| [**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) |
| [**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. |
| [**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? |
| [**S57**](#ext57) | OPEN | Android: INBOX-first local render (don't block UI on serial label sync). |
| [**S58**](#ext58) | OPEN | Android: rail on narrow — surface Inbox / compose as primary action, not hidden behind broken hamburger. |
| [**S59**](#ext59) | OPEN | Android: hamburger no-op — CSS backdrop/transform residue on close. |
| [**S60**](#ext60) | OPEN | Android: keyboard → input lag (wa-sqlite writes on main thread). |
| [**S61**](#ext61) | OPEN | Android: Send leaves a second compose open / double-opens. |
| [**S62**](#ext62) | OPEN | Android: prefetch priority/speed — most-recent-first within INBOX before any label. |
| [**S63**](#ext63) ❓ | OPEN | Desktop compose pop-out — separate OS window, drag to another monitor. Blocked on msger popups (C28). Open Q: window mechanism. |
| [**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. |
| [**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. |

<a id="open-questions"></a>

<a id="plan"></a>

### Plan

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:

| # | Slice |
|---|---|
| 10 | **Full row-objects refactor** (Slice D proper, = [S56](#ext56)). ✓ (can start the focus()/unfocus() seam — full migration too big for one session) |
| 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) |
| 12 ❓ | **P17 — shared alarm subsystem** for mail + calendar reminders. Open Q: shared vs per-feature-with-dispatcher. |
| 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) |

### Open questions

Items blocked on a design decision. Short-form question here; full context in the linked detail section and/or the Priority table above.

| # | Question |
|---|---|
| [**S51**](#ext51) | Calendar surface: full-modal (current) vs. Thunderbird-style right docked sidebar vs. both-via-toggle? |
| [**S52**](#ext52) | One `primary: boolean` per account vs. per-feature flags (`primaryCalendar`, `primaryTasks`, `primaryContacts`)? |
| [**S4**](#ext4) | Dally TTL; interaction with server-side EXPUNGE; does Discard share the dally buffer with Delete? (Formerly S4+S5, merged — same mechanism.) |
| [**C27**](#ext27) | Unified outbox multi-device claim protocol — IMAP flag-based vs. a `$Sending` keyword with heartbeat timestamps? |
| [**C25**](#ext25) | Gmail label-native model — drop synthesized UIDs entirely (deeper) vs. retain for back-compat (leakier)? |
| [**P17**](#ext17) | Popup reminders: one alarm subsystem shared by mail/calendar/tasks, or per-feature with a thin dispatcher? |

<a id="priority-daily"></a>

### Priority — daily-driver parity

| # | Status | Item |
|---|---|---|
| **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.** |
| **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) |
| **P17** ❓ | OPEN | Popup reminders — shared alarm subsystem for mail / calendar / tasks. Open Q: shared vs per-feature-with-thin-dispatcher. |
| **P18** | PARTIAL | Inline thread expansion — thread_id + popup done; inline-tree UI owed. ✓ (inline tree in message-list — moderate but self-contained) |
| **P19** ❓ | OPEN | Screener — imail rules + AI classifier. **Moved to [AI section](#ai)** (Q103-AI covers the classifier design). |
| [**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) |

<a id="categorized"></a>

### Categorized / infrastructure

| # | Status | Item |
|---|---|---|
| **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.** |
| **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) |
| **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. |
| **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) |
| **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) |
| **C31** | OPEN | Live reload on config changes — swap account configs in-place. |
| **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. |
| **C33** | PARTIAL | Dovecot-style responsive layout — rail + wide/medium/narrow done; very-narrow tier + rail badges owed. ✓ (very-narrow tier CSS + rail badges) |
| **C34** | OPEN | Restore `--server` mode + login. ✓ (flag re-wire; needs runtime test) |
| **C37** | OPEN | Email completion ranking. ✓ (ranking formula + sort) |
| **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. |
| **C39** ❓ | OPEN | Rules / extensions engine + AI classification. **Moved to [AI section](#ai)** (Q103-AI). |
| **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) |
| **C41** | PARTIAL | Reply quoting — already wraps in `<blockquote>`; read-only + non-editable-above-the-line still owed (needs Quill module work). |
| **C43** | OPEN | Message list column controls + threading flags. ✓ (sort-by-column-click is small) |
| **C44** | DONE (v1.2.105) | Real OS popup windows / pop-out — floating-overlay popout landed (Q64 v1.0.344); v1.2.105 unblocked the real OS window: 🗗 asks the daemon, which spawns a native msger window (`showMessageBoxEx({url})`) pointed at the loopback popout server. Toolbar actions POST back → `popoutAction` event → main window opens editable compose / flags / deletes (delete also closes the window). Browser-window option deferred (same URL, just `openExternal` it). |
| [**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. |
| **C127** | OPTIONAL | **Push relay for calendar / mail / tasks** — design at [`docs/push-relay.md`](push-relay.md). New `MailApps/relayer/` package, alerter-shape (single Node Express+ws process, deploy alongside other small services). Generic API: client POSTs `/subscribe` with upstream + params, relay registers the upstream `events.watch` and returns an SSE/WS endpoint the client holds open. Per-upstream adapter files (`google-calendar.ts`, `google-gmail.ts`, `ms-graph-events.ts`); fan-out + replay buffer + subscription store generic. Client side: `pushRelayUrl` + credentials in settings, falls back to syncToken polling when relay unreachable. **Build only when polling latency demonstrably matters** — for typical use, 5–10 s syncToken cadence is functionally instant. Push is a freshness boost, not a correctness fix. |
| **C128** | OPEN | **Body pre-parse on prefetch** — first click on any unviewed message pays `simpleParser` + `sanitizeHtml` cost (~100–500 ms on rich HTML). The parsedCache (added 2026-05-09) eliminates 2nd-and-later view costs but not the first. Fix: extend prefetch to ALSO parse + sanitize the body during background fetch and store the result alongside the .eml (e.g., `<uuid>.parsed.json` next to `<uuid>.eml`, or a `body_html_cached` column). Click-time path reads pre-parsed JSON, no parser invocation. Touches `mailx-imap/index.ts` prefetch path + `mailx-store/file-store.ts` for the parsed-blob storage. |
| **C129** | OPEN | **Android prod/dev split via filename/URL channel** — User 2026-05-09 (revised): no separate MAUI project. Same source tree builds; output filenames distinguish channels: Phase 1 (start now) writes `rmfmaildev.apk` alongside today's `mailx-maui.apk`. Phase 2 (when a release is declared) switches to `rmfmail-<version>.apk` versioned + `rmfmaildev.apk` for latest, with promotion = re-pointing `rmfmail.apk` at a chosen version. Single APK contents per build; channel is runtime setting in AppUpdater (Settings toggle for which URL to poll). Side-by-side install on one device NOT supported by this design (single Android package id) — that's a separate need. See `docs/prod-android.md` for full plan. Phase 1 ~30 min; full flow ~2.5 hours when activated. |
| **C130** | OPEN | **Reset prefetch error budget after fresh sync** — Android: `errors`/`failedThisSession` accumulate within a prefetch session. Already resets on next syncAll cycle (since each `prefetchBodies(account)` call is fresh). Verify this works in practice via logit; if a long sync session burns through budget and stays stuck, add an explicit reset on `bodyAvailable` events that fire while prefetch is still running. |

<a id="concerns"></a>

### Concerns to watch

Previously shown as showstoppers; moved here because they haven't recurred on recent builds. Monitor next repro.

| # | Status | Item |
|---|---|---|
| [**S49**](#ext49) | FIXED v1.0.361 | Body comingling — root cause eliminated: disk filenames are now opaque UUIDs, never reused. |
| [**S50**](#ext50) | DIAG v1.0.338 | `accounts.jsonc` banner firing repeatedly — quiet on recent builds. |
| [**S7**](#ext7) | LIKELY MOOT | IMAP delete "neither trash nor deleted" — user says probably resolved by recent fixes; demoted from Priority to monitor. |

<a id="quick-wins"></a>

### Quick wins — do autonomously when idle

Small, self-contained items. Pick them up between higher-priority blocks without asking. Bump version per fix.

- ~~**Q153 — Independent pop-out compose window (loopback-HTTP).**~~ **DONE (2026-07-09, v1.2.117).** 🗗 in the compose toolbar hands the LIVE compose state (fields incl. Bcc, body, attachments, draft identity) to the daemon (`popoutCompose` → one-shot init stash), which spawns a native msger window at the popout server's statically-served `compose/compose.html?pi=<id>`. The 2026-06-01 "needs a REST transport rewrite" blocker dissolved: instead of rewriting api-client, the popout server serves the REAL `mailxapi.js` plus a tiny `popout-bridge.ts` that sets `window.__rmfIpcOverride` — an HTTP postMessage carrying the SAME `{_action,_cbid}` payloads to `POST /rpc`, dispatched through the SAME jsonrpc dispatcher. One API surface, no duplication. (msger url-mode injects `window.ipc` non-writable → the override hook in mailxapi.js, not a wrap.) Draft continuity via pre-pinned `X-Mailx-Draft-ID`; send/discard in the popout closes its window via `closeComposePopout`. Events (WS) not carried — compose doesn't need push.

- ~~**Q152 — "Edit as new message" / resend on a sent (or any) message.**~~ **DONE 2026-05-31.** New `editAsNew` compose mode in `client/app.ts` (`openCompose` branch + `editAsNewBody` helper) clones the selected message into a fresh compose: original To/Cc/Subject verbatim, body via `sanitizeQuotedBody` with no quote/forward wrapper, no In-Reply-To/References (new Message-ID at send). Right-click menu entry "Edit as new message" added after Forward in `message-list.ts`. Replaces the move-to-Drafts kludge. *(Original entry below.)* Bob 2026-05-29: no clean way to take an already-sent message, tweak it, and send it again — today's only path is move-to-Drafts-then-edit (a kludge) or Forward (adds `Fwd:` + quote wrapper, wrong recipients). Add an **"Edit as new message"** affordance — in the viewer toolbar (next to Forward) and/or the message-list right-click menu — that loads the selected message's `.eml` **straight into a fresh compose** as the author: original To/Cc/Subject/body editable, **no `Fwd:`/`Re:` prefix, no quote indent, no In-Reply-To/References threading headers**, a new Message-ID. Effectively "duplicate into compose." Distinct from the existing Drafts `Edit & Send` (that edits the draft in place); this clones any message into a new outgoing draft. Reuse the compose-init plumbing the draft-edit path already uses (`showComposeOverlay` + the init payload built in `app.ts`); the only difference is which headers get stripped vs. carried. Most natural as the message-viewer From/To right-click "duplicate"-style action plus a toolbar button gated on non-draft messages. [S]
- ~~**Preview CSS leak — `<style>`/`<head>` contents bled into the message-list one-liner.**~~ DONE 2026-05-31. `extractPreview` (`mailx-imap/index.ts`) flattened HTML by stripping tags only, leaving the CSS *between* `<style>…</style>` (and `<head>`/`<script>`) in the preview as `*{box-sizing…}` / `@media…{…}` garbage (Bob's marketing-mail shot). Now strips those block contents + HTML comments before the tag-strip; `[image]` marker preserved. IMAP-path only (Gmail uses Google's clean snippet). Existing rows refresh via `rmfmail -repair`.
- ~~**Q138 — Quoted-reply image sizes lost.**~~ DONE 2026-05-11 in `client/app.ts:1000` — strip skips `<img>`. Two-pass loop handles multiple stripped attrs per tag.
- ~~**Q140 — User-configurable holiday calendars (generalization).**~~ **RETIRED 2026-05-29 — already dynamic, superseded by Q143.** Bob: the holiday list is pulled automatically from whatever calendars the user has selected in Google Calendar, so it's dynamic already. Confirmed in code: `refreshCalendarEvents` enumerates `listCalendars().filter(c => c.selected)` and tags each row with its source; the old hardcoded `HOLIDAY_SOURCES` + `showHolidays`/`showJewishHolidays` toggles are retired (`mailx-service/index.ts:1485`). The user curates the set in Google's web UI — no in-app holiday-calendar editor needed. The curation-gotcha + Hebcal notes below are kept only as reference IF a free-form calendar-ID *picker* is ever added (not currently planned — selection happens in Google, not mailx).

  **Curation gotcha (reference only)**: Google's `<locale>.<tradition>#holiday@group.v.calendar.google.com` IDs are NOT all clean per-tradition feeds. The "obvious" IDs are mis-curated catch-alls; the `.official` / proper-noun variant is the actually-clean source for each tradition. Documented examples from 2026-05-11 testing:
  - `en.usa#holiday` — mixed (federal + Christian + Jewish + Orthodox + Armenian + Ethiopian Jewish + …)
  - `en.usa.official#holiday` — clean federal-secular
  - `en.jewish#holiday` — mixed (Jewish + Christian + Orthodox neighbors)
  - `en.judaism#holiday` — clean Jewish-only

  When exposing the calendar picker to users, ship a curated allowlist of vetted IDs rather than a free-form text input, OR show a warning when a user enters a non-`.official` / non-proper-noun ID. The naming asymmetry is on Google.

  Alternative source worth evaluating before generalizing: **Hebcal** (`https://www.hebcal.com/`) publishes a more rigorously curated Jewish-only feed (iCal + JSON API), and similar projects exist for other traditions. A `JsonHolidayProvider` interface that abstracts Google vs Hebcal vs ICS-by-URL behind a common shape would let users plug in whatever feed they trust.
- **Q150 — `discovered` contacts don't belong in the cloud file — split it.** Bob 2026-05-16 hit a 1.5 MB `contacts.jsonc` (11,034 `discovered` rows) and asked about CSV. CSV is the wrong fix (saves ~half the bytes, loses the preferred/denylist/groups structure). Real issue: `discovered` is a *derived cache* — name/email/useCount/lastUsed rebuildable per-device from the message corpus (`seedContactsFromMessages`) — so it shouldn't be in the cloud-synced JSONC at all. Split: cloud `contacts.jsonc` keeps only user-curated data (`preferred`, `denylist`, `groups`) ≈ a few KB; `discovered` lives in the local DB only, rebuilt from each device's mail. Shrinks the cloud file ~700×, kills the 1.5 MB-blob fragility, and matches the "local store is a cache" rule.
- ~~**Q154 — Prefetch "0 FETCH responses" on new arrivals: verify-then-classify instead of ERROR noise.**~~ **DONE (2026-07-12, v1.2.125).** Zero-response chunks now UID-verify against the server: absent → one info line ("no longer in folder — reconcile owns the rows"); still present → ERROR marked "real fetch failure". Watch the log for the STILL-PRESENT variant — that would be a genuine prefetch bug. [S] Diagnosed 2026-07-12: 60–120×/day, bobma INBOX. Sequence: envelope arrives → server-side filtering (Sieve/screener) moves it to _Spam within seconds → body prefetch FETCHes the now-gone UID in INBOX → 0 responses → logged as ERROR twice; move-detect later rebinds the row (self-healing). Fix: on a 0-response batch, UID SEARCH the missing UIDs — absent from folder = expected-move (log one info line, let reconcile own it); still present = REAL fetch failure (keep the ERROR). Also answers the broader "deleted on server — find out for certain" principle (see v1.2.124 bodyError classifier: deletion is only claimed on positive server evidence). Watch: whether messages that STAY in INBOX ever hit this path — if so that is a real prefetch bug, not noise.

- ~~**Q157 — Android: long-press context menu clipped offscreen in narrow mode.**~~ **DONE (2026-07-13, v1.2.132).** showContextMenu now clamps to all four visualViewport edges (flip first, then clamp ≥4px) and scrolls internally when taller than the screen. [S] Bob 2026-07-13 shot: long-press on selected text in the viewer opened the context menu half OFF the left edge — items render truncated ("efx)", "m MyBILHChart-…", "TML…"). The context-menu positioner must clamp to the visual viewport on all four edges (it clamps for right/bottom on desktop; the left/narrow case escapes). Likely client/components/context-menu.ts — clamp x to >= 4px and shift the menu fully onscreen; max-height + scroll when taller than the viewport.

- ~~**Q158 — Android: "Search for…" lands on the empty viewer pane.**~~ **DONE (2026-07-13, v1.2.132).** runSearch flips narrow layout back to the list pane (viewer narrow-active off, list narrow-hidden off) so the populated search bar + results are visible. [S] Bob 2026-07-13 shot: choosing Search-for from the context menu ran the search but narrow mode stayed on the VIEWER pane ("Select a message to read") — zero feedback. Fix: after a programmatic search, (a) switch narrow/medium layout to the LIST pane, (b) reveal the search bar populated with the query so the state is visible, (c) focus stays on results. Applies to every search entry point, not just the context menu (see feedback_summary: search state must be visible).

- **Q151 — AI config window (natural-language config edits).** Bob 2026-05-16: an AI window that understands the config files so he can say "don't show outlook_…@outlook.com". Feasible — mailx already has AI plumbing (aiTransform, provider+key in autocomplete settings) so it reuses the existing key. Design constraint: the AI must NOT free-form-rewrite a JSONC file (a malformed contacts.jsonc wipes everything — just saw it). It proposes a structured operation (`denylist add <email>`, `preferred add …`), shows a diff, the user confirms, and mailx applies it via the existing typed methods (`addToDenylist`, `addPreferredContact`, …). Worth it for open-ended requests ("stop showing anything from that marketing company", "merge these contacts") — for a single denylist it's overkill vs. a direct affordance (Q149).
- **Q149 — Visible prefer/ignore affordances.** (1) ✅ **Done v1.1.55** — compose autocomplete rows now have visible per-row ★ (prefer → `contacts.jsonc#preferred[]`) and ⊘ (ignore → `denylist[]`) buttons, shown on hover; plain click, no dependence on the flaky right-click. (2) STILL OPEN — "Never use this address" in the message-viewer From/To/Cc right-click menu (currently Copy / Add to contacts / Reply only). (3) Related: route ★ to a Google-Contacts star instead of `preferred[]` once that's decided (see Q151 discussion).
- **Q148 — "Open in editor" + live file-watch for the JSONC config editor.** Bob 2026-05-16: the Edit-config-file modal's textarea is cramped; he wants a button revealing the source path so he can edit in a full editor (VS Code), with the modal watching the file and reflecting external changes. Local/cloud split: `config.jsonc` is a real local file (`~/.rmfmail/config.jsonc`) — trivial: a "Reveal source" button + `fs.watch` → re-read the textarea on change. The other files (`accounts.jsonc`, `allowlist.jsonc`, `clients.jsonc`, `contacts.jsonc`) live on Google Drive via the Drive API — **no local path exists**. To edit those externally, "check out" a local working copy (e.g. `~/.rmfmail/config-edit/<name>`), reveal that path, `fs.watch` it, and `cloudWrite` back on change. Modal gains: a "Reveal/Open source" button per file + a watcher that updates the textarea (and warns on a dirty-buffer conflict). ImapManager already runs `fs.watch` on the local config files (`watchConfigFiles()` → `configChanged`) — reuse that channel for the local case.
- **Q147 — Message list stalls mid-scroll during sync churn — incremental updates + virtualization.** Bob 2026-05-16: "scrolling the list of messages, it seems stuck and finally gets loose." Design defect, not a symptom to patch. Root cause: `renderMessages` rebuilds the whole list (`new MessageRow` per row, ~10 DOM nodes each) and there is **no virtualization** — infinite scroll appends pages so a large folder holds thousands of live nodes. Sync events (and the every-30s calendar/task refresh storm makes them constant) trigger synchronous list re-render and/or `scrollIntoView` re-anchoring on the **main thread the user is scrolling** → scroll input queues until the burst clears. Existing `{scroll:false}` guards (added 2026-05-14 for the same "won't let me scroll" report) patched the symptom; the defect remains. Fix, two parts: (1) **sync events update rows in place** — a changed flag/date/folder mutates that one `MessageRow`'s DOM; never rebuild the list and never touch scroll/focus on a background reconcile; (2) **virtualize** large folders so only viewport-adjacent rows are in the DOM. Continuation of the row-objects-own-behavior direction (related: S56). Touches `message-list.ts` render + the store-subscription/event handlers.
- **Q146 — All-day events are floating calendar dates, not instants — model fix.** Bob 2026-05-16. Interim fix shipped v1.1.50: `calendarEventToLocal` parses an all-day `start.date` as *local* midnight instead of UTC (`Date.parse` treats date-only as UTC → a June 14 birthday rendered June 13 in US Eastern). That makes it correct *on the current machine in the current timezone* but the underlying model is still wrong: an all-day event — a birthday especially — is a **floating date** ("June 14", true everywhere), not a timestamp. Storing it as `start_ms` epoch ties a date to a timezone; it'll drift again on travel / timezone change / cross-device view. Proper fix: distinguish **timed events** (an instant — `start_ms`, fine) from **date events** (store the `YYYY-MM-DD` string, no timezone). Day-grouping, the day header, and rendering key off the date directly for date-events; only timed events do epoch math. Reminders on all-day events (Google's "1 day before at 9am") still need a timezone anchor — resolve that against the *local* day. Touches `GCalEvent`/`calendarEventToLocal`, `calendar_events` schema (a date column or an `all_day` + date pair), `calendarRowToObject`, the sidebar `CalEvent` + `fetchUpcoming` + `renderEvents` grouping, and the alarm scheduler. Medium model change — the v1.1.50 parse is a stopgap until it lands.
- **Q145 — From header: RFC 2047 encoded-word not decoded when it spans the whole `name <addr>`.** ✅ **Fixed v1.1.49** — `db.upsertMessage` now runs `decodeHeaderWords` over the `address` of from/to/cc, not just the display `name`. A legit address never carries an encoded-word so it's a no-op there; for the spam case the `=?utf-8?q?…=3C…=40…?=` mailbox decodes to readable text. Bob 2026-05-16, `screenshots/Screenshot 2026-05-16 141730.png`, sample `.eml` at `~/.rmfmail/mailxstore/bobma/1f/1fc0be6eb64d43e88503afaa8e424009.eml`. The From renders raw as `=?utf-8?q?PayPal_Nouveaut=C3=A9s_=3CParts=40samisec=2Ecom=3E?=@exch-smtp-out-01.wtr.livemail.co.uk`. The sender packed the entire display-name-and-address — including the `<`, `@`, `.`, `>` as `=3C =40 =2E =3E` — into ONE encoded-word, then concatenated `@domain` with no whitespace. Decoded it is `PayPal Nouveautés <Parts@samisec.com>`. mailx fails to decode it: the encoded-word isn't whitespace-delimited and sits where an addr-spec is expected, so the address parser (envelope `tokenizeParenList` / header decode) skips RFC 2047 decoding. Technically malformed mail, but Thunderbird decodes it. Fix: run encoded-word decoding on From/To/Cc display strings before address splitting, and tolerate a `=?...?=` token adjacent to non-whitespace. The broken string also propagates into the remote-content "Always: …" allowlist suggestions.
- **Q143 — Dynamic per-calendar checkboxes + per-source icons (Google-selected calendars).** ✅ **Implemented v1.1.47** (pending runtime verify). Shipped: `getCalendars()` service method, dynamic checkbox list, blue-dot/🇺🇸/✡️/🎂/monogram icons, per-event source icons, `hiddenCalendars` display-filter, `HOLIDAY_SOURCES` + `showHolidays`/`showJewishHolidays` removed. **Deferred**: multi-source badge (one event from two calendars still shows one icon — last-write-wins on `calendarId`; needs `iCalUID` + a source-set). Bob 2026-05-16, design settled. **Drop the hardcoded model**: today `HOLIDAY_SOURCES` hardcodes two Google calendar IDs and the sidebar has two fixed checkboxes (`showHolidays` / `showJewishHolidays`). Replace with a fully dynamic scheme — Bob curates *which* calendars exist by selecting them in Google Calendar; mailx enumerates the `selected` calendars (`listCalendars().filter(c => c.selected)`, already done in `refreshCalendarEvents`) and the sidebar **generates one checkbox per selected calendar**, each with an icon. Per-mailx visibility persists per calendar ID (replaces the two boolean settings). Calendar-URL input / non-Google calendar kinds are **deferred** (future TODO) — for now only Google-selected calendars.
  - **Icons**: known types get a themed glyph — US holidays 🇺🇸, Jewish holidays ✡️, Birthdays 🎂 (detect via calendar ID pattern / `eventType`). Every other named calendar gets a **monogram badge**: the calendar name's first letter in a colored circle, color taken from the Google calendar's color (e.g. "Frankston Family" → "F" in a colored circle). The checkbox row and each event row from that calendar show the icon.
  - **Birthdays**: shown automatically with 🎂 — no special checkbox (it's just one of the generated per-calendar checkboxes). Bob has already moved low-priority birthdays to a Contacts label so they don't flood; the two-tier "care about / FYI" split is his to manage upstream in Google for now. `eventType` (`default`/`birthday`/`fromGmail`/…) is NOT currently captured by `calendarEventToLocal` — add it.
  - **Multi-source dedup**: the same event can arrive via two selected calendars. Show the row **once**, carry a *set* of source calendar IDs, render all their icons. Dedup key = `iCalUID` (stable across calendars), not `providerId`. Needs a source-list on the row replacing the single `calendarId`; the upsert passes in `refreshCalendarEvents` union into it instead of last-write-wins.
  - Schema: `event_type` + source-list — no migration, calendar cache rebuilds from Google. Touches `GCalEvent` type, `calendarEventToLocal`, `refreshCalendarEvents`, a new service call exposing the enumerated calendar list (id, name, color, type) to the client, `db` calendar schema, `calendar-sidebar.ts` (dynamic checkbox list + icon rendering). Removes `HOLIDAY_SOURCES`, `showHolidays`, `showJewishHolidays`.
- **Q144 — Overdue tasks pinned to the top of the calendar list.** ✅ **Implemented v1.1.47** (pending runtime verify) — overdue block prepended in `renderEvents`, done-checkbox marks the task completed in Google (non-destructive). Bob 2026-05-16, design settled. NOT full Google-Calendar parity (which interleaves every due-dated task onto its due date). Instead: **only overdue tasks** — past due date AND `status: needsAction` — pin to the **top of the calendar event list** as a persistent "behind on these" block. Each row carries a done control (checkbox / ✓) which **marks the task completed in Google** (`updateTask` with `completedMs` → `status: "completed"`) — NOT a delete. Completing it drops it off the calendar top (no longer overdue) while the task survives in Google's Completed section. Tasks that are undated or due in the future stay only in the existing Task section — they don't appear on the calendar. Implementation: `renderEvents` in `calendar-sidebar.ts` prepends an overdue-task block built from `getTasks()` filtered to `dueMs < startOfToday && !completedMs`; reuse the existing `updateTask(uuid, {completedMs})` path the task-list checkbox already uses. Depends on the Q143 sidebar rework. Note: the existing task-list **× button is a hard `tasks.delete`** (destructive, no confirm) — distinct from the checkbox's mark-done; flagged for Bob, possible separate fix.
- **Q142 — Editor help should be HTML compiled into the app, not `editor.md`.** 2026-05-16: search help was converted from a deployed `docs/search.md` (shown raw, then rendered) to HTML compiled into the client (`client/help/search-help.ts`, dynamically imported, rendered as styled HTML in the panel). Bob's principle: feature help is application content and must display as HTML — the `docs/*.md` files are only a stand-in for proper settings UI and should hold *config-file* help only. `editor.md` is the remaining feature-help `.md` still in the deploy whitelist (`mailx-settings/index.ts` `USER_DOC_WHITELIST`). Convert it the same way: author `client/help/editor-help.ts` (or wherever the compose window's help is surfaced), point the editor's help affordance at it, drop `editor.md` from the whitelist, remove the file. Also: `docs/search.md` is now orphaned (no longer whitelisted, no longer read) — delete it; the `readConfigHelp` "search" mapping in `mailx-service` can go too.
- **Q141 — Shavuot shows in calendar sidebar with the ✡ Jewish-holidays checkbox OFF — DIAGNOSED, not a bug; design decision needed.** Bob 2026-05-16 (screenshot `screenshots/Screenshot 2026-05-16 105854.png`). Root cause confirmed from `rmfmail-2026-05-16.log`: `[calendar] enumerated 4 calendars, 3 selected` → `[calendar] pulled 10 events from "Jewish Holidays"`. There are **two independent Jewish-holiday paths** and only one is the checkbox: (1) mailx's ✡ checkbox → `showJewishHolidays` → pulls the `en.judaism#holiday` holiday-source feed; the log shows it is OFF (no "pulled N Jewish holiday events" line — only "5 US-official holiday events"). (2) `refreshCalendarEvents` enumerates *every* calendar the user marked `selected` in Google Calendar itself (`listCalendars().filter(c => c.selected)`) and pulls each as regular events. The user has Google's **"Jewish Holidays"** calendar subscribed + selected in their own Google account, so mailx faithfully pulls its 10 events — Shavuot among them — as ordinary calendar rows. The ✡ checkbox has no bearing on path (2). So mailx is behaving correctly: it shows a calendar the user selected in Google. Bob's own read ("artifact of Google Calendar") was right. **Resolutions** (pick one — needs Bob): (a) zero-code — deselect "Jewish Holidays" in Google Calendar's web UI and it stops appearing in mailx; (b) feature — add per-Google-calendar visibility toggles in the sidebar so a `selected` Google calendar can be hidden in mailx without changing Google (Q140-adjacent — the sidebar would list each enumerated calendar with a checkbox; gate the `calendarsToFetch` loop on it); (c) leave as-is. No code change made — this is a design call, not a defect.
- **Q139 — General app-message channel from daemon to msger popups.** Bob 2026-05-11: "when an exit handler kills the popups by sending them a message — and can there be a general app message?" Today reminder popups are one-shot `showMessageBoxEx` (we have the handle for cross-platform reap). For graceful close-via-message AND other patterns (theme changes, in-flight content updates), spawn popups in `service: true` mode and use the bidirectional pipe — daemon calls `handle.send({type: "close"})` and the popup's JS listens for `_msgapiServiceEvent` events and reacts (`window.close()`, refresh content, etc.). Reuses the same wire mailx's main WebView already uses. Adapter shape: `showServicePopup(opts)` in `mailx-host` returns a `ServiceHandle` + a `result: Promise`; popup HTML's `<script>` registers `window._msgapiServiceEvent = (msg) => { if (msg.type === "close") window.close(); /* future: themeChanged, eventUpdated, … */ }`. Then `gracefulShutdown` in `bin/mailx.ts` iterates open popup handles and sends `{type:"close"}` instead of relying on the OS reaping. Pre-req: confirm msger's service mode supports the `rawHtml` content shape we use for reminders (may need a small msger PR).
- **Q138 (original entry) — Quoted-reply image sizes lost.** Bob 2026-05-10: in the reply quote of a marketing email (Qatar Airways), the App Store / Google Play / AppGallery button images render larger than in the original. Cause: `sanitizeQuotedBody` in `client/app.ts` strips `width` / `height` attributes (along with `align`, `valign`, `bgcolor`, `cellpadding`, `cellspacing`, `border`) to flatten marketing-email layout tables. Stripping those attrs from `<img>` is collateral damage — table-only attributes there, but they also size images. Fix: in the regex, scope the attr strip to non-img tags, OR leave width/height alone everywhere (the layout flattening doesn't actually need them on tables either — `<table>→<div>` rewrites are doing the heavy lifting). ~10 line change. Low priority — visual nit, not data loss.
- **Q136 — Server / provider capability matrix (forward backlog).** Catalog of server-specific features mailx could exploit. Detection is dynamic everywhere — IMAP via `CAPABILITY` + RFC 2971 `ID`, non-IMAP via provider-type at account setup. Each row below is its own future work item; this entry is the index.
  - **Dovecot** (IMAP — bobma's primary server): `NOTIFY` (✅ shipped 2026-05-10, see startWatching), `IDLE` (✅), `MOVE` (✅), `QRESYNC` / `CONDSTORE` (📋 Q135 — fast incremental sync with VANISHED + flag-since-modseq), `LIST-EXTENDED` (📋 single-roundtrip folder list with status counts), `METADATA` (📋 per-mailbox annotations — could hold per-folder mailx state on the server), `UNSELECT` (📋 fast folder switch without EXPUNGE), Push Framework / RFC 5423 (📋 server-side HTTP push — requires bobma config change, would let us retire IDLE).
  - **Cyrus** (IMAP — common in academia / legacy ISPs): `CONDSTORE` / `QRESYNC` (📋), `LIST-STATUS` (📋 — counts piggybacked on LIST), `MULTIAPPEND` (📋 — batch draft saves), `ANNOTATE-EXPERIMENT-1` (📋 — Cyrus's pre-METADATA cousin, syntax-incompatible). Server fingerprint via `ID` to distinguish from Dovecot when capability sets overlap.
  - **Courier** (IMAP — older shared-hosting): mostly bare RFC 3501. `THREAD=REFERENCES` available but server-side threading is fragile; client-side threading (already in mailx) is the safer path. No fast-sync extensions — skip.
  - **uw-imap** (IMAP — historical): treat as RFC 3501-only. No NOTIFY, no CONDSTORE. Detect via `ID` and disable optional code paths.
  - **Microsoft Exchange (IMAP front-end)**: capability set is weak — no NOTIFY, no QRESYNC, no SPECIAL-USE. *Don't* invest here; the Outlook Graph API path (below) is the right backend. Detect via `ID` returning `name = "Microsoft.Exchange.Imap4"` and surface a setup-time nudge to switch to OAuth + Graph.
  - **Gmail IMAP** (X-GM-EXT-1): label-aware extensions exist but mailx already uses the Gmail REST API path for Gmail accounts (faster, no per-account connection limit). Skip Gmail-IMAP-specific tuning; if anyone falls through to IMAP for a Gmail account, capability detection handles it as plain RFC 3501 IMAP.
  - **Yahoo / AOL IMAP**: standard IMAP, no NOTIFY, limited IDLE reliability. Treat as RFC 3501 + IDLE only. Document the known 25-msgs-per-fetch chunk requirement.
  - **iCloud IMAP**: standard IMAP + IDLE. App-specific password required (already documented in setup). No special tuning.
  - **Fastmail / Topicbox** (Cyrus-based + JMAP): if/when we add JMAP, Fastmail accounts should auto-prefer JMAP over IMAP. Until then, treat as Cyrus IMAP per the row above.
  - **Gmail API** (current default for Gmail accounts): already supports `historyId`-based incremental sync. **Quick wins not yet exploited**: (📋 Q137) Cloud Pub/Sub `users.watch` for real-time push — replaces 15-minute polling; (📋) `batchModify` for bulk label changes when the user multi-selects + flags/moves; (📋) `users.getProfile` with `messagesTotal` for cheap "did anything change at all" checks before issuing `history.list`.
  - **Outlook Graph API** (skeleton exists at `providers/outlook-api.ts`): Azure app registration needed (📋 C23 — blocked on Bob registering the app). Once unblocked: delta queries (`/messages/delta`) for sync; subscriptions (webhook-based push) for real-time; native conversation/thread IDs.
  - **JMAP** (Fastmail leads, IETF standard track): future provider — single HTTP API, native push channel, native threading, server-side search. Adding a `JmapProvider` would give Fastmail users a clean fast path and validate the abstraction for any other JMAP server (Topicbox, Stalwart). Detect via well-known URI `.well-known/jmap` at account setup.
  - **EWS** (legacy Exchange Web Services): pre-Graph Exchange. Avoid — Microsoft has deprecated it. If a corporate user lands here, fall back to IMAP if available, otherwise document the gap.

  **Implementation pattern reminder**: branches go on capability flags, not server names. `ID`-extension server names are only for *non-capability* quirks (annotate syntax differences, known bugs in specific versions). The Gmail-vs-IMAP-vs-Outlook split lives at the provider level; CAPABILITY-set splits live inside the IMAP provider.

- **Q135 — Server-capability gating + Dovecot QRESYNC/CONDSTORE fast path.** Bob 2026-05-10: "with IMAP you need to take advantage of features for each IMAP server. Start with Dovecot, support others as we identify them." Architectural shape: `iflow-direct` exposes a `Capabilities` set from the server's CAPABILITY response (`QRESYNC`, `CONDSTORE`, `MOVE`, `LIST-EXTENDED`, `SPECIAL-USE`, `XLIST`, `ENABLE`, `UIDPLUS`, `LITERAL+`, `NOTIFY`); `mailx-imap` branches per-account. Concrete first win — **QRESYNC/CONDSTORE on bobma**: replace the full UID-set re-fetch in `syncFolder` (`mailx-imap/index.ts`) with `SELECT ... QRESYNC (uidvalidity highestmodseq)` which returns `VANISHED` for deletions + `FETCH FLAGS` only for changed flags. Zero baseline re-fetch on idle folders, no false-positive deletes from truncated UID lists (the 50% safety guards become belt-and-braces rather than load-bearing). Second win — **MOVE (RFC 6851)** atomic move instead of COPY+EXPUNGE pair (already enabled on Gmail; needs detection on Dovecot/others). Third — **NOTIFY (RFC 5465)** per-mailbox interest declaration so IDLE on INBOX also receives FETCH/EXISTS events for OTHER selected mailboxes without re-IDLEing each. Per-server identification via `*ID` extension + capability fingerprint (Dovecot vs Cyrus vs Courier vs uw-imap have distinguishable CAPABILITY sets). Estimate: QRESYNC slice is M (iflow-direct exposes `select(folder, { qresync: { uidvalidity, modseq } })` + parse `VANISHED` + parse `OK [HIGHESTMODSEQ ...]`; mailx-imap persists modseq per folder in DB; reconcile loop uses VANISHED instead of set-diff). Pre-req: iflow-direct doesn't currently expose modseq or QRESYNC params — extension needed there first.
- **Q134 — Real popout: compose + preview into independent msger windows.** Current popout button (title-bar icon) toggles the floating compose overlay to fill the host window — fast win but not a true OS window. Bob 2026-05-10: "popout should pop out into an independent window. This should also be true for the preview." Requires daemon refactor: single `handle = showService(...)` in `bin/mailx.ts` (~40 `handle.send` call sites) becomes a `windows` registry — `broadcast(msg)` fans events to all open windows; per-window onRequest closures route request acks back to the originator. New jsonrpc actions `openPopout({ mode, init })` (spawns a fresh `showService` with `contentDir: <client>` pointed at `compose/compose.html` or a `preview/preview.html` for viewer popout) and `getPopoutInit({ token })` (init data delivery — see option below). Client wires the popout button to call `api.openPopout(...)` then closes the floating overlay. Preview gets a matching button in `message-viewer.ts`. Init data delivery: URL-hash token + first-load fetch from daemon cache; popout has no state until it asks. Estimate: half-day. Pre-req for any window-pair workflow (browse main + read popped-out message, write compose while triaging inbox).
- ~~**Q133 — TinyMCE: pre-warm CDN fetch (settings-toggle prefetch).**~~ DONE 2026-05-11. `optEditorTinymce` change handler now appends `<link rel="prefetch">` for the configured TinyMCE URL so the next compose-open finds it cached. Progressive in-place editor swap (Quill → TinyMCE without reopen) is deferred — bigger change, smaller win once the prefetch is in place.
- **Q133 (original entry) — TinyMCE: pre-warm CDN fetch / progressive editor swap.** Bob 2026-05-10: "if fetching tinymce takes time it should be done in background and only activate when it is available." Today, picking TinyMCE in Settings means the FIRST compose-open after that setting blocks for ~1s while the CDN bundle downloads. Better: when user toggles to TinyMCE in Settings, fire a fetch (or a `<link rel="modulepreload">`) so the bundle is in browser cache by the time they hit Compose. Even better: open compose with Quill instantly, swap the editor to TinyMCE in place once the bundle resolves. Touches `compose.ts` editor init + `app.ts` Settings toggle handler.
- **Q132 — importgen bugs blocking "always use importgen" rule for rmfmail.** Three issues, fix in `y:\dev\utils\importgen`: (1) reads CWD's `package.json`, so running from `client/` reads `client/package.json` (declares only quill); needs a `-p` flag or to walk up. (2) `resolveEntryPoint` blows up on packages with conditional `exports` (e.g. `".": { "import": { "node": "...", "browser": "..." } }`) — `dotExport.import` becomes an object, then `entryPoint.startsWith` throws. (3) Doesn't follow dynamic `await import()` calls if it scans imports rather than package.json deps (still need to verify which path it actually takes). Until fixed, rmfmail's import map is hand-maintained.
- **Q131 — Show holidays in calendar sidebar.** Thunderbird-style "Show holidays" checkbox. When checked, daemon's `getCalendarEvents` ALSO fetches `en.usa#holiday@group.v.calendar.google.com` (Google's standard US holidays) and merges into results, marking each with `isHoliday: true`. UI renders holiday rows with a distinct style (muted color, no edit/delete actions). Concrete edits: (a) `mailx-service/index.ts` line 1075ish `getCalendarEvents` — also call `gsync.listCalendarEvents(tp, fromMs, toMs, "en.usa#holiday@group.v.calendar.google.com")` when settings.calendar.showHolidays is true, store with `is_holiday=1` flag; (b) `mailx-store/db.ts` `calendar_events` schema — add `is_holiday` column; (c) `client/components/calendar-sidebar.ts` — render rows with `data-holiday="1"` styled distinctly; (d) settings UI radio/checkbox in the calendar sidebar's options popover. ~2 hours.
- ~~**Q117 — Spring-loaded folders (drag-hover-expand).**~~ DONE — already implemented at `folder-tree.ts:355+` with `DRAG_HOVER_EXPAND_MS = 600` constant, `dragAutoExpanded` set, and `dragend` collapse-back path. TODO entry was stale.
- **Q117 (original entry) — Spring-loaded folders.** When the user is dragging messages and hovers over a collapsed folder in the folder tree, expand it after a short hover delay so the user can see / target child folders. Auto-collapse back to its prior state when the drag leaves the folder OR the drop completes. Standard pattern in Outlook / Thunderbird / Finder. Implementation: `dragenter` on a folder row starts a timer (named constant `DRAG_HOVER_EXPAND_MS`, ~600ms target); `dragleave` cancels; on timer fire, set an `expanded-during-drag` flag and toggle the disclosure triangle. `dragend` / `drop` walks back up clearing flags and restoring prior state. No persistence — purely transient. (Per `feedback_no_magic_numbers.md`: timing values go in named constants, not inline.)
- **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).

---

**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.

<a id="glossary"></a>

## Glossary — design vocabulary  [↑ top](#top)

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.

- **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.
- **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.
- **Folder icon** — a small 📁-like glyph in the header that toggles the folder list as an overlay in medium/narrow tiers.
- **Hamburger** — the ≡ menu button that replaces the rail in narrow tier and pops a slide-in sidebar.
- **Message list** — the middle column showing envelope rows (from, subject, date, downloaded-indicator).
- **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.
- **Wide / medium / narrow** — responsive layout tiers. Defined by width alone, platform-independent. See "Dovecot-style responsive layout" TODO for the current breakpoints (1200 / 768).
- **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.
- **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.
- **Label** — Gmail's native tag-on-message concept. Many-to-many with messages. Not a folder, though mailx currently pretends it is.
- **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.
- **Drafts / Sent / Trash / Junk** — server-side IMAP folders with RFC 6154 SPECIAL-USE flags. mailx auto-detects by flag first, leaf-name second.
- **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.
- **Prefetch** — background body download so messages are readable offline. See the batch-prefetch TODO for the current implementation story.
- **Rail icon badges** — (proposed) unread counts on inbox / calendar / tasks icons in the rail, visible even when those views aren't active.
- **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.)
- **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`.
- **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.

<a id="priority-0"></a>

## Priority 0 — Independence & Architecture  [↑ top](#top)

<a id="ext1"></a><a id="ext2"></a><a id="ext3"></a><a id="ext9"></a>

### S1 / S2 / S3 / S9 — Local-first reconciliation refactor (shared root cause)  [↑ summary](#sum)

**Status as of v1.0.334:**
- ✅ **saveDraft** — local-first ACK. Writes `sending/<acct>/editing/<ts>.eml` + DB row + returns draftId synchronously. IMAP APPEND is fire-and-forget. (v1.0.316)
- ✅ **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)
- ✅ **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)
- ✅ **Compose populate fast** — reads editor type from localStorage, populates from `composeInit` sessionStorage immediately. (v1.0.315)
- ✅ **Prefetch** — periodic 60s background task, decoupled from sync success. (2026-04-21)
- ⚠️ **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)`.
- ⚠️ **Reply-opens-blank alert band-aid** still in `app.ts:openCompose`. Unconditional populate from local DB not yet done.
- ⚠️ **Row-objects-own-preview** refactor not done — still two parallel paths in `message-list.ts:544-545`.
- ⚠️ **UUID / tombstones / third-party-move detection** design below — not yet implemented.
- ⚠️ **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.

**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.

**Original design notes (the full refactor that ties all this together):**

- [ ] **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:
    - **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.
    - **Sync queue is the only IMAP-touching path.** A single background worker drains the queue with per-action retry/backoff. UI gets status events.
    - **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.
    - **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.)
    - **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.
    - **Reconciliation is two-way.** Server changes pulled via sync; local changes pushed via the queue; conflicts detected by comparing UID + ETag/internaldate.
    - **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.

- [ ] **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.

- [ ] **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.

<a id="ext4"></a><a id="ext5"></a>

### S4 — Dally mechanism (RETIRED 2026-04-23)  [↑ summary](#sum)

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.

<a id="ext10"></a>
- [ ] **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.

<a id="ext7"></a>

### S7 — IMAP delete "neither trash nor deleted" (DIAG v1.0.333)  [↑ summary](#sum)

`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.

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.

- [ ] **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.

- [ ] **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.

- [ ] **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:
  - Gmail message larger than format=raw cap (~10MB Gmail HTTP limit).
  - 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.
  - listMessageIds truncated at 1000 and the target UID isn't in the top 1000 of the folder (old messages).
  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").

- [ ] **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.

  **Design:** new `packages/mailx-sync/` containing platform-agnostic logic only:
  - All `providers/*.ts` (Gmail API, Outlook API, IMAP) — one copy.
  - Sync orchestration (`syncFolderViaApi`, `prefetchBodies`, periodic loops, batch grouping, dedup).
  - Pure parsers (RFC 2822, MIME, address lists, MIME assembly for compose).
  - Outbox claim logic (the unified design above).

  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).

  **Two consumers** instantiate it with different I/O:
  - Desktop service (`mailx-imap` becomes a thin shim): `mailx-sync` + NodeTcpTransport + node:sqlite + Node fs.
  - Android Worker (`mailx-store-web` becomes a thin shim): `mailx-sync` + BridgeTcpTransport + sql.js + IndexedDB.

  **Effort:** 1–2 days, mostly mechanical (move files, swap imports, add a Storage interface). High ROI — every future fix goes in once.

  **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.

- [ ] **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.

- [ ] **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:
  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.
  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.
  Recommend (1) since it fixes the underlying broken assumption (popups can't render content). Track in msger's TODO too.

- [ ] **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."

  **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:
  1. Message in **Outbox** (local or IMAP folder) — waiting for SMTP.
  2. SMTP succeeds → move to **Sending** (local disk + `sent_pending` DB row with Message-ID + sent-at timestamp + account).
  3. APPEND to server's Sent folder (best-effort; if it fails we still have the Sending entry).
  4. Sync sees a message land in server Sent with matching Message-ID → **match + clear** the Sending row.
  5. UI transitions: Outbox → Sending → Sent. Every step has a visible message.

  **DB addition:** `sent_pending` table (message_id, account_id, subject, recipients, raw_path, sent_at). Tiny.

  **Match rules:**
  - Primary: Message-ID from the RFC 2822 `Message-ID:` header (already generated at compose time).
  - Fallback: (from, to, subject, sent_at±N minutes) fuzzy match.
  - Expiry: rows older than 30 days auto-cleared; assume the match was missed (Gmail users with auto-delete-sent, etc.).

  **UI:**
  - "Sending" shows under Outbox in the folder tree (synthesized, not a real IMAP folder — sourced from `sent_pending`).
  - Rows look like real messages (envelope from the saved raw).
  - Clicking a Sending row renders normally; note "Sent at HH:MM, waiting for server confirmation" in the header.
  - When matched, animate a transition to Sent (optional polish).

  **Notes:**
  - 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.**
  - Reconciliation on startup: scan `sent_pending`, for any row older than 2 minutes, check server Sent for matching Message-ID and clear if present.

  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`.

- [ ] **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:
  - **Duplicate body fetches** — a message with 3 labels appears under 3 "folders" in the DB and the prefetch loop downloads it 3 times.
  - **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.
  - **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.
  - **Threading is broken** for cross-label threads — each label has its own folder_id and the thread spans them opaquely.

  Proper fix (large, discrete project):
  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.
  2. Model labels as *tags* on messages (many-to-many table `message_labels`) rather than folders.
  3. UI changes: the "folder tree" for Gmail becomes a label list with chips; sidebar still shows inbox/sent/drafts as synthesized views.
  4. Sync path uses Gmail's native `history.list` + `labelIds` diffs — see separate "Gmail History API" TODO.
  5. Batch prefetch then dedupes on `gmail_id`, skipping already-cached bodies regardless of which labels they have.

  Non-Gmail IMAP accounts keep the folder model. Abstraction is per-provider: IMAP providers report folders, Gmail reports labels.

- [ ] **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:
  - Group the `getMessagesWithoutBody` result by folder.
  - 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).
  - After each body lands: `updateBodyPath` + emit `folderCountsChanged` (incremental teal-dot fill — the batch-emit we just wired).
  - 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.
  - Chunk size: 100 messages per batch (conservative — IMAP servers vary, Gmail's batch cap is 100).
  - 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.
  - Non-goal: full parallelism. Gmail rate-limits (HTTP 429) and Dovecot connection caps mean one batched connection is better than N parallel unbatched ones.
  - 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.
  - 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).

- [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.

<a id="ext46"></a>

### C46 — Windows OS integration (PARTIAL)  [↑ summary](#sum)

- [ ] **mailto: handler** → see [P115](#ext115) — split out as standalone priority item, NOT blocked on MSIX (registry-only).
- [ ] **Windows Share target** — register as Share target (requires MSIX packaging)
- [ ] Native right-click context menus, system tray, file associations, notifications

<a id="ext115"></a>

### P115 — Default mailto handler (Windows registry)  [↑ summary](#sum)

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).

**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).

**Owed work**:
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.
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).
3. **Registry registration**: new `bin/mailx.ts -register-mailto` (and `-unregister-mailto`) that writes:
   - `HKCU\SOFTWARE\Classes\mailx\shell\open\command` → `"<mailx.exe>" --mailto "%1"`
   - `HKCU\SOFTWARE\Classes\mailto\shell\open\command` (only if user opts in to taking the protocol)
   - `HKCU\SOFTWARE\Clients\Mail\mailx\Capabilities\URLAssociations` → `mailto=mailx` (so it appears in Settings → Default apps → Email)
   - `HKCU\SOFTWARE\RegisteredApplications` → `mailx=SOFTWARE\Clients\Mail\mailx\Capabilities`
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.
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.

**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).

**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.

<a id="ext116"></a>

### P116 — Signature edit in Settings menu  [↑ summary](#sum)

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.

**Owed work**:
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.
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`).
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.
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`).

**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.).

<a id="ext49"></a>

### S49 — Body comingling (DIAG v1.0.338)  [↑ summary](#sum)

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.

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.

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.

**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.

<a id="ext50"></a>

### S50 — `accounts.jsonc` banner won't stop firing (OPEN)  [↑ summary](#sum)

User sees the "config changed — restart to apply" banner repeatedly, even after no edits. Shipped v1.0.338:
1. `fs.watch` callback hash-compares file content before emitting; metadata-only fires (atime bumps, rename-aside etc.) don't flip the banner.
2. Cloud-poll normalization (BOM/CRLF/trailing-newline strip + JSONC-semantic compare) so a download that formats differently but parses identically doesn't fire.
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.

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.

<a id="ext51"></a>

### S51 — Calendar Thunderbird-style sidebar (PARTIAL — sidebar UI done, live Google data still owed)  [↑ summary](#sum)

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.

**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).

**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.

<a id="ext52"></a>

### S52 — Generalize the `primary` account flag (DONE v1.0.376 — option (a))  [↑ summary](#sum)

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.

<a id="ext54"></a>

### S54 — (retired 2026-04-23)  [↑ summary](#sum)

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).

<a id="ext63"></a>

### S63 — Desktop compose pop-out (separate OS window) (OPEN ❓)  [↑ summary](#sum)

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.

**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?

<a id="ext55"></a>

### S55 — (retired 2026-04-23)  [↑ summary](#sum)

Split into S64 (icon identity) + S65 (badge-when-not-running). Both blocked on changes outside this repo.

<a id="ext64"></a>

### S64 — Pinned-taskbar icon shows mailx identity (OPEN, blocked on msger)  [↑ summary](#sum)

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.

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)").

<a id="ext65"></a>

### S65 — Unread badge visible when mailx isn't running (OPEN ❓, blocked)  [↑ summary](#sum)

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.

**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.

<a id="ext53"></a>

### S53 — Android parity debt (umbrella retired 2026-04-23 — split into S57–S62)  [↑ summary](#sum)

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.

<a id="ext57"></a>

### S57 — Android: INBOX-first local render (OPEN)  [↑ summary](#sum)

`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.

<a id="ext58"></a>

### S58 — Android: rail on narrow (OPEN)  [↑ summary](#sum)

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).

<a id="ext59"></a>

### S59 — Android: hamburger no-op (OPEN)  [↑ summary](#sum)

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).

<a id="ext60"></a>

### S60 — Android: keyboard → input lag (OPEN)  [↑ summary](#sum)

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.

<a id="ext61"></a>

### S61 — Android: Send leaves a second compose open (OPEN)  [↑ summary](#sum)

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.

<a id="ext62"></a>

### S62 — Android: prefetch priority + speed (OPEN)  [↑ summary](#sum)

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.

<a id="ext56"></a>

### S56 — Row-objects own the preview pane (OPEN; last slice of the old S1 umbrella)  [↑ summary](#sum)

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.

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.

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.

<a id="priority-1"></a>

## Priority 1 — Get basics working  [↑ top](#top)

### External content security
- [ ] **Allowlist editor** — button in message header to view/edit the remote content allowlist (senders, domains, recipients)
- [ ] **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.
- [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.
- [ ] **Finer control** — Thunderbird-style: per-image allow, manage in control panel
- [ ] **Allowlist reads from cloud API** — loadAllowlist() must read via Drive API, not stale local cache. Local cache is offline fallback only.

### Sync polish
- [ ] **Sync status per message** — ↻ indicator on messages with pending local changes
- [ ] **Offline indicator** — status bar shows online/offline state
- [ ] **Conflict resolution** — last-write-wins or merge flags

### Config & Deploy
- [ ] **Local appearance.jsonc** — move theme, font sizes, window position/size into a per-machine `~/.mailx/appearance.jsonc`. Per-system, not shared.
- [ ] **In-app settings page** — edit local/per-device settings (theme, font size, sync interval, historyDays) from the UI.
- [ ] **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.
- [ ] **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):
    - **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>/`.
    - **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.
    - **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.
    - **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.
    - **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.
- [ ] **Add Account wizard** — enter email, auto-detect settings (already have ISPDB/SRV), prompt for password or OAuth, test connection, save.
- [ ] **Contacts sync UI** — show sync status, manage which accounts sync contacts, manual sync button
- [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.)
- [ ] **In-app account management** — add/edit/delete accounts from UI. Work with zero accounts (show setup).
- [x] **README** — substantively complete (401 lines, refreshed with all recent features + editor + preview shortcut tables). Will keep growing as features land.
- [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.
- [ ] **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.

- [ ] **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.

- [ ] **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).

- [ ] **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.

  **Three tiers (breakpoints chosen to match real device classes):**
  1. **Wide** (≥1200px — desktop/laptop): `[rail | folder list | message list | viewer]`. Everything visible; folder list permanent.
  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`.)
  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`.)

  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.

  **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.

  **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.

  **Implementation:**
  - Extend `client/styles/layout.css` grid from the current two-regime setup to four: wide / medium / narrow / very-narrow.
  - Add `.icon-rail` component to `client/components/` and wire to existing actions (`btn-compose`, folder-tree toggle, etc.).
  - Folder panel's `open`/`closed` state becomes meaningful in both medium and narrow tiers (currently only thin).
  - Reuse the existing hamburger + folder overlay for narrow/very-narrow.
  - Single `.html` + CSS change applies to both `index.html` (desktop) and `android.html`.
  - ~half day for rail + 3-tier CSS, another half for wiring icons + ensuring folder panel toggles cleanly in each tier.

  **Calendar + tasks in the rail** — rail icons for calendar and tasks must fit the same responsive model:
  - 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.
  - 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.
  - 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.
  - 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.

- [ ] **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.

- [ ] **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.

  **Current state:**
  - 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.
  - 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).
  - ~~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.).

  **Target design:**
  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.
  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.
  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.
  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.
  5. **Drafts↔Outbox is a flag toggle**, not separate UI/storage — `\Draft` flag means "still editing," absence + presence in Outbox folder means "queued."
  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.
  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.
  8. **Servers without custom-folder support** — fall back to using `Drafts` with a custom flag (`$Mailx-Queued`) as the queue indicator.

  **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.

  **Conflict cases worth thinking through before coding:**
  - Two devices race on draft edit → APPEND-then-EXPUNGE pattern means whichever lands second wins; older copy is gone. Acceptable.
  - 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.
  - Device A claims + sends + deletes from Outbox; Device B's IDLE missed the EXPUNGE → next sync sees the gone-UID and resyncs. OK.
  - 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.

  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).
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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).
- [ ] **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.

### Server-side changes
- [ ] **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.
- [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.

### Connection resilience
- [ ] **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.

### Remaining basics
- [ ] Standalone `mailsend` CLI keeps filesystem queue for non-IMAP use

<a id="priority-2"></a>

## Priority 2 — Core UX  [↑ top](#top)

### Right-click / context menus
- [ ] **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)

### Compose editor
- [ ] **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.
- [x] **Compose close prompt** — X / Escape / close all ask Save / Discard / Cancel when there's content to save.
- [ ] **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).
- [ ] **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.
- [ ] **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.
- [ ] **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.

### Compose / From address
- [ ] **Custom From history** — persist manually-entered From addresses
- [ ] **From address mapping** — rules to auto-set From based on mailing list, recipient domain
- [x] **Auto-detect reply From** — Reply auto-From detects the right identity address based on the received recipient (handles aliases / +tag addresses).

### Folder management
- [ ] **Move folder** — drag to reorder/reparent
- [ ] **Incremental folder tree updates** — patch badge counts in place, no full DOM rebuild
- [ ] **Detect case-duplicate folders** — highlight duplicates, prevent creating new ones

### Message list
- [ ] **Control panel / Settings UI** — accounts, sync, view defaults, contacts, OAuth management
- [ ] **Cloud provider icon** — show Google Drive / OneDrive / Dropbox logo icon in toolbar or status bar to indicate active cloud provider
- [ ] **Color schemes / theming** — configurable colors, remove debug blue toolbar
- [ ] **Full menu bar** — File, Edit, View, Message menus with keyboard shortcuts
- [x] **Validate email addresses on send** — To/Cc/Bcc/From all vetted against `local@domain.tld` shape before send.
- [x] **Undo move (Ctrl+Z)** — works for last move and last delete (whichever is more recent), 60s window.
- [ ] **Message list management** — column resize (drag headers), drag-to-rearrange column order, sort by clicking column headers (from/date/subject/size with secondary sort by date), show/hide columns (size, account) via View menu. Column layout persists LOCALLY (per-machine, localStorage) — explicit user request 2026-07-04.
- [ ] **Generalize "Eleanor view" into finer view control [M]** — the View-menu "Eleanor view" checkbox (2026-07-04, reworked same day per feedback) is a named CSS preset: always-two-line card list (From … Date / Subject), outlined entries, extra vertical whitespace, monochrome rows, no avatar/snippet, at every width. Replace the one-off preset with real per-view settings — column set + order, row density (compact/normal/airy), row tinting on/off, snippet on/off — persisted per folder or as named view profiles. The preset becomes just a saved profile once profiles exist.
- [ ] **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.

### msger / mailx factoring
- [ ] **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.

### Pop-out & Multi-window
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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.


### Search
- [ ] **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.
- [ ] **More qualifiers** — date:, has:attachment, is:flagged, folder:
- [ ] **Gmail gmraw passthrough** — use Gmail's native search syntax for Gmail accounts
- [ ] **Full body text in FTS5** — currently only indexes preview snippet
- [ ] **Search results show folder origin** — indicate folder/account per result

<a id="priority-3"></a>

## Priority 3 — Polish  [↑ top](#top)

- [ ] **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.
- [ ] **Show link URL on hover** — display target URL in status bar
- [ ] **Sanitizer link corruption** — some URLs get corrupted by regex sanitizer. Consider DOMPurify.
- [ ] Threading / conversation view

## Message Metadata & External Integration

- [ ] **Per-message metadata** — JSON column for custom annotations (AI ratings, categories, priority)
- [ ] **Metadata API** — REST endpoint to get/set per-message metadata
- [ ] **Priority/Importance display** — show ! icon for high priority messages
- [ ] **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.

## Rules, Filtering & Extensions

- [ ] **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.

<a id="near-term"></a>

## Near-term  [↑ top](#top)

- [ ] **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.
- [ ] **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.
- [ ] **"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.

## Phase 2+ (future)

- [ ] **iOS** — same WebView + native bridges pattern as Android/MAUI
- [ ] **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.
- [ ] **Inline compose** — refactor from navigation to inline panel (no page switch)
- [ ] READMEs for all packages
- [ ] **Multiple emails per contact** — show all in autocomplete

---

<a id="done-recent"></a>

## Done (recent — version-tagged)  [↑ summary](#sum)

| Item | Version |
|---|---|
| **WebView timing forwarded to daemon log + cleanups** — Daemon log shows boot is **~204 ms to showService** (2026-05-09 14:23 run). The "long startup" delay is entirely WebView2 init + JS module cascade + first paint after the daemon hands off. The `[wv N ms]` / `[preview N ms]` / `[search N ms]` marks were `console.log`-only — invisible to the daemon log. Now forwarded via `mailxapi.logClientEvent` so they land alongside `[boot N ms]` for full timeline. Plus: `android.html` removed (renamed `.deleted`); `silent=true` on logit URLs (Android + WebView error handlers) so verbose marks don't spam jserv UI; build clean. Run mailx, check the log — actual breakdown of where the time goes is now visible. | v1.0.626 |
| **Preview-path timing checkpoints** — User report 2026-05-09: "still seeing very long delays on previews that should be instantaneous? It finally displayed but why?" The boot timing answers cold-start; this answers click-to-preview. New `[preview N ms] showMessage entry / parsedCache HIT|MISS / IPC done (cached, N bytes html) / iframe srcdoc set / iframe load (rendered)` marks. Slow paths now have stage-by-stage breakdown — IPC vs parser vs iframe-paint becomes visible without speculation. | v1.0.625 |
| **Search history dropdown (recent queries)** — User request 2026-05-09: "the search bar should be a dropdown offering me recent searches so I don't need to type them again. Just recent dropdowns is fine for now without editing and remove from history. Editing is implicit in bringing up a search and being able to edit it." Native HTML5 `<datalist id="search-history">` bound to `#search-input` via `list=` attribute — browser picker handles dropdown UX with no custom code. Backed by localStorage `mailx-search-history` (cap 25, move-to-front dedup). Recorded only when `doSearch(immediate=true)` fires (Enter / scope change) so debounced keystrokes don't pollute the list. Picking a past entry brings it into the input where the user can edit and re-Enter — the edited version becomes the new top entry. | v1.0.624 |
| **List/search/startup polish + measurement** — User stack 2026-05-09: (a) "long startup — would be nice to see a countup time in addition to the spinner." Live elapsed-seconds counter (ticks 100 ms) appended to `#startup-status`; auto-stops when overlay is removed. (b) "deleted entries reappeared after switching windows." `removeMessagesAndReconcile` now `listCache.clear()`s — pre-fix the per-folder snapshot cache repainted the deleted rows on next visit. (c) "switched to All Inboxes but search is still showing — being ignored?" Both folder handlers (regular + unified) now call `clearSearchMode()` AND clear the input — previously only the input was cleared, leaving `searchMode = true` so subsequent loads were rerouted. (d) "search is suddenly slow." Added `[search N ms] start / IPC begin / IPC done (n/total)` console marks so the actual cost is visible. Pair with `[boot N ms]` and `[wv N ms]` for a full timing picture. | v1.0.623 |
| **PWA convergence: one HTML, runtime bootstrap detect, MAUI points at index.html** — User insight 2026-05-09: "If I run on a Pixel tablet it should look like the desktop... You're missing the entire point of the PWA approach!" Two HTML files (`index.html` desktop, `android.html` MAUI) had been forking — different toolbars, different button labels, different search controls, different reset wording. CSS responsive layout already drives wide-vs-narrow; the HTML fork was forcing phone-style on tablets regardless of viewport. Convergence: `index.html` now carries the union of platform-runtime concerns (Android import map for in-WebView module resolution, logit error handlers, single boot router that branches on `!window.mailxapi` — present on desktop because msger pre-injects via initScript, absent on Android until `initAndroid()` runs). MAUI's `MainPage.xaml.cs` flipped `webView.Source` to `client/index.html`. `android.html` left in place as a safety net for now; delete after a successful tablet/phone smoke test. Pixel tablet → desktop layout (CSS wide breakpoint). Phone → narrow layout. Browser → desktop layout. Same code path. Plus per-stage boot timestamps `[wv N ms]` from html-parse-start through first rAF, paired with the daemon's `[boot N ms]` lines from v1.0.611 — actual measurement, not theorizing. | v1.0.621 |
| **Header encoded-words: uniform decode at the storage seam** — User reports 2026-05-09: ".eml file with strange header" (chase / spam .eml had `From: "=?UTF-8?B?...?=" <s@x>` — encoded-word inside quoted-string, RFC-strictly forbidden but common in the wild). Bob: "you aren't parsing encoded header fields correctly. It's not subject that is encoded. You should uniformly process header escapes early in the parsing." Right answer: not per-callsite patches. Added `decodeHeaderWords` helper at the top of `mailx-store/db.ts` using `libmime.decodeWords`; called once in `upsertMessage` for `subject`, `from.name`, `to[].name`, `cc[].name`. Every storage path (IMAP fetch, Gmail API, manual append, eml replay) now decodes uniformly. Safe on already-decoded text — no `=?` markers → no-op. Old rows in DB keep their bad names until re-fetched; new fetches and any sync action that re-upserts the row pick up the fix. | v1.0.618 |
| **List selection: clicks during in-flight IPC no longer bounce** — User report 2026-05-09: "I just selected the 10:03 message and it showed and then bounced back to the 15:49. I have to be quick to capture the 10:03 before it bounces away." Root cause: `loadUnifiedInbox` and `loadMessages` captured `savedUid` at function start, then awaited the IPC. If you clicked a different row during the await, the new selection painted briefly, then `renderMessages` (post-IPC) cleared it and `restoreSelection` restored the *captured* old uid — your click was overwritten. Fix: read the selected uid LIVE — once before each `restoreSelection` call (just before `renderMessages` clears the DOM). The user's mid-flight click survives the re-render. | v1.0.617 |
| **Allowlist click: refresh in-memory cache after unblock** — User report 2026-05-09: "I pressed unblock but again it failed. With chase the next run found it unblocked. You added it to allowed but don't know it!" Persistence WAS working — next launch saw the chase.com domain in allowlist. The current-session re-render path was broken: `loadRemote` swapped the iframe but never updated `currentMessage` or the WebView-side `parsedCache` (added this morning for second-view speed). Any subsequent showMessage call hit the stale blocked-version cache and re-rendered the banner. Now `loadRemote` writes the unblocked message into both `currentMessage` and `parsedCachePut(accountId, uid, full)` so future renders agree with what the user just saw. | v1.0.616 |
| **Reminder popup: unified path** — User report 2026-05-09: "popup calendar has none of the new features for editing, snoozing etc." Two divergent code paths (`showPopup` for in-WebView, `showOsPopupForItem` for msger) had different button sets, different post-click logic, different aggregation. Collapsed into a single `firePopupForItem(item)` with the render target picked internally — `showInWebViewPopup()` (DOM overlay, single item) when no msger host, `showReminderPopup()` (OS window) when there is. Both paths now share: openPopups dedup, button labels (Snooze 15m / Snooze 1h / Dismiss / Open), post-click switch (Open opens htmlLink + 2-min snooze + state clear, Dismiss = permanent, X/Esc = 15-min auto-snooze), `firedThisSession` semantics. The aggregated multi-item panel is gone — one popup per due item, same shape on both render targets. Behavior changes are now reviewable in one place. | v1.0.615 |
| **Reminder popup: skip if one for the same event is already open** — User report 2026-05-09: "just popped up again. Can you figure out you are already showing the popup?" Yes — the OS popup was fire-and-forget without an open-tracking registry; if a 2-min Open-snooze expired while the user was still looking at the first popup, pollAlarms would happily open a second window for the same uuid. Added `openPopups: Set<uuid>` registered at popup-open and cleared in `finally` (so even unhandled rejections don't leak the entry). pollAlarms filter now checks both `firedThisSession` AND `openPopups`. Doesn't fix the underlying "two paths in alarms.ts" structural issue — `showPopup` (browser fallback) and `showOsPopupForItem` (msger) still diverge. That unification is filed as the next step. | v1.0.614 |
| **Del key works with list selection even when focus is in search box** — User report 2026-05-09: "selected all npm messages and pressed del, nothing happened, but the top trashcan worked." Root cause: Del-key handler short-circuited whenever `e.target.tagName === "INPUT"` so JSONC-editor / compose-form Del would do its native thing. Search input retaining focus after a filter triggered the same guard. Fix: only defer to native Del when there's NO list selection AND focus is in a text surface. With multi-selected rows the user's intent is "delete those" regardless of focus. | v1.0.613 |
| **Cold-start: parallel imports + measurement timestamps** — User feedback 2026-05-09: "It used to start much faster, I'm very suspicious of your diagnosis." Real culprit found by reading the code (not theorizing): seven `await import()` calls in `bin/mailx.ts` runs sequentially before `showService` — `mailx-store`, `mailx-imap`, `mailx-service`, `mailx-service/jsonrpc.js`, `mailx-settings`, `node-tcp-transport`, `mailx-store/file-store.js`. Each blocked the next; they have no inter-dependencies. Now batched into one `Promise.all`, paying ESM resolution + module init cost once concurrently instead of seven times sequentially. Plus checkpoint timestamps (`[boot N ms] label`) at modules-loaded, settings-loaded, DB-opened, body-path-migration, calling-showService, addAccount-start, addAccount-complete — so next startup the log shows the actual timeline. Now we'll see numbers, not theory. | v1.0.611 |
| **App name single source of truth (APP_NAME)** — User feedback 2026-05-09: "the string rmfmail should be a named value not inline." HTML had hardcoded "rmfmail" in `<title>`, About button, app-version spans, startup-status. Now: HTML carries placeholder text, `propagateAppName()` runs at module load and stamps `APP_NAME` into every surface. APP_NAME is the only place the literal lives — rename the app, update one constant. | v1.0.610 |
| **Allowlist click reliability + honest startup string** — User reports 2026-05-09: (a) "I clicked on already show @chase.com why isn't it unblocking?" — handler awaited `allowRemoteContent` BEFORE re-rendering; if the persist threw (atomicWrite fails / GDrive unavailable / IPC error) the unhandled rejection silently aborted the click. Now re-render runs FIRST, persist runs in background, errors surface in the status bar. (b) "What server are you referring to?" — startup overlay said "Connecting to server..." despite there being no separate server (mailx is one integrated package). Now says "Loading…" and the JS hydration replaces it with the real status as soon as the IPC bridge is alive. | v1.0.609 |
| **Trashcan optimistic remove + link-hover dwell 1.5 s** — User report 2026-05-09 (a) "Trashcan on top takes a long time to delete." Trash awaited the IPC round-trip BEFORE removing rows from the list — felt sluggish on bigger selections / congested IPC. Spam button already optimistic-removed first; trash now matches: rows disappear immediately, IPC runs in background, errors surface in status bar. (b) "0.5 sec is too short for the over time. try 1.5." Link-hover dwell delay 500 ms → 1500 ms (`client/app.ts`); avoids flicker on quick mouse traversal. | v1.0.608 |
| **Link-hover preview restored** — User report 2026-05-09: "what happened to showing links on long hover?" Removed 2026-04-24 because the popover persisted on screen — parent's mousedown/scroll/blur dismissers never fire while the cursor is inside the iframe. Restored with the actual fix: the iframe-side mouseover handler posts the link URL to the parent (which keeps the existing 500 ms debounce + overlay suppression), and crucially the iframe ALSO posts an empty-URL message on link mouseout AND on body mouseleave. Parent treats empty URL as "hide now" — so the popover dismisses reliably regardless of which dismissers fire. Belt-and-suspenders covers the edge case where mouseout doesn't fire (Edge / older WebView2). | v1.0.606 |
| **Spam / Move: rows no longer reappear after action** — User report 2026-05-09: "select two lines with luxury watches, press the spam button, they go away ... and then they come back." Same root cause as today's trash fix: `MailxService.moveMessages` (and singular `moveMessage`) updated only the legacy `messages.folder_id` column. The user-visible list query joins through `message_folders`; when the next folder-counts event refreshed the list, the mf row still pointed at the source folder so the message reappeared. Fix: both methods now use `db.moveMessageLocal(uid, fromFolderId, toFolderId)` which updates BOTH columns atomically. Spam button and drag-to-folder both use this path. Regression test added in `tests/undelete.test.ts` ("spam regression: marked-as-spam rows don't reappear"). | v1.0.605 |
| **Reminder popup: collapse Edit+Open into one button, treat post-edit as new entry** — Bob 2026-05-09: "edit/open should be the same — we only need one. After edit it can be treated as a new entry to be reprocessed and rescheduled." Single "Open" button now opens htmlLink, clears the dismissed state for the uuid, and applies a 2-min snooze so the GCal sync window can pick up the edit (from any client — web, phone, another mailx, co-organizer) before the next poll re-evaluates. If the start gets pushed out by an hour, the alarm waits for the new lead time; if pulled in, it re-fires after the 2-min sync window. No carryover from old localStorage state. | v1.0.604 |
| **Reminder popup: stop auto-re-fire on X-close, add Edit/Open buttons** — Bob 2026-05-09: "Nice to see the calendar popup but it keeps popping up and needs snooze/dismiss/edit/open." Root cause: the `default:` branch in the popup-result switch (covers `closed`/`dismissed`/empty button) called `firedThisSession.delete(item.uuid)` so the next 30 s poll re-showed the same reminder — spam loop. Fix: X-close / Esc / closed-without-pick now auto-snoozes 15 min, matching what the user clearly meant ("not now"). They can dismiss permanently by clicking Dismiss when the snooze expires. Added "Edit" and "Open" buttons for items with htmlLink (calendar events): Open opens the link AND snoozes 15 m (peek), Edit opens the link AND dismisses (engage). Removed "Snooze 5m" (rarely useful) so the new buttons fit. Tasks still get only Snooze/Dismiss (no htmlLink). | v1.0.603 |
| **Ctrl+Z undelete works (trash = local move, not delete)** — Bob 2026-05-09: "^z is not undeleting the last deletion." Root cause: `trashMessage`/`trashMessages` did `db.deleteMessage(uid)` (wiped row + body file) and queued an IMAP MOVE keyed on the original UID. `undeleteMessage` then queued a counter-MOVE *also* keyed on the original UID — but after the to-trash MOVE drained on the server, the trash-side uid was new, so the counter-MOVE's `fetchByUid` returned null and the action silently dropped. Plus, even if the queue resolved, the local DB had no row to show until the next reconcile. Fix: trash is now a **local move** to the trash folder via new `db.moveMessageLocal(uid, fromFolderId, toFolderId)` — updates BOTH `messages.folder_id` AND the `message_folders` join row, preserving the same uid. The body file stays put. Undelete inverts: move back locally, then either cancel the still-pending to-trash sync action (server never saw the delete) or queue a counter-MOVE that drains as soon as the reconciler has rebound the trash-side uid via Message-ID. New `db.findPendingSyncAction` helper covers the cancel case. Daemon-side regression tests in `tests/undelete.test.ts` cover both branches + uid-preservation + missing-row cases. `tests/dom/` will pick up the keyboard handler when we have a fixture for that. | v1.0.602 |
| **Viewer scroll preserved across list re-renders** — Bob 2026-05-09: "I click on an entry... but when I go back to the letter preview window it sometimes jumps back to the top as if it were being reactivated anew." Root cause: list rebuilds on every folderCountsChanged / sync event; `restoreSelection()` then calls `focusRow()` on the row whose uid matches, firing `viewerShow()` on the same message that's already showing. The viewer re-paints, `bodyEl.innerHTML = …` destroys the iframe, scroll resets to 0. Fix: same-message guard at the top of `showMessage` — if `currentAccountId === accountId && currentMessage.uid === uid && (folderId matches) && bodyEl has iframe && !isRetry`, refresh the header from the new envelope and bail without touching `bodyEl`. The iframe and its scroll position survive. Retry path (bodyAvailable, transient-error retry, flag-watch banner) bypasses via `isRetry=true`. Caught + verified by new JSDOM client-side test harness (`tests/dom/`); first three tests reproduce the scroll wipe and the cross-message and cross-account negative cases. | v1.0.600 |
| **JSDOM client-side test harness (tests/dom/)** — new `tests/dom/setup.ts` builds a fresh JSDOM per test with a stub `mailxapi`, a `fireEvent` helper for IDLE/sync subscribers, and per-test global teardown. Catches the class of bugs that manifest in the WebView but not in the daemon: click→render races, send-while-typing, scroll preservation, focus jumps. First three tests cover viewer-scroll preservation; more land as Bob's reports surface. Wired into `npm run test` and `npm run test:fast`. | v1.0.600 |
| **C123 — Dynamic fast-lane connection (per-account)** — second persistent IMAP client per account (`fastClients` map) lazy-allocated alongside the existing slow-lane `opsClients`. `withConnection` fast tasks queue and drain on the fast client; slow tasks continue on the original ops client. Both lanes drain CONCURRENTLY now (`runningFast` / `runningSlow` independent flags) — click-time body fetch and flag toggle never wait behind a multi-minute prefetch / backfill again. Costs +1 IMAP socket per active account (Bob's bobma: 3 sockets per account = ops + fast + IDLE, well under Dovecot's 20-conn cap). Stale-detect, error-discard, shutdown, disconnectOps all updated to handle both lanes. Ops verbose-IMAP wire trace propagated to the fast lane so click-time wedges show in the log. Replaces the chunking-as-yield-point hack from v1.0.586 (which is still useful but no longer load-bearing). | v1.0.587 |
| **Pull-to-refresh on Android (and any touch context)** — Thunderbird-style drag-down at the top of the message list fires a sync, same path as F5 / btn-sync. Indicator slides in with the pull, chevron rotates at 80px to signal "Release to refresh", spinner replaces it during the actual sync. Touch-only — desktop keeps F5 / sync button. Lives in `client/app.ts` so it ships to Android MAUI through the shared WebView build automatically. | v1.0.587 |
| **Click-time body fetch unblocked by long-running sync/prefetch** — `mailx-imap.syncFolder` set-diff backfill loop and `prefetchBodies` IMAP path both held the persistent ops client for entire-folder durations. A 33-UID prefetch that grew to 222 mid-flight held it for 100 minutes (Bob log 2026-05-08 08:30→10:11). During the hold, every interactive click went into iflow's commandChain BEHIND the slow ops, so the user's preview pane stayed blank. Fix: chunk both paths — prefetch through `withConnection({slow:true})` per 25-UID slice, syncFolder backfill ditto per 100 UIDs (was 500). Each chunk releases the queue between fetches so fast-lane body clicks interleave roughly every 1-30s instead of waiting for the whole folder. The chunk sizes balance throughput (one FETCH per chunk, iflow's `fetchChunkSize:10` makes that 1-3 sub-FETCHes) against latency (worst-case click wait). | v1.0.586 |
| **Body fetch silent-null no longer hangs the viewer** — `Reconciler.pumpBodyFetches` only emitted `bodyAvailable` when `fetchMessageBody` returned a buffer; on null (Gmail format=raw cap, UID hash collision, IMAP source-extraction empty) it neither emitted `bodyAvailable` NOR `bodyFetchError`, so the viewer's placeholder + listener-armed loop stayed waiting forever. Now: null result emits `bodyFetchError` with `transient:false` and a concrete reason, so the existing `mv-body-fetch-error` banner shows instead of a permanent blank. | v1.0.586 |
| **Prefetch data-loss fix — guard against "0 of N returned"** — `mailx-imap.prefetchBodies` was pruning the entire requested batch when `fetchBodiesBatch` returned successfully but with no bodies (FETCH parser miss, wrong-folder, transient that didn't throw). User-reported on 2026-05-08: `[prefetch] bobma: 0 bodies cached, 66 stale rows pruned (done)` — 66 messages silently deleted from local DB. Earlier fix only covered "thrown batch ⇒ don't prune" (the 296-message loss); this one closes the parallel "successful but empty batch" hole on both IMAP and Gmail-API paths. Now: prune only when at least one body returned. Zero-return logs `batch returned 0/N bodies — NOT pruning (set-diff reconcile owns deletion)` and defers to syncFolder's grace-window reconcile. Per Bob's 2026-05-07 set-diff insight: deletion is exclusively the set-diff path's responsibility. | v1.0.585 |
| **mailx → rmfmail (CLI strings + log filename + post-register hint)** — daily log file is now `rmfmail-YYYY-MM-DD.log`; the 30-day retention scanner also matches the legacy `mailx-` prefix so old files age out cleanly. `mailx-store/db.ts` schema-mismatch error: "Run 'rmfmail -rebuild'". `mailx-server/index.ts` startup banner: "rmfmail server starting…" / "rmfmail server running on …". `--register-mailto` post-output rewritten to point at the right Win11 Settings path, name the Store-app warning, and explain the hash-protected `UrlAssociations\mailto\UserChoice` so the user understands why a registry write alone can't flip the active default. `openLocalPath("log")` opens `rmfmail-…` first, falls back to legacy filename if the daemon hasn't restarted since the upgrade. | v1.0.584 |
| **Trailing-comma email validation** — `mailx-service.send()` `validateAndPrune` (was `validateList`) silently drops empty / whitespace-only address fragments instead of throwing "empty address". Pruned list replaces `msg.to/cc/bcc` so the artifact also doesn't reach SMTP envelope assembly. Bare-name fragments (no `@`) still throw — that's a different bug. | v1.0.584 |
| **Send-hang root cause: synchronous group expansion (compose.ts)** — Send appeared to hang for 61 s on Bob 2026-05-09 00:16:57 because `await expandGroups(...)` inside the click handler awaited `loadGroupsMap()` which awaited `readJsoncFile("contacts.jsonc")` which fired a GDrive HTTP read. Slow network → 61 s hang → user clicked Send again at 00:23 → two real outgoing messages with different Message-IDs. expandGroups is now SYNCHRONOUS from in-memory cache + localStorage; cloud refresh runs in background at module load. Send NEVER awaits cloud I/O for group expansion. | v1.0.597 |
| **Message-ID dedup in INBOX views (db.ts)** — Thunderbird/Outlook collapse same-Message-ID rows in the inbox view; mailx was showing every copy. When a sender includes multiple of the user's own addresses on the recipient line, the local mail server delivers ONE message as N rows in the local DB — same Message-ID, different UIDs. SQL ROW_NUMBER OVER PARTITION BY message_id keeps the newest per Message-ID. Applied to both `getUnifiedInbox` and `getMessages`. Empty Message-IDs (rare; non-RFC senders) get unique buckets so each is its own row. | v1.0.597 |
| **Instant folder-switch via in-memory list cache (message-list.ts)** — clicking "All Inboxes" or any folder paints from the JS Map cache (~1 ms) BEFORE the IPC round-trip even fires; the IPC runs in background and only re-renders if the new data diverges from the cached snapshot. Listed-equality check (UIDs + flags) avoids DOM churn / scroll-jump on the common no-change refresh. The user-visible "click → empty pane → 50-200 ms wait → list paints" pattern collapses to "click → list visible immediately". First visit to a folder still needs the IPC round-trip; second-and-later are instant. | v1.0.597 |
| **Stale-response guard in message-list** — `loadMessages`, `loadUnifiedInbox`, `loadSearchResults` each capture a generation counter at the top and check before rendering. Rapid folder switches no longer let the previous folder's response clobber the new folder's render after the user has moved on. Dead `abortMessageListRequests` was incomplete cancellation; gen-counter is the working pattern. | v1.0.596 |
| **Send is atomic / fire-and-forget close** — `compose.ts` Send button used to await the IPC round-trip before closing. With a server-side stall the user could keep typing for several hundred ms, then their last edits vanished mid-keystroke when IPC eventually returned and triggered close. Snapshot body, validate locally (regex + non-empty To), close compose IMMEDIATELY, fire-and-forget the IPC. Failures bubble to the parent as `mailx-send-error` postMessage; the local outbox write inside `service.send` is synchronous-on-disk so the message is durable before the IPC even resolves. | v1.0.595 |
| **Outbox Cancel always available + claim age** — `outbox-view.ts` "Cancel" button was disabled whenever `claimed: true`, so a stuck `.sending-host-pid` claim left the message uncancelable. Now Cancel is always enabled; claim age is shown in the badge ("sending… (5s)" → "stuck (3m)" after 60 s). Confirm prompt warns that an in-flight cancel may cross the SMTP-send window — Message-ID dedup catches the duplicate, so it's still safe. Stale-claim recovery threshold cut from 1 h → 5 min so abandoned `.sending-` files reclaim themselves automatically before the user sees them as "stuck forever". Render-back failure now logs loudly instead of swallowing. | v1.0.595 |
| **Cold-start parallelism** — five places where startup serialized work that didn't need to: (a) `await new Promise(setTimeout, 500)` "Brief pause for WebView2 init" in `bin/mailx.ts:1588` removed (msger queues stdin; the wait was defensive band-aid that added unconditional 500 ms latency); (b) `for (const account of settings.accounts)` sequential `addAccount` → `Promise.all`; (c) doc-deploy's `for (const f of mdFiles) await provider.write(...)` sequential cloud writes → `Promise.all` (was ~5-10 s for 9 files, now ~1 s); (d) setup-form's `setTimeout(reload, 7000)` dwell-before-reload removed (reload immediate); (e) device-account autosetup's 2 s reload dwell same fix. | v1.0.595 |
| **C120 — TCP transport diagnostics in per-folder timeout** — `mailx-imap`'s `syncOne` per-folder Promise.race timeout used to throw a bare `per-folder timeout (360s): <path>` error with no transport context. Now reads `client.transport.diagnostics` at the timeout moment and appends `[conn#N r=YB w=ZB writes=W sinceLastRead=Tms]` — same format iflow-direct uses for its own command-level timeouts. Lets the `[sync] folder error` log distinguish "server stopped sending data" (sinceLastRead climbing) from "we never finished writing" (writes climbing, no reads) without crossing into iflow's own log timeline. | v1.0.583 |
| **P115 — Default mailto: handler (Windows registry)** — `mailx --mailto <url>` parses RFC 6068 fields and atomic-renames `~/.rmfmail/pending-mailto.json`; the running daemon's `fs.watch` on the dir picks the file up and emits an `openMailto` event, the client opens compose pre-populated. New `bin/mailto.ts` helper isolates URL parsing + write/consume. `mailx --register-mailto` / `--unregister-mailto` write/remove the `HKCU\Software\Classes\rmfmail` + `HKCU\Software\Clients\Mail\rmfmail` registry keys via `reg.exe`, so rmfmail shows up in Settings → Default apps → Email. Startup race avoided via service-side `consumePendingMailto` IPC: client polls on app init for a file that existed before the daemon's watcher armed (case: `--mailto` with no daemon running spawns one). Watch + poll both atomic-read+delete; whichever fires first wins, the other gets null. | v1.0.583 |
| **Spring-loaded folders polish (Q117)** — drag-hover-expand was already partially in but persisted to localStorage on every transient expand; reworked to keep the expand transient (auto-collapse on `dragend`) and named the `DRAG_HOVER_EXPAND_MS = 600` constant so it isn't a magic number. Tracked auto-expanded folders in `dragAutoExpanded` set; global dragstart/dragend listeners scoped to mailx-message MIME types so the watcher doesn't fire on unrelated drags (image drag, text selection, etc.). | v1.0.583 |
| **Calendar alarm popup actually fires** — `client/components/alarms.ts` `collectDueAlarms` was reading `ev.start` (undefined) instead of `ev.startMs` from the DB row; `alarm = -10 min epoch` was always less than `now - 1 hr` so the LOOKBACK filter rejected every event silently. After the fix, calendar reminders 10 min before start fire as designed; the OS popup + in-WebView fallback paths inherited from earlier implementation. Tasks were already correct (`t.dueMs`). | v1.0.583 |
| **Mailx-server: NodeTransport → NodeTcpTransport** — `packages/mailx-server/index.ts` was importing the now-removed `@bobfrankston/iflow-node` package. Switched to the canonical `@bobfrankston/node-tcp-transport` import (same path the IPC daemon uses in `bin/mailx.ts`) so the legacy `--server` Express mode at least compiles. Runtime still needs the C34 wiring before it's usable end-to-end, but the package now builds clean. | v1.0.583 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **"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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **`--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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **"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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **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 |
| **`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 |
| **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 |
| **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 |
| **Tasks modal Esc-to-close** — earlier guard blocked Esc when quickadd input had focus. | v1.0.352 |
| **Sent column From→To regression** — case-insensitive specialUse match, handles nested paths. | v1.0.352 |
| **markAsSpamMessages** via `specialUse` — no longer requires explicit `account.spam`. | v1.0.352 |
| **Compose Send waits for ACK** — keeps compose open with inline error on IPC failure instead of silently dropping. | v1.0.350-352 |
| **Android APK build script** — `android-maui/build-apk.cmd`: one-command dotnet build + copy + versions.json bump. Reads version from csproj. | v1.0.350 |
| **P20** Server-search orthogonal checkbox beside search bar; scope → server when checked. | v1.0.345 |
| **Q50** About dialog Version row links to GitHub release tag. | v1.0.345 |
| **Q63** Compose Cc/Bcc toggle buttons `tabindex="-1"` — Tab walks From→To→Subject→body. | v1.0.345 |
| **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 |
| **C26/S1** `listQueuedOutgoing` + `cancelQueuedOutgoing` IPC methods. | v1.0.344 |
| **Q64** Double-click message row → popout floating overlay, draggable + resizable + close. | v1.0.344 |
| **C29** Right-click links in body iframe → Open / Save-as / Copy URL / Copy link-text. | v1.0.344 |
| **C38** `getOpsClient` pre-checks socket liveness — catches Dovecot silent-IDLE-drops earlier. | v1.0.344 |
| **C36** Compose right-click selection → "Proofread selection" via `aiTransform`. Gated by Settings toggle. | v1.0.343 |
| **C42** Per-account signature in `accounts.jsonc`; appended on new/reply/forward (not draft). | v1.0.343 |
| **C45** Search qualifiers: `date:` / `after:` / `before:` / `has:attachment` / `is:flagged\|unread\|read\|answered\|draft` / `folder:name`. | v1.0.343 |
| **P15** JSONC editor Del key — global handler skips when focus inside INPUT/TEXTAREA/contenteditable. | v1.0.343 |
| **Q53** Per-account last-sync timestamps in `status-sync` tooltip, refreshed every 30s. | v1.0.343 |
| **Q54** Account-header right-click: Mark all read / Expand·Collapse all / Sync now. | v1.0.343 |
| **Q56** Viewer "Details" rows each have Copy (⧉) button. | v1.0.343 |
| **Q65** Alert banner auto-dismiss after 30s for non-critical banners. | v1.0.343 |
| **Q68** Compose unsaved-draft `•` marker in window title; cleared on save. | v1.0.341 |
| **P14** Auto-id/label from email local-part in `normalizeAccount`. | v1.0.341 |
| **C30** Concurrent prefetch guard — `prefetchingAccounts` Set. | v1.0.341 |
| **Q3** Paste image inline — clipboard image → `data:` URL. | v1.0.341 |
| **Q55** Ctrl+Enter in compose triggers Send. | v1.0.341 |
| **Q57** Right-click folder → Copy folder path. | v1.0.341 |
| **Q58** Message-list keyboard nav: Home / End / PgUp / PgDn. | v1.0.341 |
| **Q59** Compose autosave on window blur. | v1.0.341 |
| **Q61/Q62** Settings → Open mailx folder / Open log. | v1.0.341 |
| Address book / Calendar / Tasks panes — rail buttons enabled, local-only stores. | v1.0.341 |
| `windowsHide:true` on every spawn/execSync — no cmd-window flash. | v1.0.341 |
| Hover link tooltip — 500ms delay, suppressed when compose/modal open. | v1.0.341 |
| IPC timing instrumentation — `[ipc] → <action> ok in Nms`, `[send] +Nms <step>`, `[outbox] WROTE <path>`. | v1.0.341 |
| `saveDraft` + `markAsSpamMessages` use cached accounts — no GDrive stall on critical path. | v1.0.341 |
| `queueOutgoingLocal` kicks `processLocalQueue` via `setImmediate` — IPC ack returns before queue scan. | v1.0.341 |
| Auto-create `~/.mailx/sending/README.md` on startup. | v1.0.339 |
| Outbox status indicator in status bar — event-driven from `outboxStatus` event. | v1.0.338 |
| `getOutboxStatus()` service method scans outbox + sending-queued dirs. | v1.0.338 |
| Body-comingling guard (S49 DIAG) — Message-ID match in `fetchMessageBody`. | v1.0.338 |
| accounts.jsonc banner fix (S50) — hash-compare + cloud-poll JSONC-semantic compare. | v1.0.338 |
| Disk-first durable send queue — `.ltr` written synchronously before `processLocalQueue` kick. | v1.0.335 |
| Compose Ctrl+scroll / Ctrl+=/−/0 zoom, persisted. | v1.0.335 |
| S3 Reply-opens-blank band-aid — `openCompose` populates unconditionally from local DB. | v1.0.337 |
| S6 Spam/Move target empty — imap-layer now queue-only, no double-wipe. | v1.0.334 |
| S8 `history: 0` truncated — Gmail 200-id cap removed, IMAP 90-day cap removed, lazy chunked backfill. | v1.0.331-333 |
| P11 Server search spans all folders + materializes unknown UIDs. | v1.0.331 |
| P12 Unsubscribe fallback + diagnostic error. | v1.0.332 |
| P21 JSONC editor X close + resize corner. | v1.0.330 |
| P22 Windows taskbar per-app icon + unread badge. | v1.0.322 |

## Done

- [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.

- [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".

- [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.

- [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.
- [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.

- [ ] **Linux deploy hardening (parked 2026-04-13)** — Several unresolved threads from today's Linux session, parked while focus stays elsewhere:
  - **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.
  - **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).
  - **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.
  - **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.
  - **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.

- [ ] **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.

- [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.

- [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.
- [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.
- [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:
  - **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`.
  - **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.
  - **resender** still uses nodemailer per "no urgency to switch" — listed as future swap.
- [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.
- [x] IPC mode activated — msger.localhost custom protocol, navigation handler, compose in-window
- [x] Gmail API provider — REST sync replaces IMAP for Gmail accounts (faster, no connection limits)
- [x] Outlook API provider — Microsoft Graph API skeleton (needs Azure app registration)
- [x] Shared message state — message-state.ts: list/viewer share message objects, auto-select on delete/move
- [x] Prefetch setting — `sync.prefetch: true` default, background body download after sync
- [x] IMAP timeout 30s→60s, INBOX retry up to 5 attempts with backoff
- [x] Window size/position persistence — saved to .msger-window.json on close, restored on open
- [x] Compose rich text toolbar — font family, size, color, background color, alignment (Quill)
- [x] Stale viewer/attachment fix — viewer clears on folder switch, delete, move
- [x] RFC 2047 folding fix — whitespace between encoded words stripped per §6.2
- [x] URL opening via rundll32 — no cmd.exe quoting issues
- [x] Honest error messages — no "Authentication may have expired" guessing
- [x] Fast shutdown — 2s per-connection timeout, 3s hard exit
- [x] Gmail outbox connection storm fix — respects backoff, skips failing accounts
- [x] Gmail send via SMTP from local queue (no IMAP outbox needed)
- [x] Outbox/sending dirs moved to ~/.mailx/ (preserved by ager)
- [x] Quoted-printable encoding for outgoing mail (readable debug .eml files)
- [x] Dead connection detection — auto-reconnect on socket close/error
- [x] Console window hidden after IPC launch (terminal freed, --verbose to keep)
- [x] Daemon mode — `mailx` returns terminal immediately, re-spawns detached with --daemon
- [x] Sync doesn't steal focus — folderCountsChanged only updates badges, never reloads message list
- [x] Identity domain reply-from — per-account `identityDomains` in accounts.jsonc, auto-sets From on reply based on Delivered-To match
- [x] Reply From includes display name — `Bob Frankston <addr@domain>` not bare address
- [x] Prefetch runs continuously — fetches all missing bodies in one pass, not 25 per cycle
- [x] Gmail API 500 retry — retries on server errors with backoff (same as 429)
- [x] Undefined IPC guard — ignores malformed messages instead of crashing
- [x] Unsubscribe button fix — uses window.open() click handler for IPC mode compatibility
- [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.
- [x] Mark read/unread — R key toggle + right-click context menu
- [x] Auto-expand folder on drag hover — 500ms delay, expands collapsed folders
- [x] Right-click context menu on message list — mark read/unread, flag, reply, forward, delete
- [x] Right-click context menu on viewer header — copy address, reply, forward
- [x] Keyboard navigation — arrow keys up/down to navigate message list
- [x] Show link URL on hover — status bar shows URL when hovering links in email body
- [x] Status bar debug info — shows account/uid/folder when message is selected
- [x] Non-modal compose — floating iframe, draggable title bar, resizable, multiple compose windows
- [x] Gmail API provider platform-independent — no Buffer dependency, uses atob/TextDecoder
- [x] Client registration — clients.jsonc on GDrive with device info, accounts, version, IP
- [x] SMTP credential fallback — uses IMAP credentials when SMTP not explicitly configured
- [x] Local-first move — DB updated immediately on drag-move, IMAP sync in background
- [x] Folder sort by name — Drafts, Sent Items, etc. sort correctly even without specialUse flag
- [x] Single install — npm workspaces + npmglobalize
- [x] Cross-platform launcher — WebView2 (Windows), webkit2gtk (Linux) via msger/wry/tao
- [x] CI/CD — rust-builder auto-builds per platform
- [x] Cloud storage — GDrive API for shared settings, local cache for offline
- [x] Unsubscribe button — List-Unsubscribe header (mailto + https)
- [x] Move mailsend under mailx — packages/mailx-send
- [x] mailx-settings publishable with npmglobalize
- [x] No mlconfig dependency
- [x] Platform-independent settings path — ~/.mailx/config.jsonc pointer
- [x] Direct WebView2 launcher (Rust/wry/tao) with -dev/-prod/-restart, lock file
- [x] Window size/position persistence with DPI scaling
- [x] Embedded icon in exe for taskbar pin
- [x] mailxapi injected in WebView2
- [x] Localhost URLs stay in WebView, external links open system browser
- [x] Compose with Quill rich text editor (CDN), Bcc field, editable From
- [x] IMAP IDLE push — watchMailbox() in iflow
- [x] Compose, Reply, Reply All, Forward — prefilled fields, quoted body in div.reply
- [x] Address autocomplete — recently-sent + seeded from messages + Google Contacts
- [x] Google Contacts sync — People API with OAuth2
- [x] Ctrl+K address completion, smart Tab navigation in compose
- [x] SMTP send via nodemailer (password + OAuth2)
- [x] Gmail SMTP OAuth2 — uses iflow tokenProvider for access token
- [x] IMAP Outbox with multi-machine interlock ($Sending flag), local file fallback
- [x] Outbox auto-retry on startup (3s delay), outbox badge (red/orange) for pending
- [x] Local-first compose — always succeeds, worker syncs to IMAP
- [x] Copy to Sent folder via IMAP APPEND
- [x] Auto-save drafts every 5 seconds
- [x] Delete draft after successful send
- [x] Edit Draft / Resume — button in message header for Drafts/Outbox folders
- [x] IMAP APPEND/delete/flags support in iflow
- [x] Full body download during sync (source: true) for offline reading
- [x] Body fetch on demand with 15s timeout and auto-retry
- [x] Date column with 24h locale formatting, monospace font
- [x] Light/dark mode following system theme
- [x] Remote content blocking — HTML sanitization + CSP + per-sender/domain allow-list
- [x] Remote content banner — collapsible dropdown with sender/recipient details, action buttons
- [x] remoteAllowed flag — CSP skipped when allowlist auto-allows (fixes broken images)
- [x] View Source button — copies .eml file path to clipboard
- [x] Delivered-To, Return-Path, List-Unsubscribe headers exposed in API
- [x] Flag/unflag — click star, gold highlight
- [x] Delete with undo (Ctrl+Z within 30s), delete stays in place (no scroll jump)
- [x] Sync deletions — purge server-deleted messages from local DB
- [x] Folder counts recalculated on delete/move
- [x] Local-first sync — delete/flag/move update local DB, queue IMAP sync
- [x] sync_actions table, ↻ pending indicator in status bar
- [x] Nested folder tree — expand/collapse, virtual parents, state in localStorage
- [x] INBOX first, special folders sorted, unified "All Inboxes"
- [x] Unified inbox — SQL query with folder_id IN (...), not 10000-msg memory load
- [x] Message lookup by accountId + uid + folderId (fixes UID collision across folders)
- [x] CSS grid layout with subgrid for aligned columns
- [x] Infinite scroll
- [x] View menu — two-line, preview pane, flagged-only filter (localStorage)
- [x] Sent/Drafts/Outbox show To instead of From
- [x] Splitter position persistence (localStorage)
- [x] Plain HTTP server (removed certsupport dependency, localhost-only)
- [x] No body size limit on Express JSON parser
- [x] JSON error handler — all server errors return JSON, never HTML
- [x] Quoted-printable Content-Transfer-Encoding for sent mail
- [x] Graceful shutdown with 3s timeout
- [x] Fresh IMAP connection per folder during sync (fixes connection drop after ~10 folders)
- [x] Incremental UID-based sync, INBOX prioritized
- [x] INBOX poll every 30s (independent of full sync) with retry
- [x] Search index rebuild wrapped in single transaction (50x faster)
- [x] Restart button with node --watch auto-restart
- [x] Server + client version display in toolbar with Sync/Restart labels
- [x] No-cache API headers
- [x] Full-text search — SQLite FTS5, from:/to:/subject: qualifiers
- [x] iflow searchMessages() method + SearchObject export (ready for UI wiring)
- [x] Status page at /status — uptime, memory, accounts, pending sync
- [x] Request logging middleware
- [x] File logging to ~/.mailx/logs/ with 7-day rotation
- [x] Smart auto-update — folder counts refresh, message list only reloads when not reading
- [x] Abort stale fetch requests when switching folders
- [x] Message viewer generation counter (fixes race condition / stale preview)
- [x] Settings split — accounts.jsonc, preferences.jsonc, allowlist.jsonc with local cache/overrides
- [x] Allow-list supports senders, domains, and recipients
- [x] config.json → config.jsonc migration, readJsonc auto-discovers .json/.jsonc
- [x] Account label (UI display) separate from name (From header)
- [x] Account defaultSend flag for custom From routing
- [x] From field as proper select dropdown with label/name split + "Other..." custom
- [x] Cross-account message move via iflow moveMessageToServer
- [x] Drag-and-drop messages to folders (move), multi-select with Shift/Ctrl+click
- [x] Client-side message list filter box (instant text filter, Esc to clear)
- [x] Folder filter/search box above folder tree
- [x] Startup overlay with spinner + status during initial load
- [x] Empty folder clears message viewer (no stale preview)
- [x] WebView2 launcher: fresh data dir (~/.mailx/webview2/), 127.0.0.1, no console window
- [x] Fonts bumped for readability (preview 17.5px base), line-height 1.45
- [x] Tab in compose accepts autocomplete without jumping to next field
- [x] Attachment download — clickable chips open PDFs/images in browser
- [x] Folder context menu — right-click: mark all read, new subfolder, rename, delete, empty (Trash/Junk)
- [x] Create / delete / rename folders via API
- [x] Empty Trash/Junk — permanently delete all messages
- [x] Search scope dropdown: All folders / This folder / IMAP server
- [x] IMAP server search via iflow searchMessages() with qualifier parsing
- [x] Regex search: /pattern/ prefix for client-side regex filtering
- [x] Scoped FTS5 search (filter by accountId + folderId)
- [x] Unicode characters in HTML (no entity escapes)
- [x] Keyboard shortcut hints in toolbar tooltips
- [x] IPC architecture built — mailx-core with dispatch(), mailxapi.js bridge, api-client auto-detects
- [x] Case-insensitive mailbox name lookups (IMAP RFC)
- [x] Two-way sync — local-first delete/move/flag/send queue to sync_actions, periodic IMAP sync pulls changes back
- [x] Drafts — auto-save to IMAP Drafts every 5s, delete after send, Edit Draft button to resume
- [x] Delivered-To / recipient alias — extract real recipient, skip relay domains, strip prefixes
- [x] Auto-detect mail server settings — Thunderbird ISPDB, Mozilla autoconfig, DNS SRV records
- [x] npm install deployment — npmglobalize publishes all packages
- [x] Cloud-authoritative settings — GDrive API is source of truth, local is cache
- [x] Google Contacts sync — People API with OAuth2, autocomplete from synced contacts
- [x] Configurable OAuth credentials path — checks ~/.mailx/ then iflow package dir
- [x] Attachment download — clickable chips open in browser
- [x] Single-module architecture — IPC-first, no Express server required, msger serves files via custom protocol
- [x] Responsive / small screen — CSS media queries, narrow layout, breakpoint-driven

---

<a id="not-needed"></a>

## Not needed  [↑ top](#top)

- ~~Mobile setup via QR code~~ — shared settings on GDrive handle multi-device
- ~~**Side door server**~~ — IPC mode is default, `--server` flag for HTTP dev mode
- ~~Platform-specific npm packages~~ — msger binary included via npmglobalize postinstall
- ~~**Remote Web Access**~~ — each device runs its own mailx instance syncing directly to IMAP/Gmail API

<a id="questions-decided"></a>

## Decided questions (archive)  [↑ top](#top)

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.

| # | Decision | Status |
|---|---|---|
| **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) |
| **Q102** | Theme = System / Light / Dark radio select. Menu for now, Settings panel later. | → Done v1.0.384 |
| **Q103** | Rules / extensions engine parked pending imail experiments. | → [AI section](#ai) (Q103-AI); no implementation task yet |
| **Q105** | Dally (`~/.mailx/dally/`) dropped. Ctrl+Z and compose Save/Discard/Cancel cover loss recovery. | → S4 retired; no task |
| **Q106** | Outbox stays on filesystem queue (`~/.mailx/outbox/<acct>/*.ltr`) with per-instance atomic-rename claim. | → Status quo (already shipped); C27 parked; no task |
| **Q107** | Gmail label-native model deferred. Keep hash-UID. | → C25 parked; no migration task |
| **Q108** | Message-ID-match guard is the permanent body-comingling defense. | → Already shipped v1.0.361; S49 closed |
| **Q111** | Writing assistance moved to AI section pending back-end choice. | → [AI section](#ai) (Q111-AI); no implementation task yet |
