@bobfrankston/rmfmail 1.2.87 → 1.2.96

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 (82) hide show
  1. package/client/android-bootstrap.bundle.js +65 -15
  2. package/client/android-bootstrap.bundle.js.map +2 -2
  3. package/client/app.bundle.js +378 -286
  4. package/client/app.bundle.js.map +4 -4
  5. package/client/app.js +23 -1
  6. package/client/app.js.map +1 -1
  7. package/client/app.ts +20 -1
  8. package/client/components/address-book.js +5 -1
  9. package/client/components/address-book.js.map +1 -1
  10. package/client/components/address-book.ts +5 -1
  11. package/client/components/folder-tree.js +31 -6
  12. package/client/components/folder-tree.js.map +1 -1
  13. package/client/components/folder-tree.ts +29 -5
  14. package/client/components/message-list.js +164 -54
  15. package/client/components/message-list.js.map +1 -1
  16. package/client/components/message-list.ts +159 -51
  17. package/client/components/message-viewer.js +7 -0
  18. package/client/components/message-viewer.js.map +1 -1
  19. package/client/components/message-viewer.ts +7 -0
  20. package/client/compose/compose.bundle.js +4 -4
  21. package/client/compose/compose.bundle.js.map +2 -2
  22. package/client/index.html +4 -0
  23. package/client/lib/api-client.js +4 -4
  24. package/client/lib/api-client.js.map +1 -1
  25. package/client/lib/api-client.ts +4 -4
  26. package/client/lib/mailxapi.js +4 -4
  27. package/client/styles/components.css +20 -0
  28. package/package.json +9 -9
  29. package/packages/mailx-imap/index.d.ts +17 -0
  30. package/packages/mailx-imap/index.d.ts.map +1 -1
  31. package/packages/mailx-imap/index.js +86 -4
  32. package/packages/mailx-imap/index.js.map +1 -1
  33. package/packages/mailx-imap/index.ts +85 -4
  34. package/packages/mailx-imap/package-lock.json +2 -2
  35. package/packages/mailx-imap/package.json +1 -1
  36. package/packages/mailx-service/index.d.ts +2 -2
  37. package/packages/mailx-service/index.d.ts.map +1 -1
  38. package/packages/mailx-service/index.js +6 -4
  39. package/packages/mailx-service/index.js.map +1 -1
  40. package/packages/mailx-service/index.ts +7 -5
  41. package/packages/mailx-service/jsonrpc.js +2 -2
  42. package/packages/mailx-service/jsonrpc.js.map +1 -1
  43. package/packages/mailx-service/jsonrpc.ts +2 -2
  44. package/packages/mailx-service/sync-queue.d.ts +3 -0
  45. package/packages/mailx-service/sync-queue.d.ts.map +1 -1
  46. package/packages/mailx-service/sync-queue.js +30 -11
  47. package/packages/mailx-service/sync-queue.js.map +1 -1
  48. package/packages/mailx-service/sync-queue.ts +34 -9
  49. package/packages/mailx-service/sync-worker.js +51 -0
  50. package/packages/mailx-service/sync-worker.js.map +1 -1
  51. package/packages/mailx-service/sync-worker.ts +45 -0
  52. package/packages/mailx-settings/package.json +1 -1
  53. package/packages/mailx-store/db.d.ts +10 -1
  54. package/packages/mailx-store/db.d.ts.map +1 -1
  55. package/packages/mailx-store/db.js +68 -7
  56. package/packages/mailx-store/db.js.map +1 -1
  57. package/packages/mailx-store/db.ts +70 -8
  58. package/packages/mailx-store/package.json +1 -1
  59. package/packages/mailx-store/store.d.ts +1 -1
  60. package/packages/mailx-store/store.d.ts.map +1 -1
  61. package/packages/mailx-store/store.js +2 -2
  62. package/packages/mailx-store/store.js.map +1 -1
  63. package/packages/mailx-store/store.ts +2 -2
  64. package/packages/mailx-store-web/imap-web-provider.d.ts +7 -0
  65. package/packages/mailx-store-web/imap-web-provider.d.ts.map +1 -1
  66. package/packages/mailx-store-web/imap-web-provider.js +12 -0
  67. package/packages/mailx-store-web/imap-web-provider.js.map +1 -1
  68. package/packages/mailx-store-web/imap-web-provider.ts +15 -0
  69. package/packages/mailx-store-web/package.json +1 -1
  70. package/packages/mailx-store-web/sync-manager.d.ts.map +1 -1
  71. package/packages/mailx-store-web/sync-manager.js +12 -0
  72. package/packages/mailx-store-web/sync-manager.js.map +1 -1
  73. package/packages/mailx-store-web/sync-manager.ts +12 -0
  74. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  75. package/packages/mailx-store-web/web-service.js +53 -19
  76. package/packages/mailx-store-web/web-service.js.map +1 -1
  77. package/packages/mailx-store-web/web-service.ts +60 -20
  78. package/packages/mailx-types/index.d.ts +5 -1
  79. package/packages/mailx-types/index.d.ts.map +1 -1
  80. package/packages/mailx-types/index.js.map +1 -1
  81. package/packages/mailx-types/index.ts +5 -1
  82. package/packages/mailx-types/package.json +1 -1
@@ -96,6 +96,34 @@ if (typeof document !== "undefined") {
96
96
  let currentSort = "date";
97
97
  let currentSortDir: "asc" | "desc" = "desc";
98
98
 
99
+ // Date basis for the "date" sort + the date column: "sent" (Date: header,
100
+ // default — identical mail clusters, matches other clients) or "received"
101
+ // (per-account arrival time). Persisted so the View-menu toggle survives
102
+ // reloads. Bob 2026-07-01.
103
+ let dateBasis: "sent" | "received" =
104
+ (localStorage.getItem("mailx-date-basis") as "sent" | "received") || "sent";
105
+
106
+ /** The epoch-ms to DISPLAY for a row, honoring the current basis. sentDate is
107
+ * always populated by the store (backfilled to date), but guard anyway. */
108
+ function effectiveDate(msg: any): number {
109
+ return dateBasis === "received" ? msg.date : (msg.sentDate ?? msg.date);
110
+ }
111
+
112
+ /** Current date basis — read by app.ts to check the View-menu radio state. */
113
+ export function getDateBasis(): "sent" | "received" { return dateBasis; }
114
+
115
+ /** Switch the date basis, persist it, and reload the current view so the new
116
+ * sort + column take effect. Called by the View menu. */
117
+ export function setDateBasis(basis: "sent" | "received"): void {
118
+ if (basis === dateBasis) return;
119
+ dateBasis = basis;
120
+ try { localStorage.setItem("mailx-date-basis", basis); } catch { /* private mode */ }
121
+ // Cached snapshots were ordered by the old basis — clear so the reload
122
+ // re-sorts from the DB rather than repainting the stale order.
123
+ listCache.clear();
124
+ reloadCurrentFolder();
125
+ }
126
+
99
127
  /** Generation counter — incremented on every load* call (loadMessages,
100
128
  * loadUnifiedInbox, loadSearchResults). Each load captures the current
101
129
  * value at the top, then checks before rendering — if the captured gen
@@ -194,6 +222,7 @@ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
194
222
  // must not go off screen"). The old `scrollTop < 4 → no anchor` shortcut
195
223
  // was exactly the bug: at the top, new rows shoved the read letter off the
196
224
  // bottom.
225
+ let anchoredOnSelected = false;
197
226
  const selected = body.querySelector<HTMLElement>(".ml-row.selected");
198
227
  if (selected) {
199
228
  const vt = selected.offsetTop - scrollTop;
@@ -201,6 +230,7 @@ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
201
230
  if (vt > -selected.offsetHeight && vt < viewportH) {
202
231
  anchorUuid = selected.dataset.uuid || "";
203
232
  anchorOffsetWithinViewport = vt;
233
+ anchoredOnSelected = true;
204
234
  }
205
235
  }
206
236
 
@@ -232,7 +262,14 @@ function withScrollAnchor(body: HTMLElement, doRender: () => void): void {
232
262
  // bottom do we fall back to pinning it visible. This satisfies both
233
263
  // "show new mail at the top" and the 2026-06-04 rule "a letter in view
234
264
  // must stay in view (it may scroll down but must not go off screen)".
235
- if (wasAtTop && after.offsetTop < viewportH - after.offsetHeight) {
265
+ //
266
+ // EXCEPTION (Bob 2026-06-30: "I was looking at a message and suddenly
267
+ // my position changed and I was looking at a new message"): when the
268
+ // anchor IS the open/selected message, the user is actively reading —
269
+ // never reposition them. Pin the selected row exactly where it sat.
270
+ // The scroll-to-0 reveal only applies when nothing is open and the user
271
+ // is merely parked at the top of the list browsing subjects.
272
+ if (wasAtTop && !anchoredOnSelected && after.offsetTop < viewportH - after.offsetHeight) {
236
273
  body.scrollTop = 0;
237
274
  } else {
238
275
  body.scrollTop = after.offsetTop - anchorOffsetWithinViewport;
@@ -260,8 +297,20 @@ function rememberPosition(): void {
260
297
  persistPositions();
261
298
  }
262
299
  /** Choose the row to focus when re-entering a view with saved position.
263
- * Returns the uid to focus, or null to fall back to selectFirst. */
264
- function pickRestoreUid(items: any[], saved: ViewPosition): string | null {
300
+ * Returns the uid to focus, or null to fall back to selectFirst.
301
+ *
302
+ * `allowNeighbor` gates the deleted-message fallback. On a FOREGROUND
303
+ * re-entry (user navigating INTO a view) the saved message being absent
304
+ * means it was deleted, so we hand focus to the nearest survivor. On a
305
+ * BACKGROUND reload (sync/new-mail rebuild of the current view) absent
306
+ * does NOT mean deleted — the message the user is READING has merely been
307
+ * pushed out of the loaded top-50 page by new arrivals. Switching to a
308
+ * neighbor there yanks the viewer onto the new arrival while the list
309
+ * stays put (Bob 2026-07-01: "I was looking at a letter and suddenly found
310
+ * myself looking at a newly arrived message; the summary did not scroll").
311
+ * So background callers pass allowNeighbor=false → return null → keep the
312
+ * current viewer untouched. */
313
+ function pickRestoreUid(items: any[], saved: ViewPosition, allowNeighbor = true): string | null {
265
314
  if (!items.length) return null;
266
315
  // Exact restore by stable uuid — globally unique, so this can never
267
316
  // rebind the viewer to a same-uid-number message in another folder
@@ -270,6 +319,7 @@ function pickRestoreUid(items: any[], saved: ViewPosition): string | null {
270
319
  const exact = items.find(m => m.uuid && m.uuid === saved.uuid);
271
320
  if (exact?.uuid) return exact.uuid;
272
321
  }
322
+ if (!allowNeighbor) return null;
273
323
  // Saved message is gone (deleted). Pick the next-older entry by uid —
274
324
  // uid is roughly monotonic with arrival on IMAP/Gmail — and return ITS
275
325
  // uuid so the restore stays uuid-keyed end to end.
@@ -729,10 +779,30 @@ export function exitMultiSelect(): void {
729
779
  }
730
780
 
731
781
  /** Bulk-actions bar retired 2026-04-24 — trash + spam live on the main
732
- * toolbar now and every other bulk op (mark-read, flag, move) is one
733
- * right-click away. Kept as a no-op stub so existing call sites
734
- * (avatar tap, row click, long-press) don't need to be touched. */
735
- function updateBulkBar(): void { /* bar removed; nothing to render */ }
782
+ * toolbar. This now renders just the selection COUNT (Bob 2026-07-01:
783
+ * "multiselect should show the count somewhere") as a floating pill at the
784
+ * bottom of the list panel, shown only while a multi-selection of ≥2 is
785
+ * active. Every selection-changing site already calls this. */
786
+ function updateBulkBar(): void {
787
+ const panel = document.getElementById("message-list");
788
+ if (!panel) return;
789
+ const body = document.getElementById("ml-body");
790
+ const count = body?.classList.contains("multi-select-on")
791
+ ? body.querySelectorAll(".ml-row.selected").length : 0;
792
+ let pill = document.getElementById("ml-select-count");
793
+ if (count >= 2) {
794
+ if (!pill) {
795
+ pill = document.createElement("div");
796
+ pill.id = "ml-select-count";
797
+ pill.className = "ml-select-count";
798
+ panel.appendChild(pill);
799
+ }
800
+ pill.textContent = `${count} selected`;
801
+ pill.style.display = "";
802
+ } else if (pill) {
803
+ pill.style.display = "none";
804
+ }
805
+ }
736
806
 
737
807
  /** Select every shown (not filter-hidden) row AND enter multi-select mode.
738
808
  * Shared by the selection menu's "Select all shown" and the Ctrl+A shortcut.
@@ -855,34 +925,7 @@ export function initMessageList(handler: MessageSelectHandler): void {
855
925
  }
856
926
  }, { passive: true });
857
927
 
858
- body.addEventListener("scroll", () => {
859
- if (loading) return;
860
- // Prefetch the next page well BEFORE the bottom (was 200px). The next
861
- // 50 rows are a DB query + render; triggering it only 200px out meant
862
- // the user scrolled INTO the not-yet-loaded gap and saw a pause (Bob
863
- // 2026-06-18 "scroll the summary, there is a delay"). 1000px ≈ a couple
864
- // screens of lead time so the page is usually ready by the time it's
865
- // scrolled to. `loading` guard still prevents overlapping fetches.
866
- const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 1000;
867
- if (nearBottom) {
868
- if (currentPage * 50 < totalMessages) {
869
- loadMoreMessages();
870
- } else {
871
- // Diagnostic: at bottom but not loading more. Log why
872
- // so the user can see whether totalMessages is wrong,
873
- // a filter is on, or we've truly reached the end.
874
- // Only log once per "stuck at bottom" episode.
875
- if (!(body as any)._mlScrollEndLogged) {
876
- (body as any)._mlScrollEndLogged = true;
877
- const rows = body.querySelectorAll(".ml-row").length;
878
- console.log(` [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
879
- }
880
- }
881
- } else {
882
- // Reset the once-flag so next time we hit bottom we log again.
883
- (body as any)._mlScrollEndLogged = false;
884
- }
885
- });
928
+ body.addEventListener("scroll", () => { maybeLoadMore(body); });
886
929
  }
887
930
 
888
931
  // Viewer signals "this row is gone server-side" via mailx-remove-stale.
@@ -1117,7 +1160,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
1117
1160
  const renderCached = () => renderMessages(body, "", cached.items, { background: !autoSelect });
1118
1161
  if (!autoSelect) withScrollAnchor(body, renderCached);
1119
1162
  else renderCached();
1120
- const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
1163
+ const targetUuid = remembered ? pickRestoreUid(cached.items, remembered, autoSelect) : null;
1121
1164
  if (targetUuid) {
1122
1165
  if (autoSelect) body.scrollTop = savedScroll;
1123
1166
  restoreSelection(body, targetUuid);
@@ -1129,7 +1172,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
1129
1172
  }
1130
1173
 
1131
1174
  try {
1132
- const result = await apiGetUnifiedInbox(1, 50, flaggedOnly);
1175
+ const result = await apiGetUnifiedInbox(1, 50, flaggedOnly, dateBasis);
1133
1176
  if (myGen !== loadGen) return; // user moved on; drop stale response
1134
1177
  totalMessages = result.total;
1135
1178
  listCache.set(unifiedCacheKey, { items: result.items, total: result.total, timestamp: Date.now() });
@@ -1154,7 +1197,7 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
1154
1197
  // appear above as usual.
1155
1198
  withScrollAnchor(body, () => renderMessages(body, "", result.items, { background: !autoSelect }));
1156
1199
 
1157
- const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
1200
+ const targetUuid = remembered ? pickRestoreUid(result.items, remembered, autoSelect) : null;
1158
1201
  if (targetUuid) {
1159
1202
  // Only restore the saved scrollTop on a first paint (cache miss).
1160
1203
  // After a sync-driven re-render, withScrollAnchor has already
@@ -1225,8 +1268,8 @@ export async function loadSearchResults(query: string, scope = "all", accountId
1225
1268
  try { regex = new RegExp(pattern, "i"); } catch { body.innerHTML = `<div class="ml-empty">Invalid regex</div>`; return; }
1226
1269
 
1227
1270
  const source = scope === "current" && accountId
1228
- ? await apiGetMessages(accountId, folderId, 1, 10000)
1229
- : await apiGetUnifiedInbox(1, 10000);
1271
+ ? await apiGetMessages(accountId, folderId, 1, 10000, false, currentSort, currentSortDir, dateBasis)
1272
+ : await apiGetUnifiedInbox(1, 10000, false, dateBasis);
1230
1273
  if (myGen !== loadGen) return;
1231
1274
  const matches = source.items.filter((m: any) =>
1232
1275
  regex.test(m.subject || "") || regex.test(m.from?.name || "") || regex.test(m.from?.address || "") || regex.test(m.preview || "")
@@ -1335,7 +1378,7 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
1335
1378
  const renderCached = () => renderMessages(body, accountId, cached.items, { background: !autoSelect });
1336
1379
  if (!autoSelect) withScrollAnchor(body, renderCached);
1337
1380
  else renderCached();
1338
- const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
1381
+ const targetUuid = remembered ? pickRestoreUid(cached.items, remembered, autoSelect) : null;
1339
1382
  if (focusUid != null) {
1340
1383
  // Search-as-find: jump to the specific message the user selected.
1341
1384
  requestAnimationFrame(() => {
@@ -1356,7 +1399,7 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
1356
1399
  }
1357
1400
 
1358
1401
  try {
1359
- const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
1402
+ const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir, dateBasis);
1360
1403
  // Stale-response guard: a newer load* fired while we were
1361
1404
  // awaiting; the new view already painted. Drop this result
1362
1405
  // silently rather than overwriting.
@@ -1397,7 +1440,7 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
1397
1440
  }
1398
1441
 
1399
1442
  // Prefer saved position; otherwise default by autoSelect.
1400
- const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
1443
+ const targetUuid = remembered ? pickRestoreUid(result.items, remembered, autoSelect) : null;
1401
1444
  if (focusUid != null) {
1402
1445
  // Search-as-find: select the message the user picked from results.
1403
1446
  requestAnimationFrame(() => {
@@ -1424,6 +1467,43 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
1424
1467
  }
1425
1468
  }
1426
1469
 
1470
+ /** Decide whether the next page should load, and load it. Centralized so the
1471
+ * scroll handler and the post-load re-check share one code path.
1472
+ *
1473
+ * Why a re-check exists (Bob 2026-06-30: "scroll down near the bottom does
1474
+ * nothing till an upward scroll then I can scroll down again"): the browser
1475
+ * fires `scroll` events only while scrollTop actually changes. When the user
1476
+ * reaches the absolute bottom of the loaded rows, scrollTop is pinned at max,
1477
+ * so NO further downward scroll events fire. If the event that crossed
1478
+ * nearBottom arrived while a previous load was still in flight (the old
1479
+ * `if (loading) return` dropped it), the next page was never requested — the
1480
+ * list looked frozen until an upward scroll re-armed the handler. Now every
1481
+ * finished load re-evaluates geometry and chains the next page if the user is
1482
+ * still sitting near the bottom, so reaching the end is self-driving. */
1483
+ function maybeLoadMore(body: HTMLElement): void {
1484
+ if (loading) return;
1485
+ const nearBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 1000;
1486
+ if (!nearBottom) {
1487
+ // Reset the once-flag so next time we hit bottom we log again.
1488
+ (body as any)._mlScrollEndLogged = false;
1489
+ return;
1490
+ }
1491
+ if (currentPage * 50 < totalMessages) {
1492
+ // Prefetch the next page well BEFORE the bottom (1000px lead). The next
1493
+ // 50 rows are a DB query + render; triggering only 200px out meant the
1494
+ // user scrolled INTO the not-yet-loaded gap and saw a pause (Bob
1495
+ // 2026-06-18 "scroll the summary, there is a delay").
1496
+ loadMoreMessages();
1497
+ } else if (!(body as any)._mlScrollEndLogged) {
1498
+ // Diagnostic: at bottom but not loading more. Log why so we can see
1499
+ // whether totalMessages is wrong, a filter is on, or we've truly
1500
+ // reached the end. Once per "stuck at bottom" episode.
1501
+ (body as any)._mlScrollEndLogged = true;
1502
+ const rows = body.querySelectorAll(".ml-row").length;
1503
+ console.log(` [ml-scroll] reached bottom — currentPage=${currentPage} pageSize=50 loadedRows=${rows} totalMessages=${totalMessages} searchMode=${searchMode} unifiedMode=${unifiedMode} flaggedOnly=${body.classList.contains("flagged-only")}`);
1504
+ }
1505
+ }
1506
+
1427
1507
  async function loadMoreMessages(): Promise<void> {
1428
1508
  const body = document.getElementById("ml-body");
1429
1509
  if (!body) return;
@@ -1445,8 +1525,8 @@ async function loadMoreMessages(): Promise<void> {
1445
1525
  const result = searchMode
1446
1526
  ? await searchMessages(currentSearchQuery, currentPage)
1447
1527
  : unifiedMode
1448
- ? await apiGetUnifiedInbox(currentPage, 50, flaggedOnly)
1449
- : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly);
1528
+ ? await apiGetUnifiedInbox(currentPage, 50, flaggedOnly, dateBasis)
1529
+ : await apiGetMessages(currentAccountId, currentFolderId, currentPage, 50, flaggedOnly, currentSort, currentSortDir, dateBasis);
1450
1530
  // Append to state
1451
1531
  const current = state.getMessages();
1452
1532
  state.setMessages([...current, ...result.items]);
@@ -1461,6 +1541,13 @@ async function loadMoreMessages(): Promise<void> {
1461
1541
  } finally {
1462
1542
  loading = false;
1463
1543
  }
1544
+ // Re-evaluate: if the user is STILL near the bottom (they kept their finger
1545
+ // pinned at the end, or the page that loaded was shorter than the viewport),
1546
+ // chain the next page. Geometry has settled after the synchronous append
1547
+ // above, so this reads true heights. Terminates when nearBottom goes false
1548
+ // or currentPage*50 >= totalMessages. requestAnimationFrame defers one frame
1549
+ // so layout flushes and we don't recurse on stale heights.
1550
+ requestAnimationFrame(() => maybeLoadMore(body));
1464
1551
  }
1465
1552
 
1466
1553
  /** Replace body contents with rendered rows */
@@ -1487,6 +1574,8 @@ function renderMessages(body: HTMLElement, accountId: string, items: any[], opts
1487
1574
  for (const uuid of preserveSel) {
1488
1575
  body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
1489
1576
  }
1577
+ // Keep the "N selected" pill accurate after a background rebuild.
1578
+ updateBulkBar();
1490
1579
  }
1491
1580
  // A filter (flagged-only / priority-only) that hides EVERY row makes the
1492
1581
  // list look broken — identical to an empty/failed load. Bob 2026-05-27
@@ -1552,10 +1641,16 @@ function restoreSelection(body: HTMLElement, savedUuid: string | null | undefine
1552
1641
  const accountId = row.dataset.accountId;
1553
1642
  const uid = Number(row.dataset.uid);
1554
1643
  if (accountId && Number.isFinite(uid)) {
1555
- // {scroll:false} programmatic restore after a sync-driven reload.
1556
- // The user might be reading rows ABOVE the focused one; do NOT yank
1557
- // their scroll back to the focused row every time the list rebuilds.
1558
- focusByIdentity(accountId, uid, { scroll: false });
1644
+ // FATE-SHARING SAFETY NET (Bob 2026-07-01: "even if there is an
1645
+ // inappropriate switch it must automatically make it visible in the
1646
+ // summary"). If this restore re-binds the SAME message already in the
1647
+ // viewer the normal case after a sync rebuild — keep the user's
1648
+ // scroll exactly where it is; they may be reading rows above/below and
1649
+ // must not be yanked. But if it targets a DIFFERENT message than the
1650
+ // one currently shown, that IS a real viewer switch: scroll the list to
1651
+ // that row so the summary and the viewer can never silently disagree.
1652
+ const switching = focusedRow?.msg?.uuid !== savedUuid;
1653
+ focusByIdentity(accountId, uid, { scroll: switching });
1559
1654
  }
1560
1655
  }
1561
1656
 
@@ -1580,7 +1675,7 @@ export async function showThreadPopup(pillEl: HTMLElement, headMsg: any): Promis
1580
1675
  from.textContent = msg.from?.name || msg.from?.address || "?";
1581
1676
  const date = document.createElement("span");
1582
1677
  date.className = "ml-thread-popup-date";
1583
- date.textContent = formatDate(msg.date);
1678
+ date.textContent = formatDate(effectiveDate(msg));
1584
1679
  const subject = document.createElement("span");
1585
1680
  subject.className = "ml-thread-popup-subject";
1586
1681
  subject.textContent = msg.subject || "(no subject)";
@@ -1772,7 +1867,8 @@ class MessageRow {
1772
1867
  renderStatusIcons(icons, msg);
1773
1868
  const dateText = document.createElement("span");
1774
1869
  dateText.className = "ml-date-text";
1775
- dateText.textContent = formatDate(msg.date);
1870
+ dateText.textContent = formatDate(effectiveDate(msg));
1871
+ dateText.title = dateBasis === "received" ? "Received date" : "Sent date";
1776
1872
  date.appendChild(icons);
1777
1873
  date.appendChild(dateText);
1778
1874
 
@@ -2010,6 +2106,18 @@ class MessageRow {
2010
2106
  if (e.ctrlKey || e.metaKey) {
2011
2107
  this.setSelected(!this.isSelected);
2012
2108
  lastClickedRow = this.el;
2109
+ // Ctrl/Cmd-click builds a multi-selection — it MUST set
2110
+ // `multi-select-on`, or a background re-render (new mail arriving)
2111
+ // won't preserve the highlights and restoreSelection collapses the
2112
+ // whole selection down to the single focused row (Bob 2026-07-01:
2113
+ // "suddenly deselect all the messages and leave one"). Same root as
2114
+ // the old Ctrl+A gap. Drop the flag again if the toggle emptied the
2115
+ // selection so a later plain click behaves as single-select.
2116
+ if (body) {
2117
+ const n = body.querySelectorAll(".ml-row.selected").length;
2118
+ if (n >= 1) body.classList.add("multi-select-on");
2119
+ else body.classList.remove("multi-select-on");
2120
+ }
2013
2121
  } else {
2014
2122
  clearSelection();
2015
2123
  focusRow(this);
@@ -762,6 +762,13 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
762
762
  if (name) {
763
763
  items.push({ label: `Copy both: ${both}`, action: () => navigator.clipboard.writeText(both) });
764
764
  }
765
+ items.push({
766
+ label: `Open in contacts: ${addr.address}`,
767
+ action: async () => {
768
+ const { openAddressBook } = await import("./address-book.js");
769
+ await openAddressBook(addr.address);
770
+ },
771
+ });
765
772
  items.push({
766
773
  label: `Add to contacts: ${addr.address}`,
767
774
  action: async () => {