@bobfrankston/mailx 1.0.131 → 1.0.133

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.
package/client/app.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * mailx client entry point.
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
- import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
5
+ import { initFolderTree, refreshFolderTree, updateFolderCounts } from "./components/folder-tree.js";
6
6
  import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
8
  import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, rebuildServer } from "./lib/api-client.js";
@@ -428,6 +428,7 @@ function doSearch(immediate = false) {
428
428
  // Track current folder for scoped search
429
429
  let currentAccountId = "";
430
430
  let currentFolderId = 0;
431
+ let reloadDebounceTimer = null;
431
432
  searchInput?.addEventListener("input", () => {
432
433
  clearTimeout(searchTimeout);
433
434
  searchTimeout = setTimeout(() => doSearch(false), 300);
@@ -582,10 +583,22 @@ onWsEvent((event) => {
582
583
  break;
583
584
  }
584
585
  case "folderCountsChanged": {
585
- refreshFolderTree();
586
+ // Incremental count update — no DOM rebuild, no jitter
587
+ updateFolderCounts();
586
588
  updateNewMessageCount();
587
- // Reload message list but keep current scroll position and selection
588
- reloadCurrentFolder();
589
+ // Only reload message list if the synced account is the one we're viewing
590
+ // (or unified inbox which shows all accounts). Debounce to avoid rapid reloads
591
+ // during first sync which emits per-batch.
592
+ const syncedAccount = event.accountId;
593
+ const viewingThis = !currentAccountId || currentAccountId === syncedAccount;
594
+ if (viewingThis) {
595
+ if (reloadDebounceTimer)
596
+ clearTimeout(reloadDebounceTimer);
597
+ reloadDebounceTimer = setTimeout(() => {
598
+ reloadDebounceTimer = null;
599
+ reloadCurrentFolder();
600
+ }, 500);
601
+ }
589
602
  // Sync finished — re-enable sync button
590
603
  const syncBtn = document.getElementById("btn-sync");
591
604
  if (syncBtn) {
@@ -9,6 +9,9 @@ let onUnifiedInbox = null;
9
9
  let selectedElement;
10
10
  let selectedAccountId = null;
11
11
  let selectedFolderId = null;
12
+ let isFirstLoad = true; // only auto-select on first load
13
+ // Debounce timer for refreshFolderTree
14
+ let refreshDebounceTimer = null;
12
15
  // Persist expand/collapse state in localStorage
13
16
  const expandState = JSON.parse(localStorage.getItem("mailx-folders-expanded") || "{}");
14
17
  function saveExpandState() {
@@ -114,6 +117,7 @@ function renderNode(node, container, depth) {
114
117
  folderEl.dataset.accountId = node.accountId;
115
118
  folderEl.dataset.folderId = String(node.id);
116
119
  folderEl.dataset.folderPath = node.path;
120
+ folderEl.dataset.specialUse = node.specialUse || "";
117
121
  folderEl.style.paddingLeft = `${depth * 16 + 8}px`;
118
122
  // Expand/collapse toggle
119
123
  const toggle = document.createElement("span");
@@ -433,8 +437,17 @@ async function loadFolderTree(container) {
433
437
  }
434
438
  return;
435
439
  }
436
- // Clear loading state now that we have data
437
- container.innerHTML = "";
440
+ // Fetch ALL account folder data in parallel BEFORE touching the DOM
441
+ const accountFolderData = await Promise.all(accounts.map(async (account) => {
442
+ const accountKey = `account:${account.id}`;
443
+ const accountExpanded = expandState[accountKey] !== false;
444
+ const folders = accountExpanded ? await getFolders(account.id) : [];
445
+ return { account, folders };
446
+ }));
447
+ // Save scroll position before rebuild
448
+ const savedScroll = container.scrollTop;
449
+ // Build entire new tree into a DocumentFragment (off-screen, no reflows)
450
+ const fragment = document.createDocumentFragment();
438
451
  // Unified Inbox (if multiple accounts)
439
452
  if (accounts.length > 1) {
440
453
  const unifiedEl = document.createElement("div");
@@ -450,9 +463,9 @@ async function loadFolderTree(container) {
450
463
  if (onUnifiedInbox)
451
464
  onUnifiedInbox();
452
465
  });
453
- container.appendChild(unifiedEl);
466
+ fragment.appendChild(unifiedEl);
454
467
  }
455
- for (const account of accounts) {
468
+ for (const { account, folders } of accountFolderData) {
456
469
  const accountEl = document.createElement("div");
457
470
  accountEl.className = "ft-account";
458
471
  const accountKey = `account:${account.id}`;
@@ -474,10 +487,10 @@ async function loadFolderTree(container) {
474
487
  const allExpanded = Object.entries(expandState)
475
488
  .filter(([k]) => k.startsWith(`${account.id}:`))
476
489
  .every(([, v]) => v);
477
- const allFolders = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
490
+ const allFolderKeys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
478
491
  // If some expanded, collapse all. If all collapsed, expand all.
479
492
  const newState = !allExpanded;
480
- for (const key of allFolders)
493
+ for (const key of allFolderKeys)
481
494
  expandState[key] = newState;
482
495
  expandState[accountKey] = newState;
483
496
  saveExpandState();
@@ -486,18 +499,21 @@ async function loadFolderTree(container) {
486
499
  loadFolderTree(treeContainer);
487
500
  });
488
501
  accountEl.appendChild(header);
489
- if (accountExpanded) {
490
- const allFolders = await getFolders(account.id);
491
- const delimiter = allFolders[0]?.delimiter || ".";
492
- const tree = buildTree(allFolders, delimiter, account.id);
502
+ if (accountExpanded && folders.length > 0) {
503
+ const delimiter = folders[0]?.delimiter || ".";
504
+ const tree = buildTree(folders, delimiter, account.id);
493
505
  sortFolders(tree);
494
506
  for (const node of tree) {
495
507
  renderNode(node, accountEl, 1);
496
508
  }
497
509
  }
498
- container.appendChild(accountEl);
510
+ fragment.appendChild(accountEl);
499
511
  }
500
- // Re-select previous folder on refresh, or auto-select Inbox on first load
512
+ // Atomic swap single reflow, no intermediate empty state
513
+ container.replaceChildren(fragment);
514
+ // Restore scroll position
515
+ container.scrollTop = savedScroll;
516
+ // Re-select previous folder, or auto-select on first load
501
517
  const allFolderEls = container.querySelectorAll('.ft-folder');
502
518
  let target = null;
503
519
  if (selectedFolderId === -1) {
@@ -520,14 +536,13 @@ async function loadFolderTree(container) {
520
536
  }
521
537
  }
522
538
  }
523
- if (!target) {
524
- // Prefer unified inbox if available, otherwise pick inbox with most unread
539
+ if (!target && isFirstLoad) {
540
+ // Auto-select only on first load not on refresh (prevents jumping)
525
541
  const unified = container.querySelector('.ft-unified');
526
542
  if (unified) {
527
543
  target = unified;
528
544
  }
529
545
  else {
530
- // Find all inboxes, pick the one with a badge (unread count)
531
546
  let bestInbox = null;
532
547
  let bestCount = -1;
533
548
  for (const f of allFolderEls) {
@@ -548,6 +563,7 @@ async function loadFolderTree(container) {
548
563
  if (target)
549
564
  target.click();
550
565
  }
566
+ isFirstLoad = false;
551
567
  // Dismiss startup overlay once tree is loaded
552
568
  const overlay = document.getElementById("startup-overlay");
553
569
  if (overlay)
@@ -556,7 +572,10 @@ async function loadFolderTree(container) {
556
572
  setTimeout(() => overlay?.remove(), 400);
557
573
  }
558
574
  catch (e) {
559
- container.innerHTML = `<div class="folder-loading">Error loading folders: ${e.message}</div>`;
575
+ const errEl = document.createElement("div");
576
+ errEl.className = "folder-loading";
577
+ errEl.textContent = `Error loading folders: ${e.message}`;
578
+ container.replaceChildren(errEl);
560
579
  // Dismiss overlay on error too — show the error, not a spinner
561
580
  const overlay = document.getElementById("startup-overlay");
562
581
  if (overlay) {
@@ -567,10 +586,128 @@ async function loadFolderTree(container) {
567
586
  }
568
587
  }
569
588
  }
570
- /** Refresh folder tree (e.g., after sync) */
589
+ /** Refresh folder tree (e.g., after sync) — debounced to prevent rapid rebuilds */
571
590
  export function refreshFolderTree() {
591
+ if (refreshDebounceTimer)
592
+ clearTimeout(refreshDebounceTimer);
593
+ refreshDebounceTimer = setTimeout(() => {
594
+ refreshDebounceTimer = null;
595
+ const container = document.getElementById("folder-tree");
596
+ if (container)
597
+ loadFolderTree(container);
598
+ }, 300);
599
+ }
600
+ /**
601
+ * Incremental count update — patches badge counts in-place without rebuilding the DOM.
602
+ * Used for folderCountsChanged events to avoid jitter. Falls back to full rebuild
603
+ * if the folder structure has changed.
604
+ */
605
+ export async function updateFolderCounts() {
572
606
  const container = document.getElementById("folder-tree");
573
- if (container)
574
- loadFolderTree(container);
607
+ if (!container)
608
+ return;
609
+ // If tree hasn't loaded yet, do a full load
610
+ if (container.children.length === 0 || container.querySelector(".folder-loading")) {
611
+ refreshFolderTree();
612
+ return;
613
+ }
614
+ try {
615
+ const accounts = await getAccounts();
616
+ // Fetch all folder data in parallel
617
+ const allFolderData = await Promise.all(accounts.map(async (account) => {
618
+ const folders = await getFolders(account.id);
619
+ return { accountId: account.id, folders };
620
+ }));
621
+ // Build a lookup: accountId+folderId → { unreadCount, totalCount }
622
+ // Also rebuild trees to get aggregated counts
623
+ const countMap = new Map();
624
+ for (const { accountId, folders } of allFolderData) {
625
+ const delimiter = folders[0]?.delimiter || ".";
626
+ const tree = buildTree(folders, delimiter, accountId);
627
+ // Walk the tree and collect counts (buildTree already aggregates)
628
+ function collectCounts(nodes) {
629
+ for (const n of nodes) {
630
+ countMap.set(`${n.accountId}:${n.id}`, { unread: n.unreadCount, total: n.totalCount });
631
+ collectCounts(n.children);
632
+ }
633
+ }
634
+ collectCounts(tree);
635
+ }
636
+ // Patch existing DOM elements in-place
637
+ const folderEls = container.querySelectorAll(".ft-folder[data-account-id][data-folder-id]");
638
+ let structureChanged = false;
639
+ for (const el of folderEls) {
640
+ const htmlEl = el;
641
+ const key = `${htmlEl.dataset.accountId}:${htmlEl.dataset.folderId}`;
642
+ const counts = countMap.get(key);
643
+ if (!counts)
644
+ continue; // folder not found — structure may have changed
645
+ // Update unread badge
646
+ const isOutbox = htmlEl.dataset.specialUse === "outbox" || htmlEl.dataset.folderPath?.toLowerCase() === "outbox";
647
+ let badge = htmlEl.querySelector(".ft-badge");
648
+ const outboxBadge = htmlEl.querySelector(".ft-badge-outbox");
649
+ if (isOutbox) {
650
+ if (counts.total > 0) {
651
+ if (outboxBadge) {
652
+ outboxBadge.textContent = String(counts.total);
653
+ }
654
+ else {
655
+ const b = document.createElement("span");
656
+ b.className = "ft-badge ft-badge-outbox";
657
+ b.textContent = String(counts.total);
658
+ htmlEl.querySelector(".ft-folder-name")?.after(b);
659
+ }
660
+ }
661
+ else if (outboxBadge) {
662
+ outboxBadge.remove();
663
+ }
664
+ }
665
+ else {
666
+ if (counts.unread > 0) {
667
+ if (badge) {
668
+ badge.textContent = String(counts.unread);
669
+ }
670
+ else {
671
+ const b = document.createElement("span");
672
+ b.className = "ft-badge";
673
+ b.textContent = String(counts.unread);
674
+ htmlEl.querySelector(".ft-folder-name")?.after(b);
675
+ }
676
+ }
677
+ else if (badge && !badge.classList.contains("ft-badge-outbox")) {
678
+ badge.remove();
679
+ }
680
+ }
681
+ // Update total count
682
+ let totalEl = htmlEl.querySelector(".ft-total-count");
683
+ if (counts.total > 0) {
684
+ if (totalEl) {
685
+ totalEl.textContent = String(counts.total);
686
+ }
687
+ else {
688
+ const t = document.createElement("span");
689
+ t.className = "ft-total-count";
690
+ t.textContent = String(counts.total);
691
+ htmlEl.appendChild(t);
692
+ }
693
+ }
694
+ else if (totalEl) {
695
+ totalEl.remove();
696
+ }
697
+ }
698
+ // Check if folder count changed (new folders added or removed)
699
+ const existingCount = folderEls.length;
700
+ let serverCount = 0;
701
+ for (const { folders } of allFolderData)
702
+ serverCount += folders.length;
703
+ if (Math.abs(existingCount - serverCount) > 2) {
704
+ // Structure changed significantly — do a full rebuild
705
+ refreshFolderTree();
706
+ }
707
+ }
708
+ catch {
709
+ // If count update fails, fall back to full rebuild
710
+ refreshFolderTree();
711
+ }
575
712
  }
576
713
  //# sourceMappingURL=folder-tree.js.map
@@ -94,7 +94,10 @@ export async function loadUnifiedInbox(autoSelect = true) {
94
94
  return;
95
95
  const savedScroll = !autoSelect ? body.scrollTop : 0;
96
96
  const savedUid = !autoSelect ? body.querySelector(".ml-row.selected")?.getAttribute("data-uid") : null;
97
- body.innerHTML = `<div class="ml-empty">Loading...</div>`;
97
+ // Only show loading indicator on fresh navigation, not reloads
98
+ if (autoSelect) {
99
+ body.innerHTML = `<div class="ml-empty">Loading...</div>`;
100
+ }
98
101
  try {
99
102
  const result = await getUnifiedInbox(1);
100
103
  totalMessages = result.total;
@@ -103,8 +106,13 @@ export async function loadUnifiedInbox(autoSelect = true) {
103
106
  clearViewer();
104
107
  return;
105
108
  }
106
- body.innerHTML = "";
107
- appendMessages(body, "", result.items);
109
+ // Build new rows into a fragment, then swap atomically (no flash)
110
+ const fragment = document.createDocumentFragment();
111
+ const tempDiv = document.createElement("div");
112
+ appendMessages(tempDiv, "", result.items);
113
+ while (tempDiv.firstChild)
114
+ fragment.appendChild(tempDiv.firstChild);
115
+ body.replaceChildren(fragment);
108
116
  if (autoSelect) {
109
117
  const firstRow = body.querySelector(".ml-row");
110
118
  if (firstRow)
@@ -209,8 +217,13 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
209
217
  clearViewer();
210
218
  return;
211
219
  }
212
- body.innerHTML = "";
213
- appendMessages(body, accountId, result.items);
220
+ // Build new rows into a fragment, then swap atomically (no flash)
221
+ const fragment = document.createDocumentFragment();
222
+ const tempDiv = document.createElement("div");
223
+ appendMessages(tempDiv, accountId, result.items);
224
+ while (tempDiv.firstChild)
225
+ fragment.appendChild(tempDiv.firstChild);
226
+ body.replaceChildren(fragment);
214
227
  if (autoSelect) {
215
228
  // Explicit folder navigation — select first message
216
229
  const firstRow = body.querySelector(".ml-row");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-client",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "dependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.131",
3
+ "version": "1.0.133",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -866,7 +866,11 @@ export class ImapManager extends EventEmitter {
866
866
  for (let attempt = 0; attempt < 2; attempt++) {
867
867
  try {
868
868
  const client = this.getFetchClient(accountId);
869
- const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
869
+ // 30s timeout prevents hanging on stale connections
870
+ const msg = await Promise.race([
871
+ client.fetchMessageByUid(folder.path, uid, { source: true }),
872
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Body fetch timeout (30s)")), 30000))
873
+ ]);
870
874
  if (!msg?.source)
871
875
  return null;
872
876
  const raw = Buffer.from(msg.source, "utf-8");
@@ -876,8 +880,16 @@ export class ImapManager extends EventEmitter {
876
880
  return raw;
877
881
  }
878
882
  catch (e) {
879
- console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
883
+ console.error(` Body fetch error (${accountId}/${uid} attempt ${attempt + 1}): ${e.message}`);
884
+ // Kill stale client so retry creates a fresh connection
885
+ const stale = this.fetchClients.get(accountId);
880
886
  this.fetchClients.delete(accountId);
887
+ if (stale) {
888
+ try {
889
+ await stale.logout();
890
+ }
891
+ catch { /* ignore */ }
892
+ }
881
893
  if (attempt === 1)
882
894
  return null;
883
895
  // Retry with fresh client
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-server",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",