@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
@@ -51,6 +51,16 @@ function applyLiveFilter(body) {
51
51
  }
52
52
  }
53
53
  let currentSearchQuery = "";
54
+ /** Empty-list placeholder text. When a search is active, name the query so an
55
+ * empty list reads as "nothing matched THIS search" rather than a bare,
56
+ * alarming "No messages" (Bob 2026-06-25). Covers paths other than the
57
+ * search loader itself — e.g. deleting the last surviving search hit. */
58
+ function emptyListMessage() {
59
+ if (searchMode && currentSearchQuery) {
60
+ return `No messages found by "${escapeHtmlText(currentSearchQuery)}"`;
61
+ }
62
+ return "No messages";
63
+ }
54
64
  // Remember the pre-search view mode so clearSearchMode() can restore it.
55
65
  // Without this, searching from "All Inboxes" loses the unified-mode flag
56
66
  // (loadSearchResults sets unifiedMode=false), and clearing the search
@@ -387,6 +397,166 @@ export function setRowSeen(accountId, uid, yes) {
387
397
  if (row)
388
398
  row.setUnreadClass(!yes);
389
399
  }
400
+ /** Resolve currently-selected rows to MessageRow objects (skips any not in
401
+ * the loaded DOM). */
402
+ function selectedRows() {
403
+ const out = [];
404
+ for (const s of getSelectedMessages()) {
405
+ const row = rowByKey.get(rowKey(s.accountId, s.uid));
406
+ if (row)
407
+ out.push(row);
408
+ }
409
+ return out;
410
+ }
411
+ /** Every row currently shown (visible = matches search + view filters, and
412
+ * loaded — the list lazy-loads). */
413
+ function visibleRowObjs() {
414
+ const body = document.getElementById("ml-body");
415
+ if (!body)
416
+ return [];
417
+ return Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
418
+ .map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid))))
419
+ .filter((r) => !!r);
420
+ }
421
+ /** The set a bulk action targets: the SELECTED rows if any are marked,
422
+ * otherwise ALL shown rows. This is the "act on selected, else all filtered"
423
+ * rule Bob asked for (2026-06-24). */
424
+ function bulkTargets() {
425
+ const sel = selectedRows();
426
+ return sel.length > 0 ? sel : visibleRowObjs();
427
+ }
428
+ /** Flag / unflag a set of rows. Captures the prior flagged state per row and
429
+ * emits `mailx-flagged-batch` so app.ts can register ONE Ctrl+Z undo entry
430
+ * for the whole batch (Bob 2026-06-24: bulk ops need undo for safety). */
431
+ async function bulkSetFlagged(rows, flagged) {
432
+ const undo = [];
433
+ await Promise.all(rows.map(async (row) => {
434
+ // Read current state from the VISIBLE class via row.isFlagged — NOT
435
+ // flaggedOf(row.msg). The two can drift, and a stale `msg.flags` made
436
+ // Unflag skip flagged rows as "already unflagged" (Bob 2026-06-24
437
+ // "unflag is not removing the flag"). The class is the source of truth
438
+ // the single-star toggle already trusts.
439
+ const prev = row.isFlagged;
440
+ if (prev === flagged)
441
+ return; // already in the target state
442
+ const prevFlags = [...(row.msg.flags || [])];
443
+ row.isFlagged = flagged; // setter: updates class + msg.flags
444
+ try {
445
+ await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
446
+ state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
447
+ undo.push({ accountId: row.accountId, uid: row.msg.uid, prevFlagged: prev });
448
+ }
449
+ catch {
450
+ row.msg.flags = prevFlags; // revert on server failure
451
+ row.setFlaggedClass(prev);
452
+ }
453
+ }));
454
+ if (undo.length)
455
+ document.dispatchEvent(new CustomEvent("mailx-flagged-batch", { detail: undo }));
456
+ }
457
+ /** Mark a set of rows read / unread. Low-risk + trivially reversible, so no
458
+ * undo entry (unlike flag, move, delete). State read from the class too. */
459
+ async function bulkSetSeen(rows, seen) {
460
+ await Promise.all(rows.map(async (row) => {
461
+ const prev = row.isSeen;
462
+ if (prev === seen)
463
+ return;
464
+ const prevFlags = [...(row.msg.flags || [])];
465
+ row.isSeen = seen; // setter: updates class + msg.flags
466
+ try {
467
+ await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
468
+ state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
469
+ }
470
+ catch {
471
+ row.msg.flags = prevFlags;
472
+ row.setUnreadClass(!prev);
473
+ }
474
+ }));
475
+ }
476
+ /** Move every selected message (of the first selected row's account) to a
477
+ * picked folder. Local-first: optimistic remove + fire-and-forget IPC, same
478
+ * as the row context menu's "Move to folder…". */
479
+ async function bulkMoveSelection() {
480
+ const targets = bulkTargets();
481
+ if (targets.length === 0)
482
+ return;
483
+ const accountId = targets[0].accountId;
484
+ const same = targets.filter(r => r.accountId === accountId);
485
+ const pick = await pickFolder(accountId, { excludeFolderIds: [same[0].msg.folderId] });
486
+ if (!pick)
487
+ return;
488
+ const uids = same.map(r => r.msg.uid);
489
+ removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
490
+ const statusSync = document.getElementById("status-sync");
491
+ if (statusSync)
492
+ statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? "s" : ""} to ${pick.folderName}…`;
493
+ apiMoveMessages(accountId, uids, pick.folderId)
494
+ .then(() => { if (statusSync)
495
+ statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })
496
+ .catch((err) => { if (statusSync)
497
+ statusSync.textContent = `Move sync issue: ${err?.message || err}`; });
498
+ exitMultiSelect();
499
+ }
500
+ /** Toolbar "Filtered" menu — home for bulk operations. Two concepts, kept
501
+ * separate (Bob 2026-06-24): SHOWN = rows the current folder/search/filters
502
+ * make visible (lazy-loaded, so it's what's loaded now); SELECTED = rows
503
+ * you've clicked. Actions auto-target: if anything is SELECTED they act on
504
+ * that, otherwise on ALL SHOWN — the header says which is in effect. Delete
505
+ * and Mark-as-spam are omitted: the toolbar's own buttons already do those on
506
+ * the selection. Flag/unflag and move are Ctrl+Z-undoable. */
507
+ export async function showSelectionMenu(x, y) {
508
+ const { showContextMenu } = await import("./context-menu.js");
509
+ const body = document.getElementById("ml-body");
510
+ const visibleRows = body
511
+ ? Array.from(body.querySelectorAll(".ml-row:not(.filter-hidden)"))
512
+ : [];
513
+ const enterMulti = () => body?.classList.add("multi-select-on");
514
+ const shownCount = visibleRows.length;
515
+ const selCount = getSelectedMessages().length;
516
+ const hasShown = shownCount > 0;
517
+ // Auto-target: selected if any, else all shown. The action labels carry
518
+ // the effective target count so there's no ambiguity about what runs.
519
+ const targetN = selCount > 0 ? selCount : shownCount;
520
+ const onWhat = selCount > 0 ? `selected (${selCount})` : `all shown (${shownCount})`;
521
+ const canAct = targetN > 0;
522
+ const items = [
523
+ { label: `Acting on ${onWhat}`, action: () => { }, disabled: true },
524
+ { label: "", action: () => { }, separator: true },
525
+ {
526
+ label: `Select all shown (${shownCount})`,
527
+ action: () => { if (!body)
528
+ return; enterMulti(); for (const r of visibleRows)
529
+ r.classList.add("selected"); lastClickedRow = visibleRows[visibleRows.length - 1] || null; updateBulkBar(); },
530
+ disabled: !hasShown,
531
+ },
532
+ {
533
+ label: "Select flagged ★ among shown",
534
+ action: () => { if (!body)
535
+ return; enterMulti(); for (const r of visibleRows)
536
+ r.classList.toggle("selected", r.classList.contains("flagged")); lastClickedRow = null; updateBulkBar(); },
537
+ disabled: !hasShown,
538
+ },
539
+ {
540
+ label: "Invert selection among shown",
541
+ action: () => { if (!body)
542
+ return; enterMulti(); for (const r of visibleRows)
543
+ r.classList.toggle("selected"); updateBulkBar(); },
544
+ disabled: !hasShown,
545
+ },
546
+ { label: `Deselect all (${selCount})`, action: () => exitMultiSelect(), disabled: selCount === 0 },
547
+ { label: "", action: () => { }, separator: true },
548
+ // Actions — auto-targeted (selected else all shown). No count on the
549
+ // verb itself (a number on "Flag" reads as ambiguous, Bob 2026-06-24);
550
+ // the "Acting on …" header above carries the target count.
551
+ { label: "Flag", action: () => bulkSetFlagged(bulkTargets(), true), disabled: !canAct },
552
+ { label: "Unflag", action: () => bulkSetFlagged(bulkTargets(), false), disabled: !canAct },
553
+ { label: "Mark read", action: () => bulkSetSeen(bulkTargets(), true), disabled: !canAct },
554
+ { label: "Mark unread", action: () => bulkSetSeen(bulkTargets(), false), disabled: !canAct },
555
+ { label: "", action: () => { }, separator: true },
556
+ { label: "Move to folder…", action: () => bulkMoveSelection(), disabled: !canAct },
557
+ ];
558
+ showContextMenu(x, y, items);
559
+ }
390
560
  /** Scroll the focused row into view. Wired to a keyboard shortcut so the
391
561
  * user can recover after scrolling the list away from the preview.
392
562
  *
@@ -815,7 +985,7 @@ export function removeMessagesAndReconcile(uids) {
815
985
  }
816
986
  }
817
987
  if (state.getMessages().length === 0) {
818
- body.innerHTML = `<div class="ml-empty">No messages</div>`;
988
+ body.innerHTML = `<div class="ml-empty">${emptyListMessage()}</div>`;
819
989
  }
820
990
  }
821
991
  if (outcome.focusedWasRemoved) {
@@ -908,10 +1078,17 @@ export async function loadUnifiedInbox(autoSelect = true) {
908
1078
  if (cached) {
909
1079
  totalMessages = cached.total;
910
1080
  state.setMessages(cached.items);
911
- renderMessages(body, "", cached.items, { background: !autoSelect });
1081
+ // See loadMessages: anchor the cache render on background reloads so a
1082
+ // scrolled reader isn't yanked to the top when new mail arrives.
1083
+ const renderCached = () => renderMessages(body, "", cached.items, { background: !autoSelect });
1084
+ if (!autoSelect)
1085
+ withScrollAnchor(body, renderCached);
1086
+ else
1087
+ renderCached();
912
1088
  const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
913
1089
  if (targetUuid) {
914
- body.scrollTop = savedScroll;
1090
+ if (autoSelect)
1091
+ body.scrollTop = savedScroll;
915
1092
  restoreSelection(body, targetUuid);
916
1093
  }
917
1094
  else if (autoSelect) {
@@ -1034,7 +1211,7 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
1034
1211
  totalMessages = matches.length;
1035
1212
  state.setMessages(matches);
1036
1213
  if (matches.length === 0) {
1037
- body.innerHTML = `<div class="ml-empty">No regex matches</div>`;
1214
+ body.innerHTML = `<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"</div>`;
1038
1215
  return;
1039
1216
  }
1040
1217
  body.innerHTML = "";
@@ -1060,7 +1237,7 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
1060
1237
  if (result.items.length === 0) {
1061
1238
  state.setMessages([]);
1062
1239
  body.innerHTML = partialNote +
1063
- `<div class="ml-empty">No results for "${query}"${partial ? " in the folders that responded" : ""}</div>`;
1240
+ `<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"${partial ? " in the folders that responded" : ""}</div>`;
1064
1241
  return;
1065
1242
  }
1066
1243
  state.setMessages(result.items);
@@ -1074,7 +1251,15 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
1074
1251
  body.innerHTML = `<div class="ml-empty">Search error: ${e.message}</div>`;
1075
1252
  }
1076
1253
  }
1077
- export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
1254
+ /** Search-as-find: leave search and reveal a specific message in its own
1255
+ * folder. Loads the folder list, selects the message, and scrolls it into
1256
+ * view. Used when the user closes search while a result is selected — they
1257
+ * "stay at" that message in its real folder instead of bouncing back to the
1258
+ * pre-search view (Bob 2026-06-25). */
1259
+ export function revealMessage(accountId, folderId, uid, specialUse = "") {
1260
+ loadMessages(accountId, folderId, 1, specialUse, true, uid);
1261
+ }
1262
+ export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true, focusUid) {
1078
1263
  const myGen = ++loadGen;
1079
1264
  searchMode = false;
1080
1265
  unifiedMode = false;
@@ -1117,11 +1302,31 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
1117
1302
  if (cached) {
1118
1303
  totalMessages = cached.total;
1119
1304
  state.setMessages(cached.items);
1120
- renderMessages(body, accountId, cached.items, { background: !autoSelect });
1305
+ // Background reload (new mail / sync): a bare renderMessages rebuilds
1306
+ // innerHTML and resets scrollTop to 0, which then makes the follow-up
1307
+ // network anchor think the user is at the top and reveal new mail there
1308
+ // — yanking a scrolled-down reader to the top (Bob 2026-06-24 "the list
1309
+ // jumps when a new message is added ... must not move while I'm
1310
+ // viewing it"). Anchor the cache render too so the visible rows stay put.
1311
+ const renderCached = () => renderMessages(body, accountId, cached.items, { background: !autoSelect });
1312
+ if (!autoSelect)
1313
+ withScrollAnchor(body, renderCached);
1314
+ else
1315
+ renderCached();
1121
1316
  const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
1122
- if (targetUuid) {
1317
+ if (focusUid != null) {
1318
+ // Search-as-find: jump to the specific message the user selected.
1123
1319
  requestAnimationFrame(() => {
1124
- body.scrollTop = savedScroll;
1320
+ if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect)
1321
+ selectFirst(body);
1322
+ });
1323
+ }
1324
+ else if (targetUuid) {
1325
+ requestAnimationFrame(() => {
1326
+ // Folder switch restores saved pixels; a background reload was
1327
+ // already placed by the anchor — don't override it.
1328
+ if (autoSelect)
1329
+ body.scrollTop = savedScroll;
1125
1330
  restoreSelection(body, targetUuid);
1126
1331
  });
1127
1332
  }
@@ -1173,7 +1378,16 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
1173
1378
  }
1174
1379
  // Prefer saved position; otherwise default by autoSelect.
1175
1380
  const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
1176
- if (targetUuid) {
1381
+ if (focusUid != null) {
1382
+ // Search-as-find: select the message the user picked from results.
1383
+ requestAnimationFrame(() => {
1384
+ if (myGen !== loadGen)
1385
+ return;
1386
+ if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect)
1387
+ selectFirst(body);
1388
+ });
1389
+ }
1390
+ else if (targetUuid) {
1177
1391
  requestAnimationFrame(() => {
1178
1392
  if (myGen !== loadGen)
1179
1393
  return;
@@ -1204,6 +1418,15 @@ async function loadMoreMessages() {
1204
1418
  return;
1205
1419
  loading = true;
1206
1420
  currentPage++;
1421
+ // Diagnostics for the "scroll near the bottom does nothing" report
1422
+ // (Bob 2026-06-22: position-dependent, "go up a tad and it works"). The
1423
+ // suspect is a loadMore that doesn't grow scrollHeight — duplicate/empty
1424
+ // page, end-of-list overshoot — leaving the user pinned. Capture
1425
+ // before/after geometry + how many rows the page actually added so the
1426
+ // log shows whether the list grew, stalled, or the page came back empty.
1427
+ const t0 = Date.now();
1428
+ const heightBefore = body.scrollHeight;
1429
+ const rowsBefore = body.querySelectorAll(".ml-row").length;
1207
1430
  try {
1208
1431
  const flaggedOnly = body.classList.contains("flagged-only");
1209
1432
  const result = searchMode
@@ -1215,6 +1438,11 @@ async function loadMoreMessages() {
1215
1438
  const current = state.getMessages();
1216
1439
  state.setMessages([...current, ...result.items]);
1217
1440
  appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
1441
+ const rowsAfter = body.querySelectorAll(".ml-row").length;
1442
+ const heightAfter = body.scrollHeight;
1443
+ const elapsed = Date.now() - t0;
1444
+ const grew = heightAfter > heightBefore;
1445
+ console.log(` [ml-loadmore] page=${currentPage} got=${result.items?.length ?? 0} rows ${rowsBefore}->${rowsAfter} height ${heightBefore}->${heightAfter} grew=${grew} total=${totalMessages} ${elapsed}ms${grew ? "" : " ⚠ NO GROWTH — scroll will appear stuck"}`);
1218
1446
  }
1219
1447
  catch (e) {
1220
1448
  console.error(`Load more error: ${e.message}`);
@@ -1225,12 +1453,30 @@ async function loadMoreMessages() {
1225
1453
  }
1226
1454
  /** Replace body contents with rendered rows */
1227
1455
  function renderMessages(body, accountId, items, opts = {}) {
1456
+ // MULTI-SELECT SURVIVAL: replaceChildren below builds fresh rows with no
1457
+ // `.selected`, so a sync/new-mail rebuild would collapse an active
1458
+ // multi-selection down to the single focused row restoreSelection re-binds
1459
+ // (Bob 2026-06-22: "selected multiple NMLS spam, suddenly seeing only one —
1460
+ // a new message arrived and confused you"). The `multi-select-on` class
1461
+ // lives on the body and survives the rebuild, so when it's set we snapshot
1462
+ // the selected rows' stable uuids and re-apply `.selected` to whichever
1463
+ // survive the new render. Keyed on uuid, so a folder switch (different
1464
+ // uuids) matches nothing and correctly drops the stale highlight.
1465
+ const preserveSel = body.classList.contains("multi-select-on")
1466
+ ? new Set(Array.from(body.querySelectorAll(".ml-row.selected"))
1467
+ .map(r => r.dataset.uuid || "").filter(Boolean))
1468
+ : null;
1228
1469
  const fragment = document.createDocumentFragment();
1229
1470
  const tempDiv = document.createElement("div");
1230
1471
  appendMessages(tempDiv, accountId, items);
1231
1472
  while (tempDiv.firstChild)
1232
1473
  fragment.appendChild(tempDiv.firstChild);
1233
1474
  body.replaceChildren(fragment);
1475
+ if (preserveSel && preserveSel.size > 0) {
1476
+ for (const uuid of preserveSel) {
1477
+ body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
1478
+ }
1479
+ }
1234
1480
  // A filter (flagged-only / priority-only) that hides EVERY row makes the
1235
1481
  // list look broken — identical to an empty/failed load. Bob 2026-05-27
1236
1482
  // lost an afternoon to this: the flagged-only filter was stuck on (it
@@ -1674,6 +1920,19 @@ class MessageRow {
1674
1920
  }
1675
1921
  onFlagClick(e) {
1676
1922
  e.stopPropagation();
1923
+ // Multi-select: clicking the ★ on one of several highlighted rows acts
1924
+ // on the WHOLE selection, not just this row — same as the context-menu
1925
+ // Flag/Unflag (Bob 2026-06-26: "clear the * only clears one not the
1926
+ // selection"). Target state is the toggle of the clicked row, so a
1927
+ // flagged row clears the lot and an unflagged row flags the lot.
1928
+ const body = this.el.parentElement;
1929
+ if (body?.classList.contains("multi-select-on") && this.el.classList.contains("selected")) {
1930
+ const sel = selectedRows();
1931
+ if (sel.length > 1) {
1932
+ void bulkSetFlagged(sel, !this.el.classList.contains("flagged"));
1933
+ return;
1934
+ }
1935
+ }
1677
1936
  // Local-first: flip the visual class + in-memory flag array NOW so the
1678
1937
  // star toggles under the cursor with zero latency, then mirror to the
1679
1938
  // server in the background. The earlier `await updateFlags(...)`
@@ -1854,38 +2113,23 @@ class MessageRow {
1854
2113
  const accountId = this.accountId;
1855
2114
  const msg = this.msg;
1856
2115
  const self = this;
2116
+ // Count of selected rows — when >1, the read/flag actions apply to the
2117
+ // whole selection and the label says so (e.g. "Unflag (5)"). The row
2118
+ // menu's fallback (nothing selected) is the clicked row, NOT all shown.
2119
+ const selCount = getSelectedMessages().length;
2120
+ const countSfx = selCount > 1 ? ` (${selCount})` : "";
2121
+ const rowTargets = () => { const s = selectedRows(); return s.length > 0 ? s : [self]; };
1857
2122
  const items = [
1858
2123
  {
1859
- label: isSeen ? "Mark unread" : "Mark read",
1860
- action: async () => {
1861
- // Target state = inverse of what the menu showed,
1862
- // matching what the user clicked. Visual-state-based
1863
- // labels stay coherent with the auto-mark-as-read
1864
- // debounce; the new flag array is computed via the
1865
- // typed setter and sent to the server.
1866
- const probe = { flags: [...(msg.flags || [])] };
1867
- setSeen(probe, !isSeen);
1868
- try {
1869
- await updateFlags(accountId, msg.uid, probe.flags);
1870
- msg.flags = probe.flags;
1871
- state.updateMessageFlags(accountId, msg.uid, probe.flags);
1872
- self.setUnreadClass(isSeen);
1873
- }
1874
- catch { /* ignore */ }
1875
- },
2124
+ // Target state = inverse of what the menu showed, matching what
2125
+ // the user clicked. Visual-state-based labels stay coherent with
2126
+ // the auto-mark-as-read debounce. Applies to the whole selection.
2127
+ label: (isSeen ? "Mark unread" : "Mark read") + countSfx,
2128
+ action: () => bulkSetSeen(rowTargets(), !isSeen),
1876
2129
  },
1877
2130
  {
1878
- label: isFlagged ? "Unflag" : "Flag",
1879
- action: async () => {
1880
- const probe = { flags: [...(msg.flags || [])] };
1881
- setFlagged(probe, !isFlagged);
1882
- try {
1883
- await updateFlags(accountId, msg.uid, probe.flags);
1884
- msg.flags = probe.flags;
1885
- self.setFlaggedClass(!isFlagged);
1886
- }
1887
- catch { /* ignore */ }
1888
- },
2131
+ label: (isFlagged ? "Unflag" : "Flag") + countSfx,
2132
+ action: () => bulkSetFlagged(rowTargets(), !isFlagged),
1889
2133
  },
1890
2134
  { label: "", action: () => { }, separator: true },
1891
2135
  // Drafts get an explicit "Edit draft" entry as the primary action —