@bobfrankston/rmfmail 1.2.187 → 1.2.190

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/TODO.md +10 -7
  2. package/client/android-bootstrap.bundle.js +66 -47
  3. package/client/android-bootstrap.bundle.js.map +2 -2
  4. package/client/app.bundle.js +22 -0
  5. package/client/app.bundle.js.map +3 -3
  6. package/client/components/calendar-sidebar.js +12 -1
  7. package/client/components/calendar-sidebar.js.map +1 -1
  8. package/client/components/calendar-sidebar.ts +8 -1
  9. package/client/components/message-viewer.js +19 -0
  10. package/client/components/message-viewer.js.map +1 -1
  11. package/client/components/message-viewer.ts +17 -0
  12. package/client/compose/compose.bundle.js +4 -2
  13. package/client/compose/compose.bundle.js.map +2 -2
  14. package/client/compose/editor.js +11 -2
  15. package/client/compose/editor.js.map +1 -1
  16. package/client/compose/editor.ts +11 -2
  17. package/client/package.json +1 -1
  18. package/docs/TODO-prereorg-snapshot-2026-07-15.md +1130 -0
  19. package/package.json +5 -5
  20. package/packages/mailx-imap/package-lock.json +2 -2
  21. package/packages/mailx-imap/package.json +1 -1
  22. package/packages/mailx-store/db.d.ts +6 -2
  23. package/packages/mailx-store/db.d.ts.map +1 -1
  24. package/packages/mailx-store/db.js +46 -16
  25. package/packages/mailx-store/db.js.map +1 -1
  26. package/packages/mailx-store/db.ts +53 -24
  27. package/packages/mailx-store/package.json +1 -1
  28. package/packages/mailx-store-web/android-bootstrap.d.ts.map +1 -1
  29. package/packages/mailx-store-web/android-bootstrap.js +84 -50
  30. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  31. package/packages/mailx-store-web/android-bootstrap.ts +86 -51
  32. package/packages/mailx-store-web/package.json +1 -1
  33. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-35072 → node_modules.npmglobalize-stash-12376}/.package-lock.json +0 -0
package/TODO.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mailx TODO
4
4
 
5
- *Last updated: 2026-04-27 21:10 local · v1.0.431 (contact-list junk filter + sent/received split + Esc-during-attach guard; build clean, uncommitted)*
5
+ *Last updated: 2026-07-30 · v1.2.189 (bug-sweep: paste-link double-insert generalized, task-delete confirms, ⊘ in viewer address menu, C158 Android double-init + OAuth single-flight guards; stale entries reconciled)*
6
6
 
7
7
  > **Policy: completed items live at the end of this file.** Active sections (autonomous queue, priority tables, quick wins, categorized) show OPEN / PARTIAL / PENDING only. When an item ships, strike it through *and move the row* to the [Done section](#done-recent) (or [Done](#done) for older items). The version-tagged completion log is `DONE.md`; this file's Done section is the in-TODO archive of items that used to appear in the active tables.
8
8
 
@@ -21,7 +21,7 @@ These items don't need user input — source-only changes, compile-verified befo
21
21
  | **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. |
22
22
  | **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. |
23
23
  | **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`. |
24
- | **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). |
24
+ | ~~9~~ | ~~**C158 — Android double-init after fold-triggered reload**~~ **FIXED v1.2.189** (2026-07-30, needs on-device verify): `initAndroid` now single-flights via `window.__rmfInitAndroid` (window-global so it holds across duplicate module instances bundle + package-path specifiers), and the OAuth token provider single-flights per email via `window.__rmfOAuthInflight` so concurrent startup callers (reconcile + contacts + syncAll) share one consent instead of launching three. What triggers the reload-on-fold itself is still unidentified — the guards make it harmless; watch logit for the "duplicate suppressed (C158)" line to confirm. | M | |
25
25
  | **11** | **C160 — Attachment delete: Gmail + Android halves** [M] | M | Desktop IMAP shipped v1.2.173 (right-click chip → Delete one/all: verified local strip + append-confirm-then-delete server replace, .pre-strip.bak backup, UID rebind). Remaining: Gmail REST path (messages.insert + trash of original — changes message id, provider_id rebind) and Android (bridge method + web-service strip using its own parser). Strip helper lives in mailx-service (enumerateMimeLeafParts) — move to mailx-types when Android needs it. |
26
26
  | **12** | **C161 — `_rmfmail` IMAP folder for cross-machine state sync** [M] | M | Bob 2026-07-27: use a hidden `_rmfmail` folder on the mail server itself as a per-account sync channel for state that today rides on Google Drive or nowhere — reminder dismissals, per-machine prefs, read-position, contact edits for non-Google accounts. Advantages over Drive files: works for every IMAP account (no Google dependency), naturally per-account, versioned by APPEND, and reuses the existing connection/sync machinery. Shape: one message per state document with a stable `X-Rmfmail-State: <name>` header, save = delete-and-reappend (the draft-dedup pattern), merge semantics per document type (reminders already have per-key max merge in mailx-types). Design Qs: folder visibility (subscribe or not, hide from folder tree), conflict handling when two machines append concurrently, and whether reminders.jsonc migrates or dual-writes. First shipped consumer of the pattern: draft freshness probe (v1.2.180) already proves the search-by-header primitive. |
27
27
  | **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. |
@@ -155,7 +155,10 @@ None of these are truly showstoppers — mailx is usable — but they're the nex
155
155
  | [**S62**](#ext62) | OPEN | Android: prefetch priority/speed — most-recent-first within INBOX before any label. |
156
156
  | [**S63**](#ext63) ❓ | OPEN | Desktop compose pop-out — separate OS window, drag to another monitor. Blocked on msger popups (C28). Open Q: window mechanism. |
157
157
  | [**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. |
158
- | [**S66**](#ext66) | **PARTIALneeds 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. |
158
+ | ~~**S66**~~ | **SHIPPEDstale entry closed 2026-07-30** | ~~Google Tasks API 429 flood~~ the April patches (row-equality no-op upserts via `taskRowEquals`/`calendarRowEquals`, per-feature `quotaCooldown`, in-flight dedup, typed `GoogleHttpError`, sidebar `quotaError` banner) are long since in the built source AND the compiled `.d.ts` (verified by inspection 2026-07-30); the "needs build verify" note simply never got cleared. No 429-flood reports since. |
159
+ | ~~**S67**~~ | **FIXED v1.2.139 — needs on-device verify** | ~~Popout open destabilizes main window~~ — root cause: all windows shared one WebView2 user-data-dir, so a popout's crash could take the main window's browser process with it. msger grew a `profile` option (`webview2-<profile>` per-window user-data-dir); mailx passes `profile: "popout"` on all four secondary-window spawns. Verify on next popout: `AppData/Local/rmfmail/webview2-popout` created, no stall, main window stays alive. *(Row restored 2026-07-30 — it was recorded only in the pre-reorg top-level TODO.md, which is now a pointer.)* |
160
+ | ~~**S68**~~ | **FIXED v1.2.137** | ~~No `[client]` log lines from a long-running daemon~~ — console-redirect log path was resolved once at boot; a daemon running past midnight kept writing main-thread lines to the boot-day file. Fixed with day-aware logWrite that swaps the stream at midnight. *(Row restored 2026-07-30, same reason as S67.)* |
161
+ | ~~**S69**~~ | **FIXED v1.2.137** | ~~Message list scroll dead zone / snap-back yank~~ — (a) background refreshes re-fetched only page 1, collapsing scrollHeight under a deep reader; now re-fetch full loaded depth. (b) unified-inbox date walk had no usable global date index → ~750 ms temp-B-tree sort per page; fixed with global date indexes + index-ordered streaming. *(Row restored 2026-07-30, same reason as S67.)* |
159
162
 
160
163
  <a id="open-questions"></a>
161
164
 
@@ -260,7 +263,7 @@ Small, self-contained items. Pick them up between higher-priority blocks without
260
263
  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.
261
264
 
262
265
  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.
263
- - **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.
266
+ - ~~**Q150 — `discovered` contacts don't belong in the cloud file — split it.**~~ **ALREADY SHIPPED stale entry caught 2026-07-30.** `flushContactsConfigNow` (`mailx-service/index.ts`) deletes `merged.discovered` before every cloud write ("never sync the local-derived cache — Q150" comment in code), so an old bloated file self-heals on the next flush; the load path seeds `discovered` from the local corpus per device. Cloud file carries only preferred/denylist/groups.
264
267
  - ~~**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.
265
268
 
266
269
  - ~~**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.
@@ -268,7 +271,7 @@ Small, self-contained items. Pick them up between higher-priority blocks without
268
271
  - ~~**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).
269
272
 
270
273
  - **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).
271
- - **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).
274
+ - **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) **Done v1.2.189** (2026-07-30) — "Never use this address" added to the message-viewer From/To/Cc right-click menu (`message-viewer.ts`), routed through `addToDenylist`. (3) Related: route ★ to a Google-Contacts star instead of `preferred[]` once that's decided (see Q151 discussion).
272
275
  - **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.
273
276
  - **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.
274
277
  - **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.
@@ -278,7 +281,7 @@ Small, self-contained items. Pick them up between higher-priority blocks without
278
281
  - **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.
279
282
  - **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.
280
283
  - 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`.
281
- - **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.
284
+ - **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)~~**fixed v1.2.189** (2026-07-30): all three sidebar delete paths (per-row ×, batch Delete-key, context menu) now confirm, with wording that points at the checkbox for mark-done. The full tasks pane already confirmed.
282
285
  - **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.
283
286
  - **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.
284
287
  - **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).
@@ -383,7 +386,7 @@ Design-time terms used throughout this doc. Kept here (rather than README) becau
383
386
  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.
384
387
 
385
388
  <a id="ext10"></a>
386
- - [ ] **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.
389
+ - [x] ~~**Paste-link-with-text double-inserts**~~**FIXED v1.2.189** (2026-07-30). The capture-phase paste handler in `client/compose/editor.ts` (which stops Quill's parallel listener via `stopImmediatePropagation`) already covered browser copies, but only detected a *top-level lone* `<a>` Word/Docs wrap the anchor in `<p>`/`<span>` layers, so those clipboards fell through to Quill's own matcher. Detection generalized: one `a[href]` carrying all the meaningful text at ANY wrapper depth is treated as a single-anchor clipboard and consumed.
387
390
 
388
391
  <a id="ext7"></a>
389
392
 
@@ -10979,55 +10979,65 @@ async function refreshAccessToken(refreshToken) {
10979
10979
  return res.json();
10980
10980
  }
10981
10981
  function createNativeTokenProvider(email) {
10982
- return async () => {
10983
- const cached = await getCachedToken(email);
10984
- if (cached?.access_token) {
10985
- const expiresAt = cached.expires_at || 0;
10986
- const bufferMs = 5 * 60 * 1e3;
10987
- if (Date.now() < expiresAt - bufferMs) {
10988
- return cached.access_token;
10989
- }
10990
- if (cached.refresh_token) {
10991
- try {
10992
- console.log(`[oauth] Refreshing token for ${email}`);
10993
- const refreshed = await refreshAccessToken(cached.refresh_token);
10994
- const token2 = {
10995
- access_token: refreshed.access_token,
10996
- refresh_token: cached.refresh_token,
10997
- expires_at: Date.now() + refreshed.expires_in * 1e3
10998
- };
10999
- await setCachedToken(email, token2);
11000
- return token2.access_token;
11001
- } catch (e) {
11002
- console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
11003
- }
10982
+ return () => {
10983
+ const w = window;
10984
+ const inflight = w.__rmfOAuthInflight ||= /* @__PURE__ */ new Map();
10985
+ const key = canonEmail(email);
10986
+ const existing = inflight.get(key);
10987
+ if (existing) return existing;
10988
+ const p = fetchTokenForEmail(email).finally(() => inflight.delete(key));
10989
+ inflight.set(key, p);
10990
+ return p;
10991
+ };
10992
+ }
10993
+ async function fetchTokenForEmail(email) {
10994
+ const cached = await getCachedToken(email);
10995
+ if (cached?.access_token) {
10996
+ const expiresAt = cached.expires_at || 0;
10997
+ const bufferMs = 5 * 60 * 1e3;
10998
+ if (Date.now() < expiresAt - bufferMs) {
10999
+ return cached.access_token;
11000
+ }
11001
+ if (cached.refresh_token) {
11002
+ try {
11003
+ console.log(`[oauth] Refreshing token for ${email}`);
11004
+ const refreshed = await refreshAccessToken(cached.refresh_token);
11005
+ const token2 = {
11006
+ access_token: refreshed.access_token,
11007
+ refresh_token: cached.refresh_token,
11008
+ expires_at: Date.now() + refreshed.expires_in * 1e3
11009
+ };
11010
+ await setCachedToken(email, token2);
11011
+ return token2.access_token;
11012
+ } catch (e) {
11013
+ console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
11004
11014
  }
11005
11015
  }
11006
- const bridge2 = window._nativeBridge;
11007
- if (!bridge2?.app?.startOAuth) {
11008
- throw new Error("No native OAuth bridge");
11009
- }
11010
- const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
11011
- client_id: OAUTH_CLIENT.clientId,
11012
- redirect_uri: OAUTH_CLIENT.redirectUri,
11013
- response_type: "code",
11014
- scope: OAUTH_SCOPES,
11015
- access_type: "offline",
11016
- prompt: "consent",
11017
- login_hint: email
11018
- }).toString();
11019
- console.log(`[oauth] Starting browser consent for ${email}`);
11020
- const code = await bridge2.app.startOAuth(authUrl);
11021
- const tokens = await exchangeCodeForTokens(code);
11022
- const token = {
11023
- access_token: tokens.access_token,
11024
- refresh_token: tokens.refresh_token,
11025
- expires_at: Date.now() + tokens.expires_in * 1e3
11026
- };
11027
- await setCachedToken(email, token);
11028
- console.log(`[oauth] Token obtained for ${email}`);
11029
- return token.access_token;
11016
+ }
11017
+ const bridge2 = window._nativeBridge;
11018
+ if (!bridge2?.app?.startOAuth) {
11019
+ throw new Error("No native OAuth bridge");
11020
+ }
11021
+ const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
11022
+ client_id: OAUTH_CLIENT.clientId,
11023
+ redirect_uri: OAUTH_CLIENT.redirectUri,
11024
+ response_type: "code",
11025
+ scope: OAUTH_SCOPES,
11026
+ access_type: "offline",
11027
+ prompt: "consent",
11028
+ login_hint: email
11029
+ }).toString();
11030
+ console.log(`[oauth] Starting browser consent for ${email}`);
11031
+ const code = await bridge2.app.startOAuth(authUrl);
11032
+ const tokens = await exchangeCodeForTokens(code);
11033
+ const token = {
11034
+ access_token: tokens.access_token,
11035
+ refresh_token: tokens.refresh_token,
11036
+ expires_at: Date.now() + tokens.expires_in * 1e3
11030
11037
  };
11038
+ await setCachedToken(email, token);
11039
+ console.log(`[oauth] Token obtained for ${email}`);
11040
+ return token.access_token;
11031
11041
  }
11032
11042
  async function registerDeviceInGDrive(tokenProvider2, folderId, accountIds) {
11033
11043
  try {
@@ -11241,7 +11251,16 @@ async function waitForNativeBridge(timeoutMs = 5e3) {
11241
11251
  check();
11242
11252
  });
11243
11253
  }
11244
- async function initAndroid() {
11254
+ function initAndroid() {
11255
+ const w = window;
11256
+ if (w.__rmfInitAndroid) {
11257
+ console.warn("[android] initAndroid called again \u2014 duplicate suppressed (C158)");
11258
+ vlog("C158: duplicate initAndroid call suppressed");
11259
+ return w.__rmfInitAndroid;
11260
+ }
11261
+ return w.__rmfInitAndroid = initAndroidOnce();
11262
+ }
11263
+ async function initAndroidOnce() {
11245
11264
  console.log("[android] Initializing mailx (main-thread mode)...");
11246
11265
  await waitForNativeBridge();
11247
11266
  if (window._nativeBridge && !window.msgapi) {