@bobfrankston/rmfmail 1.2.49 → 1.2.65

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 (90) hide show
  1. package/client/android-bootstrap.bundle.js +63 -2
  2. package/client/android-bootstrap.bundle.js.map +4 -4
  3. package/client/app.bundle.js +417 -63
  4. package/client/app.bundle.js.map +3 -3
  5. package/client/app.js +182 -24
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +166 -21
  8. package/client/components/calendar-sidebar.js +5 -5
  9. package/client/components/calendar-sidebar.js.map +1 -1
  10. package/client/components/calendar-sidebar.ts +5 -5
  11. package/client/components/folder-tree.js +81 -0
  12. package/client/components/folder-tree.js.map +1 -1
  13. package/client/components/folder-tree.ts +70 -0
  14. package/client/components/message-list.js +282 -38
  15. package/client/components/message-list.js.map +1 -1
  16. package/client/components/message-list.ts +264 -36
  17. package/client/compose/compose.bundle.js +70 -14
  18. package/client/compose/compose.bundle.js.map +2 -2
  19. package/client/compose/compose.css +28 -7
  20. package/client/compose/compose.js +64 -4
  21. package/client/compose/compose.js.map +1 -1
  22. package/client/compose/compose.ts +66 -4
  23. package/client/compose/editor.js +23 -7
  24. package/client/compose/editor.js.map +1 -1
  25. package/client/compose/editor.ts +23 -7
  26. package/client/help/search-help.js +4 -3
  27. package/client/help/search-help.js.map +1 -1
  28. package/client/help/search-help.ts +4 -3
  29. package/client/index.html +5 -1
  30. package/client/lib/api-client.js +4 -2
  31. package/client/lib/api-client.js.map +1 -1
  32. package/client/lib/api-client.ts +4 -2
  33. package/client/lib/mailxapi.js +2 -2
  34. package/client/lib/rmf-tiny.js +39 -6
  35. package/client/styles/components.css +6 -0
  36. package/docs/azure.md +58 -0
  37. package/docs/config-help.md +131 -0
  38. package/docs/edit-in-word-docx.md +98 -0
  39. package/docs/host-abstraction-plan.md +169 -0
  40. package/docs/local-first-plan.md +303 -0
  41. package/docs/npmglobalize-transitive-workspace-deps.md +107 -0
  42. package/docs/outlook.md +80 -18
  43. package/docs/outlook.txt +35 -0
  44. package/docs/rules-design.md +172 -0
  45. package/package.json +7 -7
  46. package/packages/mailx-imap/index.d.ts +48 -1
  47. package/packages/mailx-imap/index.d.ts.map +1 -1
  48. package/packages/mailx-imap/index.js +332 -70
  49. package/packages/mailx-imap/index.js.map +1 -1
  50. package/packages/mailx-imap/index.ts +323 -67
  51. package/packages/mailx-imap/microsoft-credentials.json +8 -0
  52. package/packages/mailx-imap/package-lock.json +2 -2
  53. package/packages/mailx-imap/package.json +1 -1
  54. package/packages/mailx-imap/providers/outlook-api.d.ts +7 -0
  55. package/packages/mailx-imap/providers/outlook-api.d.ts.map +1 -0
  56. package/packages/mailx-imap/providers/outlook-api.js +7 -0
  57. package/packages/mailx-imap/providers/outlook-api.js.map +1 -0
  58. package/packages/mailx-service/index.d.ts +7 -1
  59. package/packages/mailx-service/index.d.ts.map +1 -1
  60. package/packages/mailx-service/index.js +8 -8
  61. package/packages/mailx-service/index.js.map +1 -1
  62. package/packages/mailx-service/index.ts +8 -7
  63. package/packages/mailx-service/jsonrpc.js +1 -1
  64. package/packages/mailx-service/jsonrpc.js.map +1 -1
  65. package/packages/mailx-service/jsonrpc.ts +1 -1
  66. package/packages/mailx-settings/docs/azure.md +58 -0
  67. package/packages/mailx-settings/docs/config-help.md +131 -0
  68. package/packages/mailx-settings/docs/edit-in-word-docx.md +98 -0
  69. package/packages/mailx-settings/docs/host-abstraction-plan.md +169 -0
  70. package/packages/mailx-settings/docs/local-first-plan.md +303 -0
  71. package/packages/mailx-settings/docs/npmglobalize-transitive-workspace-deps.md +107 -0
  72. package/packages/mailx-settings/docs/outlook.md +80 -18
  73. package/packages/mailx-settings/docs/rules-design.md +172 -0
  74. package/packages/mailx-settings/index.d.ts.map +1 -1
  75. package/packages/mailx-settings/index.js +9 -8
  76. package/packages/mailx-settings/index.js.map +1 -1
  77. package/packages/mailx-settings/index.ts +9 -8
  78. package/packages/mailx-settings/package.json +1 -1
  79. package/packages/mailx-store/db.d.ts +50 -1
  80. package/packages/mailx-store/db.d.ts.map +1 -1
  81. package/packages/mailx-store/db.js +120 -6
  82. package/packages/mailx-store/db.js.map +1 -1
  83. package/packages/mailx-store/db.ts +126 -6
  84. package/packages/mailx-store/package.json +1 -1
  85. package/packages/mailx-types/contact-rules.d.ts +1 -1
  86. package/packages/mailx-types/contact-rules.js +1 -1
  87. package/packages/mailx-types/contact-rules.js.map +1 -1
  88. package/packages/mailx-types/contact-rules.jsonc +1 -1
  89. package/packages/mailx-types/contact-rules.ts +1 -1
  90. package/packages/mailx-types/package.json +1 -1
package/client/app.js CHANGED
@@ -3,17 +3,41 @@
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
5
  import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced, setOutboxTotal } from "./components/folder-tree.js";
6
- import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView, refreshPriorityIndex } from "./components/message-list.js";
6
+ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, clearSearchMode, getSelectedMessages, markBodiesCached, getCurrentFocused, releaseFocus, removeMessagesAndReconcile, setRowFlagged, scrollFocusedIntoView, refreshPriorityIndex, showSelectionMenu, revealMessage } from "./components/message-list.js";
7
7
  import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
8
8
  import { initTabs, setActiveView as setActiveTabView, openTab } from "./components/tabs.js";
9
9
  import { getCurrentMessage, initViewer, popOutCurrentMessage, printCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from "./components/message-viewer.js";
10
- import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore, cancelServerSearch, installConsoleCapture } from "./lib/api-client.js";
10
+ import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore, cancelServerSearch, installConsoleCapture, getAttachment } from "./lib/api-client.js";
11
11
  import * as messageState from "./lib/message-state.js";
12
12
  // FIRST THING. Captures every console.log/warn/error + window errors +
13
13
  // unhandled promise rejections to the daemon log via logClientEvent, so
14
14
  // debugging client-side bugs never requires opening devtools — search
15
15
  // rmfmail-YYYY-MM-DD.log for "console.error" or "window.error" instead.
16
16
  installConsoleCapture();
17
+ // ── Main-thread stall watchdog ──
18
+ // Bob 2026-06-22: "trying to scroll does nothing, as if the UI thread is
19
+ // locked"; the UI must always be responsive. A blocked event loop is
20
+ // otherwise invisible — the symptom shows up in the UI but never names its
21
+ // cause. This watchdog re-arms a 1s timer and reports the slip whenever the
22
+ // callback fires late by more than a threshold: a 1s timer that fires at
23
+ // 1.0s is healthy; one that fires at 4.2s means the main thread was wedged
24
+ // ~3.2s (long IPC on the loop, giant synchronous render, parse on main).
25
+ // The slip duration + wall-clock pinpoints which interaction blew out.
26
+ (function installStallWatchdog() {
27
+ const EXPECTED_MS = 1000;
28
+ const STALL_MS = 250; // only report slips that a user would feel
29
+ let last = performance.now();
30
+ const tick = () => {
31
+ const now = performance.now();
32
+ const slip = now - last - EXPECTED_MS;
33
+ if (slip > STALL_MS) {
34
+ console.warn(` [stall] main thread blocked ~${slip.toFixed(0)}ms (event loop frozen — UI was unresponsive this long)`);
35
+ }
36
+ last = now;
37
+ setTimeout(tick, EXPECTED_MS);
38
+ };
39
+ setTimeout(tick, EXPECTED_MS);
40
+ })();
17
41
  // ── New message badge (favicon + title) ──
18
42
  /** The user-visible app name. Single point of change for the rename;
19
43
  * every UI surface that shows "rmfmail" reads from here. Static HTML
@@ -881,6 +905,17 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
881
905
  });
882
906
  async function openCompose(mode, overrideMsg, overrideAccountId) {
883
907
  logClientEvent("openCompose-entry", { mode });
908
+ // Opening a compose (Ctrl+N, the Compose button, the rail) must dismiss any
909
+ // open toolbar menu. Click-outside already closes them via the overlay
910
+ // shield, but a keyboard Ctrl+N over an open Settings/View menu left it
911
+ // stuck open behind the new compose (Bob 2026-06-21). Close them here so
912
+ // every compose-open path behaves the same.
913
+ for (const id of ["settings-dropdown", "view-dropdown", "restart-dropdown"]) {
914
+ const dd = document.getElementById(id);
915
+ if (dd && !dd.hidden)
916
+ hideDropdownHard(dd);
917
+ }
918
+ refreshToolbarOverlayShield();
884
919
  // `overrideMsg` lets a detached surface (the message pop-out) compose a
885
920
  // reply/forward for ITS message rather than whatever the main viewer has
886
921
  // selected. Same `{ message, accountId }` shape getCurrentMessage returns.
@@ -920,7 +955,16 @@ async function openCompose(mode, overrideMsg, overrideAccountId) {
920
955
  // Now finish initialisation off the critical path — editor bootstrap
921
956
  // inside the iframe runs concurrently with this await.
922
957
  const accounts = await accountsP;
923
- const accountId = current?.accountId || accounts[0]?.id || "";
958
+ // For a brand-new compose, default the From to the account whose folder
959
+ // you're currently viewing — pressing Ctrl+N while reading AOL should
960
+ // compose AS aol, not the first/defaultSend account. In the unified "All
961
+ // Inboxes" view there's no single account (currentAccountId === ""), so
962
+ // fall back to the selected message's account, then the first account.
963
+ // Reply/forward keep using the message's own account (you answer from the
964
+ // address that received it; detectReplyFrom refines further).
965
+ const accountId = mode === "new"
966
+ ? (currentAccountId || current?.accountId || accounts[0]?.id || "")
967
+ : (current?.accountId || currentAccountId || accounts[0]?.id || "");
924
968
  const rePrefix = /^(re|fwd?):\s*/i;
925
969
  const cleanSubject = msg ? msg.subject.replace(rePrefix, "") : "";
926
970
  const init = {
@@ -1077,6 +1121,32 @@ async function openCompose(mode, overrideMsg, overrideAccountId) {
1077
1121
  init.subject = msg.subject || "";
1078
1122
  init.bodyHtml = editAsNewBody(msg);
1079
1123
  }
1124
+ // Carry the original message's attachments into forward / edit-as-new so
1125
+ // reopening a sent message to edit (or forwarding one) keeps its files —
1126
+ // they were being silently dropped (Bob 2026-06-21). Fetch each via
1127
+ // getAttachment (the same path the viewer's download uses) and hand compose
1128
+ // the base64 it expects. Reply/replyAll intentionally omit attachments.
1129
+ if (msg && (mode === "editAsNew" || mode === "forward") && Array.isArray(msg.attachments) && msg.attachments.length) {
1130
+ const msgAccountId = current?.accountId || accountId;
1131
+ const loaded = [];
1132
+ for (const att of msg.attachments) {
1133
+ try {
1134
+ const data = await getAttachment(msgAccountId, msg.uid, att.id, msg.folderId);
1135
+ if (data?.content) {
1136
+ loaded.push({
1137
+ filename: data.filename || att.filename || "attachment",
1138
+ mimeType: data.contentType || att.contentType || "application/octet-stream",
1139
+ dataBase64: data.content,
1140
+ });
1141
+ }
1142
+ }
1143
+ catch (e) {
1144
+ console.error(`[compose] ${mode}: failed to load attachment ${att.id}: ${e?.message || e}`);
1145
+ }
1146
+ }
1147
+ if (loaded.length)
1148
+ init.attachments = loaded;
1149
+ }
1080
1150
  // Store init data for compose window to pick up. Two parallel paths
1081
1151
  // because sessionStorage propagation to a freshly-loading iframe has
1082
1152
  // intermittently failed under WebView2's custom-protocol host (Bob
@@ -1632,6 +1702,14 @@ async function performUndo() {
1632
1702
  document.addEventListener("mailx-moved", (e) => {
1633
1703
  pushUndo({ kind: "move", at: Date.now(), payload: e.detail });
1634
1704
  });
1705
+ // Bulk flag/unflag (Filtered menu, row context menu) emits "mailx-flagged-batch"
1706
+ // with the per-row prior state so Ctrl+Z restores the whole batch at once.
1707
+ document.addEventListener("mailx-flagged-batch", (e) => {
1708
+ const payload = e.detail;
1709
+ if (Array.isArray(payload) && payload.length) {
1710
+ pushUndo({ kind: "flag", at: Date.now(), payload });
1711
+ }
1712
+ });
1635
1713
  // ── Right-button drag → Move / Copy menu ───────────────────────────────
1636
1714
  // Browser drag-and-drop is left-button only, so a RIGHT-button drag is a
1637
1715
  // custom mouse-tracked gesture: right-press a message row, drag onto a folder
@@ -1980,6 +2058,14 @@ document.getElementById("btn-spam-report")?.addEventListener("click", async () =
1980
2058
  }
1981
2059
  });
1982
2060
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
2061
+ // Toolbar "Selection" button — opens the bulk-actions menu anchored under the
2062
+ // button (flag/read/move/delete + select-all/flagged/invert/clear). Rendered
2063
+ // via the shared context-menu component so it self-dismisses on outside click.
2064
+ document.getElementById("btn-selection")?.addEventListener("click", (e) => {
2065
+ e.stopPropagation();
2066
+ const r = e.currentTarget.getBoundingClientRect();
2067
+ void showSelectionMenu(r.left, r.bottom + 2);
2068
+ });
1983
2069
  document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
1984
2070
  // Toggle \Seen on the currently-focused message. Mirrors the R
1985
2071
  // keyboard shortcut and the right-click "Mark unread" menu item, but
@@ -2507,6 +2593,28 @@ let currentAccountId = "";
2507
2593
  let currentFolderId = 0;
2508
2594
  let reloadDebounceTimer = null;
2509
2595
  let lastListReloadAt = 0;
2596
+ // Search-as-find: closing search returns the user to the message they had
2597
+ // selected among the results, in its own folder — not back to the pre-search
2598
+ // view scrolled "who knows where" (Bob 2026-06-25). Capture the focused result
2599
+ // BEFORE clearing search mode, then navigate to that message's folder and
2600
+ // reselect it. With no selected result (or no folderId) fall back to the old
2601
+ // reloadCurrentFolder behavior.
2602
+ function closeSearchAndReveal() {
2603
+ const focused = getCurrentFocused();
2604
+ clearSearchMode();
2605
+ const body = document.getElementById("ml-body");
2606
+ if (body)
2607
+ body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
2608
+ if (focused && focused.folderId) {
2609
+ currentAccountId = focused.accountId;
2610
+ currentFolderId = focused.folderId;
2611
+ revealMessage(focused.accountId, focused.folderId, focused.uid);
2612
+ }
2613
+ else {
2614
+ reloadCurrentFolder();
2615
+ }
2616
+ setTitle(APP_NAME);
2617
+ }
2510
2618
  searchInput?.addEventListener("input", () => {
2511
2619
  clearTimeout(searchTimeout);
2512
2620
  if (serverSearchTimer) {
@@ -2519,12 +2627,7 @@ searchInput?.addEventListener("input", () => {
2519
2627
  // Cleared — reset immediately, no debounce. Must exit search mode
2520
2628
  // first; otherwise reloadCurrentFolder() sees searchMode=true and
2521
2629
  // re-runs the stale query (user-reported regression 2026-04-24).
2522
- clearSearchMode();
2523
- const body = document.getElementById("ml-body");
2524
- if (body)
2525
- body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
2526
- reloadCurrentFolder();
2527
- setTitle(APP_NAME);
2630
+ closeSearchAndReveal();
2528
2631
  }
2529
2632
  else {
2530
2633
  // Short debounce — just enough to coalesce a keystroke burst. The
@@ -2546,13 +2649,7 @@ searchInput?.addEventListener("keydown", (e) => {
2546
2649
  }
2547
2650
  cancelServerSearch();
2548
2651
  updateSearchHighlight();
2549
- clearSearchMode();
2550
- // Clear any client-side filters
2551
- const body = document.getElementById("ml-body");
2552
- if (body)
2553
- body.querySelectorAll(".filter-hidden").forEach(r => r.classList.remove("filter-hidden"));
2554
- reloadCurrentFolder();
2555
- setTitle(APP_NAME);
2652
+ closeSearchAndReveal();
2556
2653
  }
2557
2654
  });
2558
2655
  // Re-run the active search when the scope dropdown or "server too" checkbox
@@ -3952,6 +4049,7 @@ const viewDropdown = document.getElementById("view-dropdown");
3952
4049
  const optTwoLine = document.getElementById("opt-two-line");
3953
4050
  const optPreview = document.getElementById("opt-preview");
3954
4051
  const optSnippet = document.getElementById("opt-snippet");
4052
+ const optFlagStars = document.getElementById("opt-flag-stars");
3955
4053
  const optThreaded = document.getElementById("opt-threaded");
3956
4054
  const optFlagged = document.getElementById("opt-flagged");
3957
4055
  const optFolderCounts = document.getElementById("opt-folder-counts");
@@ -4031,6 +4129,16 @@ function hideDropdownHard(dd) {
4031
4129
  // while any toolbar dropdown is open — its pointerdown lands here in the
4032
4130
  // parent doc, triggering the same outside-click close. The shield sits
4033
4131
  // above compose (z 1600) but below the dropdown itself (z 2000).
4132
+ // Cleanup for the in-iframe Escape forwarder (see below). Null when no
4133
+ // forwarder is attached.
4134
+ let tbEscapeCleanup = null;
4135
+ function closeToolbarDropdowns() {
4136
+ for (const id of ["settings-dropdown", "view-dropdown", "restart-dropdown"]) {
4137
+ const dd = document.getElementById(id);
4138
+ if (dd && !dd.hidden)
4139
+ hideDropdownHard(dd);
4140
+ }
4141
+ }
4034
4142
  function refreshToolbarOverlayShield() {
4035
4143
  const dropdownOpen = ["settings-dropdown", "view-dropdown", "restart-dropdown"]
4036
4144
  .some(id => {
@@ -4047,18 +4155,57 @@ function refreshToolbarOverlayShield() {
4047
4155
  shield.addEventListener("pointerdown", (ev) => {
4048
4156
  ev.preventDefault();
4049
4157
  ev.stopPropagation();
4050
- for (const id of ["settings-dropdown", "view-dropdown", "restart-dropdown"]) {
4051
- const dd = document.getElementById(id);
4052
- if (dd)
4053
- dd.hidden = true;
4054
- }
4055
- shield?.remove();
4158
+ closeToolbarDropdowns();
4159
+ refreshToolbarOverlayShield();
4056
4160
  });
4057
4161
  document.body.appendChild(shield);
4058
4162
  }
4163
+ // Escape with the cursor inside the compose editor never reaches the
4164
+ // parent's Escape handler — the same-origin iframe captures the
4165
+ // keystroke. While a toolbar menu is open over compose, attach a
4166
+ // capture-phase keydown to each compose/popout iframe document that
4167
+ // closes the menu and swallows the event, so compose's OWN Escape
4168
+ // (the save/discard prompt) doesn't also fire. Only active while a
4169
+ // menu is open; detached when it closes (Bob 2026-06-21 "fix esc").
4170
+ if (!tbEscapeCleanup) {
4171
+ const docs = [];
4172
+ document.querySelectorAll(".compose-overlay iframe, .popout-overlay iframe")
4173
+ .forEach(ifr => { try {
4174
+ if (ifr.contentDocument)
4175
+ docs.push(ifr.contentDocument);
4176
+ }
4177
+ catch { /* cross-origin */ } });
4178
+ if (docs.length) {
4179
+ const onEsc = (e) => {
4180
+ if (e.key !== "Escape")
4181
+ return;
4182
+ if (!["settings-dropdown", "view-dropdown", "restart-dropdown"]
4183
+ .some(id => { const el = document.getElementById(id); return el && !el.hidden; }))
4184
+ return;
4185
+ e.preventDefault();
4186
+ e.stopPropagation();
4187
+ closeToolbarDropdowns();
4188
+ refreshToolbarOverlayShield();
4189
+ };
4190
+ for (const d of docs)
4191
+ d.addEventListener("keydown", onEsc, true);
4192
+ tbEscapeCleanup = () => {
4193
+ for (const d of docs) {
4194
+ try {
4195
+ d.removeEventListener("keydown", onEsc, true);
4196
+ }
4197
+ catch { /* */ }
4198
+ }
4199
+ tbEscapeCleanup = null;
4200
+ };
4201
+ }
4202
+ }
4059
4203
  }
4060
- else if (shield) {
4061
- shield.remove();
4204
+ else {
4205
+ if (shield)
4206
+ shield.remove();
4207
+ if (tbEscapeCleanup)
4208
+ tbEscapeCleanup();
4062
4209
  }
4063
4210
  }
4064
4211
  // Escape always closes any open dropdown/backdrop, regardless of how it was
@@ -4094,6 +4241,7 @@ document.addEventListener("keydown", (e) => {
4094
4241
  const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
4095
4242
  const savedPreview = localStorage.getItem("mailx-preview") !== "false"; // default true
4096
4243
  const savedSnippet = localStorage.getItem("mailx-snippet") !== "false"; // default true
4244
+ const savedFlagStars = localStorage.getItem("mailx-flag-stars") !== "false"; // default true (show)
4097
4245
  const savedThreaded = localStorage.getItem("mailx-threaded") === "true";
4098
4246
  const savedFlagged = localStorage.getItem("mailx-flagged") === "true";
4099
4247
  const savedFolderCounts = localStorage.getItem("mailx-folder-counts") === "true";
@@ -4103,6 +4251,8 @@ if (optPreview)
4103
4251
  optPreview.checked = savedPreview;
4104
4252
  if (optSnippet)
4105
4253
  optSnippet.checked = savedSnippet;
4254
+ if (optFlagStars)
4255
+ optFlagStars.checked = savedFlagStars;
4106
4256
  if (optThreaded)
4107
4257
  optThreaded.checked = savedThreaded;
4108
4258
  if (optFlagged)
@@ -4115,6 +4265,8 @@ if (!savedPreview)
4115
4265
  document.querySelector(".main-area")?.classList.add("no-preview");
4116
4266
  if (!savedSnippet)
4117
4267
  document.getElementById("message-list")?.classList.add("no-snippets");
4268
+ if (!savedFlagStars)
4269
+ document.getElementById("message-list")?.classList.add("no-empty-stars");
4118
4270
  if (savedThreaded)
4119
4271
  document.getElementById("ml-body")?.classList.add("threaded");
4120
4272
  if (savedFlagged) {
@@ -4349,6 +4501,12 @@ optSnippet?.addEventListener("change", () => {
4349
4501
  }
4350
4502
  localStorage.setItem("mailx-snippet", String(optSnippet.checked));
4351
4503
  });
4504
+ optFlagStars?.addEventListener("change", () => {
4505
+ const list = document.getElementById("message-list");
4506
+ // checked = show empty stars (no class); unchecked = hide them.
4507
+ list?.classList.toggle("no-empty-stars", !optFlagStars.checked);
4508
+ localStorage.setItem("mailx-flag-stars", String(optFlagStars.checked));
4509
+ });
4352
4510
  // ── Search help button (?) ──
4353
4511
  // Toggles a NON-modal panel with the search-syntax reference so it stays
4354
4512
  // visible while the user types into the search box — build a query