@bobfrankston/rmfmail 1.2.50 → 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 +320 -68
  49. package/packages/mailx-imap/index.js.map +1 -1
  50. package/packages/mailx-imap/index.ts +311 -65
  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 +36 -1
  80. package/packages/mailx-store/db.d.ts.map +1 -1
  81. package/packages/mailx-store/db.js +100 -6
  82. package/packages/mailx-store/db.js.map +1 -1
  83. package/packages/mailx-store/db.ts +100 -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
@@ -58,6 +58,17 @@ function applyLiveFilter(body: HTMLElement): void {
58
58
  }
59
59
 
60
60
  let currentSearchQuery = "";
61
+
62
+ /** Empty-list placeholder text. When a search is active, name the query so an
63
+ * empty list reads as "nothing matched THIS search" rather than a bare,
64
+ * alarming "No messages" (Bob 2026-06-25). Covers paths other than the
65
+ * search loader itself — e.g. deleting the last surviving search hit. */
66
+ function emptyListMessage(): string {
67
+ if (searchMode && currentSearchQuery) {
68
+ return `No messages found by "${escapeHtmlText(currentSearchQuery)}"`;
69
+ }
70
+ return "No messages";
71
+ }
61
72
  // Remember the pre-search view mode so clearSearchMode() can restore it.
62
73
  // Without this, searching from "All Inboxes" loses the unified-mode flag
63
74
  // (loadSearchResults sets unifiedMode=false), and clearing the search
@@ -404,6 +415,156 @@ export function setRowSeen(accountId: string, uid: number, yes: boolean): void {
404
415
  if (row) row.setUnreadClass(!yes);
405
416
  }
406
417
 
418
+ /** Resolve currently-selected rows to MessageRow objects (skips any not in
419
+ * the loaded DOM). */
420
+ function selectedRows(): MessageRow[] {
421
+ const out: MessageRow[] = [];
422
+ for (const s of getSelectedMessages()) {
423
+ const row = rowByKey.get(rowKey(s.accountId, s.uid));
424
+ if (row) out.push(row);
425
+ }
426
+ return out;
427
+ }
428
+
429
+ /** Every row currently shown (visible = matches search + view filters, and
430
+ * loaded — the list lazy-loads). */
431
+ function visibleRowObjs(): MessageRow[] {
432
+ const body = document.getElementById("ml-body");
433
+ if (!body) return [];
434
+ return Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
435
+ .map(el => rowByKey.get(rowKey(el.dataset.accountId || "", Number(el.dataset.uid))))
436
+ .filter((r): r is MessageRow => !!r);
437
+ }
438
+
439
+ /** The set a bulk action targets: the SELECTED rows if any are marked,
440
+ * otherwise ALL shown rows. This is the "act on selected, else all filtered"
441
+ * rule Bob asked for (2026-06-24). */
442
+ function bulkTargets(): MessageRow[] {
443
+ const sel = selectedRows();
444
+ return sel.length > 0 ? sel : visibleRowObjs();
445
+ }
446
+
447
+ /** Flag / unflag a set of rows. Captures the prior flagged state per row and
448
+ * emits `mailx-flagged-batch` so app.ts can register ONE Ctrl+Z undo entry
449
+ * for the whole batch (Bob 2026-06-24: bulk ops need undo for safety). */
450
+ async function bulkSetFlagged(rows: MessageRow[], flagged: boolean): Promise<void> {
451
+ const undo: { accountId: string; uid: number; prevFlagged: boolean }[] = [];
452
+ await Promise.all(rows.map(async (row) => {
453
+ // Read current state from the VISIBLE class via row.isFlagged — NOT
454
+ // flaggedOf(row.msg). The two can drift, and a stale `msg.flags` made
455
+ // Unflag skip flagged rows as "already unflagged" (Bob 2026-06-24
456
+ // "unflag is not removing the flag"). The class is the source of truth
457
+ // the single-star toggle already trusts.
458
+ const prev = row.isFlagged;
459
+ if (prev === flagged) return; // already in the target state
460
+ const prevFlags = [...(row.msg.flags || [])];
461
+ row.isFlagged = flagged; // setter: updates class + msg.flags
462
+ try {
463
+ await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
464
+ state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
465
+ undo.push({ accountId: row.accountId, uid: row.msg.uid, prevFlagged: prev });
466
+ } catch {
467
+ row.msg.flags = prevFlags; // revert on server failure
468
+ row.setFlaggedClass(prev);
469
+ }
470
+ }));
471
+ if (undo.length) document.dispatchEvent(new CustomEvent("mailx-flagged-batch", { detail: undo }));
472
+ }
473
+
474
+ /** Mark a set of rows read / unread. Low-risk + trivially reversible, so no
475
+ * undo entry (unlike flag, move, delete). State read from the class too. */
476
+ async function bulkSetSeen(rows: MessageRow[], seen: boolean): Promise<void> {
477
+ await Promise.all(rows.map(async (row) => {
478
+ const prev = row.isSeen;
479
+ if (prev === seen) return;
480
+ const prevFlags = [...(row.msg.flags || [])];
481
+ row.isSeen = seen; // setter: updates class + msg.flags
482
+ try {
483
+ await updateFlags(row.accountId, row.msg.uid, row.msg.flags);
484
+ state.updateMessageFlags(row.accountId, row.msg.uid, row.msg.flags);
485
+ } catch {
486
+ row.msg.flags = prevFlags;
487
+ row.setUnreadClass(!prev);
488
+ }
489
+ }));
490
+ }
491
+
492
+ /** Move every selected message (of the first selected row's account) to a
493
+ * picked folder. Local-first: optimistic remove + fire-and-forget IPC, same
494
+ * as the row context menu's "Move to folder…". */
495
+ async function bulkMoveSelection(): Promise<void> {
496
+ const targets = bulkTargets();
497
+ if (targets.length === 0) return;
498
+ const accountId = targets[0].accountId;
499
+ const same = targets.filter(r => r.accountId === accountId);
500
+ const pick = await pickFolder(accountId, { excludeFolderIds: [same[0].msg.folderId] });
501
+ if (!pick) return;
502
+ const uids = same.map(r => r.msg.uid);
503
+ removeMessagesAndReconcile(uids.map(u => ({ accountId, uid: u })));
504
+ const statusSync = document.getElementById("status-sync");
505
+ if (statusSync) statusSync.textContent = `Moving ${uids.length} message${uids.length !== 1 ? "s" : ""} to ${pick.folderName}…`;
506
+ apiMoveMessages(accountId, uids, pick.folderId)
507
+ .then(() => { if (statusSync) statusSync.textContent = `Moved ${uids.length} to ${pick.folderName}`; })
508
+ .catch((err: any) => { if (statusSync) statusSync.textContent = `Move sync issue: ${err?.message || err}`; });
509
+ exitMultiSelect();
510
+ }
511
+
512
+ /** Toolbar "Filtered" menu — home for bulk operations. Two concepts, kept
513
+ * separate (Bob 2026-06-24): SHOWN = rows the current folder/search/filters
514
+ * make visible (lazy-loaded, so it's what's loaded now); SELECTED = rows
515
+ * you've clicked. Actions auto-target: if anything is SELECTED they act on
516
+ * that, otherwise on ALL SHOWN — the header says which is in effect. Delete
517
+ * and Mark-as-spam are omitted: the toolbar's own buttons already do those on
518
+ * the selection. Flag/unflag and move are Ctrl+Z-undoable. */
519
+ export async function showSelectionMenu(x: number, y: number): Promise<void> {
520
+ const { showContextMenu } = await import("./context-menu.js");
521
+ const body = document.getElementById("ml-body");
522
+ const visibleRows = body
523
+ ? Array.from(body.querySelectorAll<HTMLElement>(".ml-row:not(.filter-hidden)"))
524
+ : [];
525
+ const enterMulti = () => body?.classList.add("multi-select-on");
526
+ const shownCount = visibleRows.length;
527
+ const selCount = getSelectedMessages().length;
528
+ const hasShown = shownCount > 0;
529
+ // Auto-target: selected if any, else all shown. The action labels carry
530
+ // the effective target count so there's no ambiguity about what runs.
531
+ const targetN = selCount > 0 ? selCount : shownCount;
532
+ const onWhat = selCount > 0 ? `selected (${selCount})` : `all shown (${shownCount})`;
533
+ const canAct = targetN > 0;
534
+
535
+ const items: MenuItem[] = [
536
+ { label: `Acting on ${onWhat}`, action: () => {}, disabled: true },
537
+ { label: "", action: () => {}, separator: true },
538
+ {
539
+ label: `Select all shown (${shownCount})`,
540
+ action: () => { if (!body) return; enterMulti(); for (const r of visibleRows) r.classList.add("selected"); lastClickedRow = visibleRows[visibleRows.length - 1] || null; updateBulkBar(); },
541
+ disabled: !hasShown,
542
+ },
543
+ {
544
+ label: "Select flagged ★ among shown",
545
+ action: () => { if (!body) return; enterMulti(); for (const r of visibleRows) r.classList.toggle("selected", r.classList.contains("flagged")); lastClickedRow = null; updateBulkBar(); },
546
+ disabled: !hasShown,
547
+ },
548
+ {
549
+ label: "Invert selection among shown",
550
+ action: () => { if (!body) return; enterMulti(); for (const r of visibleRows) r.classList.toggle("selected"); updateBulkBar(); },
551
+ disabled: !hasShown,
552
+ },
553
+ { label: `Deselect all (${selCount})`, action: () => exitMultiSelect(), disabled: selCount === 0 },
554
+ { label: "", action: () => {}, separator: true },
555
+ // Actions — auto-targeted (selected else all shown). No count on the
556
+ // verb itself (a number on "Flag" reads as ambiguous, Bob 2026-06-24);
557
+ // the "Acting on …" header above carries the target count.
558
+ { label: "Flag", action: () => bulkSetFlagged(bulkTargets(), true), disabled: !canAct },
559
+ { label: "Unflag", action: () => bulkSetFlagged(bulkTargets(), false), disabled: !canAct },
560
+ { label: "Mark read", action: () => bulkSetSeen(bulkTargets(), true), disabled: !canAct },
561
+ { label: "Mark unread", action: () => bulkSetSeen(bulkTargets(), false), disabled: !canAct },
562
+ { label: "", action: () => {}, separator: true },
563
+ { label: "Move to folder…", action: () => bulkMoveSelection(), disabled: !canAct },
564
+ ];
565
+ showContextMenu(x, y, items);
566
+ }
567
+
407
568
  /** Scroll the focused row into view. Wired to a keyboard shortcut so the
408
569
  * user can recover after scrolling the list away from the preview.
409
570
  *
@@ -832,7 +993,7 @@ export function removeMessagesAndReconcile(
832
993
  }
833
994
  }
834
995
  if (state.getMessages().length === 0) {
835
- body.innerHTML = `<div class="ml-empty">No messages</div>`;
996
+ body.innerHTML = `<div class="ml-empty">${emptyListMessage()}</div>`;
836
997
  }
837
998
  }
838
999
 
@@ -926,10 +1087,14 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
926
1087
  if (cached) {
927
1088
  totalMessages = cached.total;
928
1089
  state.setMessages(cached.items);
929
- renderMessages(body, "", cached.items, { background: !autoSelect });
1090
+ // See loadMessages: anchor the cache render on background reloads so a
1091
+ // scrolled reader isn't yanked to the top when new mail arrives.
1092
+ const renderCached = () => renderMessages(body, "", cached.items, { background: !autoSelect });
1093
+ if (!autoSelect) withScrollAnchor(body, renderCached);
1094
+ else renderCached();
930
1095
  const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
931
1096
  if (targetUuid) {
932
- body.scrollTop = savedScroll;
1097
+ if (autoSelect) body.scrollTop = savedScroll;
933
1098
  restoreSelection(body, targetUuid);
934
1099
  } else if (autoSelect) {
935
1100
  selectFirst(body);
@@ -1043,7 +1208,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
1043
1208
  );
1044
1209
  totalMessages = matches.length;
1045
1210
  state.setMessages(matches);
1046
- if (matches.length === 0) { body.innerHTML = `<div class="ml-empty">No regex matches</div>`; return; }
1211
+ if (matches.length === 0) { body.innerHTML = `<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"</div>`; return; }
1047
1212
  body.innerHTML = "";
1048
1213
  appendMessages(body, "", matches);
1049
1214
  selectFirst(body);
@@ -1069,7 +1234,7 @@ export async function loadSearchResults(query: string, scope = "all", accountId
1069
1234
  if (result.items.length === 0) {
1070
1235
  state.setMessages([]);
1071
1236
  body.innerHTML = partialNote +
1072
- `<div class="ml-empty">No results for "${query}"${partial ? " in the folders that responded" : ""}</div>`;
1237
+ `<div class="ml-empty">No messages found by "${escapeHtmlText(query)}"${partial ? " in the folders that responded" : ""}</div>`;
1073
1238
  return;
1074
1239
  }
1075
1240
 
@@ -1084,7 +1249,16 @@ export async function loadSearchResults(query: string, scope = "all", accountId
1084
1249
  }
1085
1250
  }
1086
1251
 
1087
- export async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = "", autoSelect = true): Promise<void> {
1252
+ /** Search-as-find: leave search and reveal a specific message in its own
1253
+ * folder. Loads the folder list, selects the message, and scrolls it into
1254
+ * view. Used when the user closes search while a result is selected — they
1255
+ * "stay at" that message in its real folder instead of bouncing back to the
1256
+ * pre-search view (Bob 2026-06-25). */
1257
+ export function revealMessage(accountId: string, folderId: number, uid: number, specialUse = ""): void {
1258
+ loadMessages(accountId, folderId, 1, specialUse, true, uid);
1259
+ }
1260
+
1261
+ export async function loadMessages(accountId: string, folderId: number, page = 1, specialUse = "", autoSelect = true, focusUid?: number): Promise<void> {
1088
1262
  const myGen = ++loadGen;
1089
1263
  searchMode = false;
1090
1264
  unifiedMode = false;
@@ -1127,11 +1301,26 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
1127
1301
  if (cached) {
1128
1302
  totalMessages = cached.total;
1129
1303
  state.setMessages(cached.items);
1130
- renderMessages(body, accountId, cached.items, { background: !autoSelect });
1304
+ // Background reload (new mail / sync): a bare renderMessages rebuilds
1305
+ // innerHTML and resets scrollTop to 0, which then makes the follow-up
1306
+ // network anchor think the user is at the top and reveal new mail there
1307
+ // — yanking a scrolled-down reader to the top (Bob 2026-06-24 "the list
1308
+ // jumps when a new message is added ... must not move while I'm
1309
+ // viewing it"). Anchor the cache render too so the visible rows stay put.
1310
+ const renderCached = () => renderMessages(body, accountId, cached.items, { background: !autoSelect });
1311
+ if (!autoSelect) withScrollAnchor(body, renderCached);
1312
+ else renderCached();
1131
1313
  const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
1132
- if (targetUuid) {
1314
+ if (focusUid != null) {
1315
+ // Search-as-find: jump to the specific message the user selected.
1316
+ requestAnimationFrame(() => {
1317
+ if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect) selectFirst(body);
1318
+ });
1319
+ } else if (targetUuid) {
1133
1320
  requestAnimationFrame(() => {
1134
- body.scrollTop = savedScroll;
1321
+ // Folder switch restores saved pixels; a background reload was
1322
+ // already placed by the anchor — don't override it.
1323
+ if (autoSelect) body.scrollTop = savedScroll;
1135
1324
  restoreSelection(body, targetUuid);
1136
1325
  });
1137
1326
  } else if (autoSelect) {
@@ -1184,7 +1373,13 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
1184
1373
 
1185
1374
  // Prefer saved position; otherwise default by autoSelect.
1186
1375
  const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
1187
- if (targetUuid) {
1376
+ if (focusUid != null) {
1377
+ // Search-as-find: select the message the user picked from results.
1378
+ requestAnimationFrame(() => {
1379
+ if (myGen !== loadGen) return;
1380
+ if (!focusByIdentity(accountId, focusUid, { scroll: true }) && autoSelect) selectFirst(body);
1381
+ });
1382
+ } else if (targetUuid) {
1188
1383
  requestAnimationFrame(() => {
1189
1384
  if (myGen !== loadGen) return;
1190
1385
  // On a first paint (no cache), use the saved scrollTop.
@@ -1211,6 +1406,15 @@ async function loadMoreMessages(): Promise<void> {
1211
1406
  loading = true;
1212
1407
  currentPage++;
1213
1408
 
1409
+ // Diagnostics for the "scroll near the bottom does nothing" report
1410
+ // (Bob 2026-06-22: position-dependent, "go up a tad and it works"). The
1411
+ // suspect is a loadMore that doesn't grow scrollHeight — duplicate/empty
1412
+ // page, end-of-list overshoot — leaving the user pinned. Capture
1413
+ // before/after geometry + how many rows the page actually added so the
1414
+ // log shows whether the list grew, stalled, or the page came back empty.
1415
+ const t0 = Date.now();
1416
+ const heightBefore = body.scrollHeight;
1417
+ const rowsBefore = body.querySelectorAll(".ml-row").length;
1214
1418
  try {
1215
1419
  const flaggedOnly = body.classList.contains("flagged-only");
1216
1420
  const result = searchMode
@@ -1222,6 +1426,11 @@ async function loadMoreMessages(): Promise<void> {
1222
1426
  const current = state.getMessages();
1223
1427
  state.setMessages([...current, ...result.items]);
1224
1428
  appendMessages(body, unifiedMode ? "" : currentAccountId, result.items);
1429
+ const rowsAfter = body.querySelectorAll(".ml-row").length;
1430
+ const heightAfter = body.scrollHeight;
1431
+ const elapsed = Date.now() - t0;
1432
+ const grew = heightAfter > heightBefore;
1433
+ 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"}`);
1225
1434
  } catch (e: any) {
1226
1435
  console.error(`Load more error: ${e.message}`);
1227
1436
  } finally {
@@ -1231,11 +1440,29 @@ async function loadMoreMessages(): Promise<void> {
1231
1440
 
1232
1441
  /** Replace body contents with rendered rows */
1233
1442
  function renderMessages(body: HTMLElement, accountId: string, items: any[], opts: { background?: boolean } = {}): void {
1443
+ // MULTI-SELECT SURVIVAL: replaceChildren below builds fresh rows with no
1444
+ // `.selected`, so a sync/new-mail rebuild would collapse an active
1445
+ // multi-selection down to the single focused row restoreSelection re-binds
1446
+ // (Bob 2026-06-22: "selected multiple NMLS spam, suddenly seeing only one —
1447
+ // a new message arrived and confused you"). The `multi-select-on` class
1448
+ // lives on the body and survives the rebuild, so when it's set we snapshot
1449
+ // the selected rows' stable uuids and re-apply `.selected` to whichever
1450
+ // survive the new render. Keyed on uuid, so a folder switch (different
1451
+ // uuids) matches nothing and correctly drops the stale highlight.
1452
+ const preserveSel = body.classList.contains("multi-select-on")
1453
+ ? new Set(Array.from(body.querySelectorAll<HTMLElement>(".ml-row.selected"))
1454
+ .map(r => r.dataset.uuid || "").filter(Boolean))
1455
+ : null;
1234
1456
  const fragment = document.createDocumentFragment();
1235
1457
  const tempDiv = document.createElement("div");
1236
1458
  appendMessages(tempDiv, accountId, items);
1237
1459
  while (tempDiv.firstChild) fragment.appendChild(tempDiv.firstChild);
1238
1460
  body.replaceChildren(fragment);
1461
+ if (preserveSel && preserveSel.size > 0) {
1462
+ for (const uuid of preserveSel) {
1463
+ body.querySelector(`.ml-row[data-uuid="${CSS.escape(uuid)}"]`)?.classList.add("selected");
1464
+ }
1465
+ }
1239
1466
  // A filter (flagged-only / priority-only) that hides EVERY row makes the
1240
1467
  // list look broken — identical to an empty/failed load. Bob 2026-05-27
1241
1468
  // lost an afternoon to this: the flagged-only filter was stuck on (it
@@ -1671,6 +1898,19 @@ class MessageRow {
1671
1898
 
1672
1899
  private onFlagClick(e: MouseEvent): void {
1673
1900
  e.stopPropagation();
1901
+ // Multi-select: clicking the ★ on one of several highlighted rows acts
1902
+ // on the WHOLE selection, not just this row — same as the context-menu
1903
+ // Flag/Unflag (Bob 2026-06-26: "clear the * only clears one not the
1904
+ // selection"). Target state is the toggle of the clicked row, so a
1905
+ // flagged row clears the lot and an unflagged row flags the lot.
1906
+ const body = this.el.parentElement as HTMLElement | null;
1907
+ if (body?.classList.contains("multi-select-on") && this.el.classList.contains("selected")) {
1908
+ const sel = selectedRows();
1909
+ if (sel.length > 1) {
1910
+ void bulkSetFlagged(sel, !this.el.classList.contains("flagged"));
1911
+ return;
1912
+ }
1913
+ }
1674
1914
  // Local-first: flip the visual class + in-memory flag array NOW so the
1675
1915
  // star toggles under the cursor with zero latency, then mirror to the
1676
1916
  // server in the background. The earlier `await updateFlags(...)`
@@ -1845,36 +2085,24 @@ class MessageRow {
1845
2085
  const msg = this.msg;
1846
2086
  const self = this;
1847
2087
 
2088
+ // Count of selected rows — when >1, the read/flag actions apply to the
2089
+ // whole selection and the label says so (e.g. "Unflag (5)"). The row
2090
+ // menu's fallback (nothing selected) is the clicked row, NOT all shown.
2091
+ const selCount = getSelectedMessages().length;
2092
+ const countSfx = selCount > 1 ? ` (${selCount})` : "";
2093
+ const rowTargets = (): MessageRow[] => { const s = selectedRows(); return s.length > 0 ? s : [self]; };
2094
+
1848
2095
  const items: MenuItem[] = [
1849
2096
  {
1850
- label: isSeen ? "Mark unread" : "Mark read",
1851
- action: async () => {
1852
- // Target state = inverse of what the menu showed,
1853
- // matching what the user clicked. Visual-state-based
1854
- // labels stay coherent with the auto-mark-as-read
1855
- // debounce; the new flag array is computed via the
1856
- // typed setter and sent to the server.
1857
- const probe = { flags: [...(msg.flags || [])] };
1858
- setSeen(probe, !isSeen);
1859
- try {
1860
- await updateFlags(accountId, msg.uid, probe.flags);
1861
- msg.flags = probe.flags;
1862
- state.updateMessageFlags(accountId, msg.uid, probe.flags);
1863
- self.setUnreadClass(isSeen);
1864
- } catch { /* ignore */ }
1865
- },
2097
+ // Target state = inverse of what the menu showed, matching what
2098
+ // the user clicked. Visual-state-based labels stay coherent with
2099
+ // the auto-mark-as-read debounce. Applies to the whole selection.
2100
+ label: (isSeen ? "Mark unread" : "Mark read") + countSfx,
2101
+ action: () => bulkSetSeen(rowTargets(), !isSeen),
1866
2102
  },
1867
2103
  {
1868
- label: isFlagged ? "Unflag" : "Flag",
1869
- action: async () => {
1870
- const probe = { flags: [...(msg.flags || [])] };
1871
- setFlagged(probe, !isFlagged);
1872
- try {
1873
- await updateFlags(accountId, msg.uid, probe.flags);
1874
- msg.flags = probe.flags;
1875
- self.setFlaggedClass(!isFlagged);
1876
- } catch { /* ignore */ }
1877
- },
2104
+ label: (isFlagged ? "Unflag" : "Flag") + countSfx,
2105
+ action: () => bulkSetFlagged(rowTargets(), !isFlagged),
1878
2106
  },
1879
2107
  { label: "", action: () => {}, separator: true },
1880
2108
  // Drafts get an explicit "Edit draft" entry as the primary action —
@@ -1367,8 +1367,8 @@ function markFolderRead(accountId, folderId) {
1367
1367
  function createFolder(accountId, parentPath, name) {
1368
1368
  return ipc().createFolder?.(accountId, parentPath, name);
1369
1369
  }
1370
- function renameFolder(accountId, folderId, newName) {
1371
- return ipc().renameFolder?.(accountId, folderId, newName);
1370
+ function renameFolder(accountId, folderId, newName, newParentId) {
1371
+ return ipc().renameFolder?.(accountId, folderId, newName, newParentId);
1372
1372
  }
1373
1373
  function deleteFolder(accountId, folderId) {
1374
1374
  return ipc().deleteFolder?.(accountId, folderId);
@@ -2301,20 +2301,35 @@ async function createTinyMceEditor(container2, opts = {}) {
2301
2301
  }
2302
2302
  }
2303
2303
  });
2304
+ const QUOTE_SELECTOR = "blockquote, div.reply, .gmail_quote, .moz-cite-prefix, pre.moz-quote-pre, [type=cite]";
2305
+ const markQuotedNoSpellcheck = (body) => {
2306
+ if (!body?.querySelectorAll)
2307
+ return;
2308
+ body.querySelectorAll(QUOTE_SELECTOR).forEach((el) => {
2309
+ el.setAttribute("spellcheck", "false");
2310
+ });
2311
+ };
2312
+ ed.on("SetContent", () => {
2313
+ try {
2314
+ markQuotedNoSpellcheck(ed.getBody());
2315
+ } catch {
2316
+ }
2317
+ });
2304
2318
  ed.on("init", () => {
2305
2319
  try {
2306
2320
  const body = ed.getBody();
2307
2321
  if (body) {
2308
2322
  body.setAttribute("spellcheck", "true");
2309
2323
  body.setAttribute("lang", "en");
2324
+ markQuotedNoSpellcheck(body);
2310
2325
  }
2311
2326
  const doc = ed.getDoc();
2312
2327
  if (doc?.documentElement)
2313
2328
  doc.documentElement.setAttribute("lang", "en");
2314
2329
  } catch {
2315
2330
  }
2316
- if (zoomPx !== ZOOM_DEFAULT)
2317
- applyZoom();
2331
+ const reflowRaf = (ed.getWin && ed.getWin() || window).requestAnimationFrame || ((f) => setTimeout(f, 16));
2332
+ reflowRaf(() => applyZoom());
2318
2333
  try {
2319
2334
  const doc = ed.getDoc();
2320
2335
  doc.addEventListener("wheel", (e) => {
@@ -3345,12 +3360,22 @@ function createQuillEditor(container2) {
3345
3360
  };
3346
3361
  }
3347
3362
  async function createTiptapEditor(container2) {
3348
- const { Editor } = window.tiptapCore;
3349
- const { StarterKit } = window.tiptapStarterKit;
3350
- const { Link } = window.tiptapExtensionLink;
3351
- const { Image } = window.tiptapExtensionImage;
3352
- const { Underline } = window.tiptapExtensionUnderline;
3353
- const { Placeholder } = window.tiptapExtensionPlaceholder;
3363
+ const w = window;
3364
+ const core = w["@tiptap/core"];
3365
+ const starter = w["@tiptap/starter-kit"];
3366
+ const link = w["@tiptap/extension-link"];
3367
+ const image = w["@tiptap/extension-image"];
3368
+ const underline = w["@tiptap/extension-underline"];
3369
+ const placeholder = w["@tiptap/extension-placeholder"];
3370
+ const Editor = core?.Editor;
3371
+ const StarterKit = starter?.StarterKit ?? starter?.default;
3372
+ const Link = link?.Link ?? link?.default;
3373
+ const Image = image?.Image ?? image?.default;
3374
+ const Underline = underline?.Underline ?? underline?.default;
3375
+ const Placeholder = placeholder?.Placeholder ?? placeholder?.default;
3376
+ if (!Editor || !StarterKit) {
3377
+ throw new Error("tiptap UMD globals not found (expected window['@tiptap/core'].Editor)");
3378
+ }
3354
3379
  const toolbar = document.createElement("div");
3355
3380
  toolbar.className = "tt-toolbar";
3356
3381
  toolbar.innerHTML = `
@@ -3695,8 +3720,11 @@ function setActiveEditorBadge(type) {
3695
3720
  tinymce: "https://www.tiny.cloud/docs/tinymce/6/",
3696
3721
  fallback: "https://github.com/BobFrankston/mailx/blob/master/app/docs/editor.md"
3697
3722
  };
3698
- el.textContent = labels[type] || type;
3723
+ const requested = editorType;
3724
+ const fellBack = type !== "fallback" && type !== requested;
3725
+ el.textContent = fellBack ? `${labels[type] || type} (${labels[requested] || requested} unavailable)` : labels[type] || type;
3699
3726
  el.dataset.editor = type;
3727
+ el.dataset.fellBack = fellBack ? "true" : "false";
3700
3728
  const url = docs[type];
3701
3729
  if (url) {
3702
3730
  el.style.cursor = "pointer";
@@ -3824,6 +3852,18 @@ setActiveEditorBadge(activeEditorType);
3824
3852
  })();
3825
3853
  var fromInput = document.getElementById("compose-from-input");
3826
3854
  var fromOptions = document.getElementById("compose-from-options");
3855
+ if (fromInput) {
3856
+ fromInput.addEventListener("focus", () => {
3857
+ fromInput.dataset.prevFrom = fromInput.value;
3858
+ fromInput.value = "";
3859
+ });
3860
+ fromInput.addEventListener("blur", () => {
3861
+ if (fromInput.value.trim() === "" && fromInput.dataset.prevFrom) {
3862
+ fromInput.value = fromInput.dataset.prevFrom;
3863
+ }
3864
+ delete fromInput.dataset.prevFrom;
3865
+ });
3866
+ }
3827
3867
  var toInput = document.getElementById("compose-to");
3828
3868
  var ccInput = document.getElementById("compose-cc");
3829
3869
  var bccInput = document.getElementById("compose-bcc");
@@ -4248,18 +4288,24 @@ setupAutocomplete(toInput);
4248
4288
  setupAutocomplete(ccInput);
4249
4289
  setupAutocomplete(bccInput);
4250
4290
  function splitRecipients(s) {
4251
- const out = [];
4291
+ const raw = [];
4252
4292
  let cur = "", inQuote = false;
4253
4293
  for (const c of s) {
4254
4294
  if (c === '"') {
4255
4295
  inQuote = !inQuote;
4256
4296
  cur += c;
4257
4297
  } else if (c === "," && !inQuote) {
4258
- out.push(cur);
4298
+ raw.push(cur);
4259
4299
  cur = "";
4260
4300
  } else cur += c;
4261
4301
  }
4262
- out.push(cur);
4302
+ raw.push(cur);
4303
+ const out = [];
4304
+ for (let i = 0; i < raw.length; i++) {
4305
+ let seg = raw[i];
4306
+ while (i < raw.length - 1 && !/[@<]/.test(seg)) seg += "," + raw[++i];
4307
+ out.push(seg);
4308
+ }
4263
4309
  return out;
4264
4310
  }
4265
4311
  function tokenSpanAtCaret(s, caret) {
@@ -4423,6 +4469,16 @@ function applyInit(init) {
4423
4469
  }
4424
4470
  replyInReplyTo = init.inReplyTo || "";
4425
4471
  replyReferences = init.references || [];
4472
+ if (Array.isArray(init.attachments) && init.attachments.length && attachments.length === 0) {
4473
+ for (const a of init.attachments) {
4474
+ if (a && a.dataBase64) {
4475
+ const b64 = a.dataBase64;
4476
+ const size = Math.max(0, Math.floor(b64.length * 3 / 4) - (b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0));
4477
+ attachments.push({ filename: a.filename, mimeType: a.mimeType, size, dataBase64: b64 });
4478
+ }
4479
+ }
4480
+ renderAttachmentChips();
4481
+ }
4426
4482
  setComposeTitle(init.subject || "");
4427
4483
  if (!toInput.value) toInput.focus();
4428
4484
  else if (!subjectInput.value) subjectInput.focus();