@bobfrankston/mailx 1.0.132 → 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
@@ -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);
@@ -585,8 +586,19 @@ onWsEvent((event) => {
585
586
  // Incremental count update — no DOM rebuild, no jitter
586
587
  updateFolderCounts();
587
588
  updateNewMessageCount();
588
- // Reload message list but keep current scroll position and selection
589
- 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
+ }
590
602
  // Sync finished — re-enable sync button
591
603
  const syncBtn = document.getElementById("btn-sync");
592
604
  if (syncBtn) {
@@ -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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.132",
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