@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
|
-
|
|
586
|
+
// Incremental count update — no DOM rebuild, no jitter
|
|
587
|
+
updateFolderCounts();
|
|
586
588
|
updateNewMessageCount();
|
|
587
|
-
//
|
|
588
|
-
|
|
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
|
-
//
|
|
437
|
-
|
|
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
|
-
|
|
466
|
+
fragment.appendChild(unifiedEl);
|
|
454
467
|
}
|
|
455
|
-
for (const account of
|
|
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
|
|
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
|
|
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
|
|
491
|
-
const
|
|
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
|
-
|
|
510
|
+
fragment.appendChild(accountEl);
|
|
499
511
|
}
|
|
500
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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");
|
package/client/package.json
CHANGED
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|