@bobfrankston/mailx 1.0.156 → 1.0.157

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/bin/mailx.js CHANGED
@@ -703,8 +703,9 @@ async function main() {
703
703
  imapManager.on("accountError", (accountId, error, hint, isOAuth) => {
704
704
  handle.send({ _event: "accountError", type: "accountError", accountId, error, hint, isOAuth });
705
705
  });
706
- // Wait for WebView2 initialization before starting IMAP (stdin writes during init crash wry)
706
+ // Wait for WebView2 initialization, then signal readiness
707
707
  await new Promise(r => setTimeout(r, 2000));
708
+ handle.send({ _event: "ready", type: "ready" });
708
709
  // Register all accounts (OAuth may open browser for Gmail — event loop stays free for IPC)
709
710
  for (const account of settings.accounts) {
710
711
  if (!account.enabled)
package/client/app.js CHANGED
@@ -899,25 +899,8 @@ optAutocomplete?.addEventListener("change", () => {
899
899
  }).catch(() => { });
900
900
  });
901
901
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
902
- // Retry getVersion first IPC calls may be lost before Rust process is ready
903
- async function getVersionWithRetry() {
904
- // Wait for IPC to be established (first getAccounts succeeds around cbid 3)
905
- await new Promise(r => setTimeout(r, 3000));
906
- for (let i = 0; i < 5; i++) {
907
- try {
908
- const result = await Promise.race([
909
- getVersion(),
910
- new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
911
- ]);
912
- return result;
913
- }
914
- catch {
915
- await new Promise(r => setTimeout(r, 2000));
916
- }
917
- }
918
- return { version: "?", storage: {} };
919
- }
920
- const versionPromise = getVersionWithRetry();
902
+ // Wait for server ready signal, then fetch version
903
+ const versionPromise = getVersion();
921
904
  versionPromise.then((d) => {
922
905
  const el = document.getElementById("app-version");
923
906
  const storage = d.storage || {};
@@ -102,8 +102,7 @@ export async function loadUnifiedInbox(autoSelect = true) {
102
102
  const result = await getUnifiedInbox(1);
103
103
  totalMessages = result.total;
104
104
  if (result.items.length === 0) {
105
- body.innerHTML = `<div class="ml-empty">No messages</div>`;
106
- clearViewer();
105
+ body.innerHTML = `<div class="ml-empty">${result.total > 0 ? `${result.total} messages syncing...` : "Syncing — messages will appear shortly"}</div>`;
107
106
  return;
108
107
  }
109
108
  // Build new rows into a fragment, then swap atomically (no flash)
@@ -187,6 +186,9 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
187
186
  }
188
187
  }
189
188
  export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
189
+ // Clear viewer when navigating to a new folder (not on reloads)
190
+ if (autoSelect)
191
+ clearViewer();
190
192
  searchMode = false;
191
193
  unifiedMode = false;
192
194
  showToInsteadOfFrom = ["sent", "drafts", "outbox"].includes(specialUse) ||
@@ -19,8 +19,8 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
19
19
  const headerEl = document.getElementById("mv-header");
20
20
  const bodyEl = document.getElementById("mv-body");
21
21
  const attEl = document.getElementById("mv-attachments");
22
- bodyEl.innerHTML = `<div class="mv-empty">Loading...</div>`;
23
- headerEl.hidden = true;
22
+ bodyEl.innerHTML = `<div class="mv-empty">Fetching message body...</div>`;
23
+ // Don't hide the header — keep previous header visible until new one loads
24
24
  attEl.hidden = true;
25
25
  try {
26
26
  const msg = await getMessage(accountId, uid, false, folderId);
@@ -102,7 +102,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
102
102
  draftFolderId: msg.folderId,
103
103
  };
104
104
  sessionStorage.setItem("composeInit", JSON.stringify(init));
105
- window.open("/compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
105
+ window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
106
106
  };
107
107
  }
108
108
  else {
@@ -12,6 +12,8 @@
12
12
  var _callbacks = {};
13
13
  var _callbackId = 0;
14
14
  var _eventHandlers = [];
15
+ var _ready = false;
16
+ var _pendingCalls = []; // buffered until server sends "ready"
15
17
 
16
18
  function callNode(action, params) {
17
19
  var id = String(++_callbackId);
@@ -22,10 +24,14 @@
22
24
  }, 120000);
23
25
  _callbacks[id] = { resolve: resolve, reject: reject, timer: timer };
24
26
  var msg = Object.assign({ _action: action, _cbid: id }, params || {});
27
+ if (!_ready) {
28
+ // Buffer until server is ready (early calls are lost in the pipe)
29
+ _pendingCalls.push(msg);
30
+ return;
31
+ }
25
32
  if (window.ipc && window.ipc.postMessage) {
26
33
  window.ipc.postMessage(JSON.stringify(msg));
27
34
  } else {
28
- // Fallback: should not happen in WebView
29
35
  clearTimeout(timer);
30
36
  delete _callbacks[id];
31
37
  reject(new Error("No IPC channel available"));
@@ -33,6 +39,16 @@
33
39
  });
34
40
  }
35
41
 
42
+ function flushPending() {
43
+ _ready = true;
44
+ var pending = _pendingCalls.splice(0);
45
+ for (var i = 0; i < pending.length; i++) {
46
+ if (window.ipc && window.ipc.postMessage) {
47
+ window.ipc.postMessage(JSON.stringify(pending[i]));
48
+ }
49
+ }
50
+ }
51
+
36
52
  // Called by Rust to resolve promises
37
53
  window._mailxapiResolve = function(id, value) {
38
54
  var cb = _callbacks[id];
@@ -53,6 +69,11 @@
53
69
 
54
70
  // Called by Rust to push events (new mail, sync progress, etc.)
55
71
  window._mailxapiEvent = function(event) {
72
+ // "ready" signal from server — flush buffered IPC calls
73
+ if (event && event.type === "ready") {
74
+ flushPending();
75
+ return;
76
+ }
56
77
  for (var i = 0; i < _eventHandlers.length; i++) {
57
78
  try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
58
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.156",
3
+ "version": "1.0.157",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -23,7 +23,7 @@
23
23
  "@bobfrankston/iflow": "^1.0.53",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
- "@bobfrankston/msger": "^0.1.206",
26
+ "@bobfrankston/msger": "^0.1.207",
27
27
  "@capacitor/android": "^8.3.0",
28
28
  "@capacitor/cli": "^8.3.0",
29
29
  "@capacitor/core": "^8.3.0",
@@ -79,6 +79,10 @@ export declare class ImapManager extends EventEmitter {
79
79
  /** Sync all folders for all accounts */
80
80
  syncAll(): Promise<void>;
81
81
  private _syncAll;
82
+ /** Sync a single account — manages its own connection lifecycle */
83
+ private syncAccount;
84
+ /** Kill and recreate the persistent ops connection */
85
+ private reconnectOps;
82
86
  /** Handle sync errors — classify and emit appropriate UI events */
83
87
  private handleSyncError;
84
88
  /** Sync just INBOX for each account (fast check for new mail) */
@@ -378,7 +378,10 @@ export class ImapManager extends EventEmitter {
378
378
  this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
379
379
  }
380
380
  this.emit("syncProgress", accountId, "folders", 100);
381
- return this.db.getFolders(accountId);
381
+ // Notify UI that folder structure changed — triggers tree re-render
382
+ const dbFolders = this.db.getFolders(accountId);
383
+ this.emit("folderCountsChanged", accountId, {});
384
+ return dbFolders;
382
385
  }
383
386
  /** Sync messages for a specific folder */
384
387
  async syncFolder(accountId, folderId, client) {
@@ -595,59 +598,86 @@ export class ImapManager extends EventEmitter {
595
598
  }
596
599
  async _syncAll() {
597
600
  const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
598
- // Sync each account sequentiallyone connection each, reused for all operations
599
- for (const [accountId] of this.configs) {
600
- try {
601
- await this.withConnection(accountId, async (client) => {
602
- // Step 1: Get folder list
603
- const t0 = Date.now();
604
- const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
605
- console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
606
- // Step 2: Sync INBOX first (most important)
607
- const inbox = folders.find(f => f.specialUse === "inbox");
608
- if (inbox) {
609
- try {
610
- await this.syncFolder(accountId, inbox.id, client);
611
- }
612
- catch (e) {
613
- console.error(` Inbox sync error for ${accountId}: ${e.message}`);
614
- }
601
+ // Sync all accounts in parallel — each manages its own connection
602
+ const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
603
+ await Promise.allSettled(syncPromises);
604
+ }
605
+ /** Sync a single account — manages its own connection lifecycle */
606
+ async syncAccount(accountId, priorityOrder) {
607
+ try {
608
+ // Step 1: Get folder list (fast <1s typically)
609
+ let client = await this.getOpsClient(accountId);
610
+ const t0 = Date.now();
611
+ const folders = await this.syncFolders(accountId, client);
612
+ console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
613
+ // Step 2: Sync INBOX first
614
+ const inbox = folders.find(f => f.specialUse === "inbox");
615
+ if (inbox) {
616
+ try {
617
+ client = await this.getOpsClient(accountId);
618
+ await this.syncFolder(accountId, inbox.id, client);
619
+ }
620
+ catch (e) {
621
+ console.error(` Inbox sync error for ${accountId}: ${e.message}`);
622
+ await this.reconnectOps(accountId);
623
+ }
624
+ }
625
+ // Step 3: Sync remaining folders
626
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
627
+ remaining.sort((a, b) => {
628
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
629
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
630
+ return pa - pb;
631
+ });
632
+ let consecutiveErrors = 0;
633
+ for (const folder of remaining) {
634
+ const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
635
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
636
+ if (isTrashChild && highestUid === 0)
637
+ continue;
638
+ try {
639
+ client = await this.getOpsClient(accountId);
640
+ await this.syncFolder(accountId, folder.id, client);
641
+ consecutiveErrors = 0;
642
+ }
643
+ catch (e) {
644
+ consecutiveErrors++;
645
+ if (e.responseText?.includes("doesn't exist")) {
646
+ this.db.deleteFolder(folder.id);
615
647
  }
616
- // Step 3: Sync remaining folders in priority order
617
- const remaining = folders.filter(f => f.specialUse !== "inbox");
618
- remaining.sort((a, b) => {
619
- const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
620
- const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
621
- return pa - pb;
622
- });
623
- for (const folder of remaining) {
624
- const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
625
- const highestUid = this.db.getHighestUid(accountId, folder.id);
626
- if (isTrashChild && highestUid === 0)
627
- continue; // defer trash subfolders on first sync
628
- try {
629
- await this.syncFolder(accountId, folder.id, client);
630
- }
631
- catch (e) {
632
- if (e.responseText?.includes("doesn't exist")) {
633
- this.db.deleteFolder(folder.id);
634
- }
635
- else {
636
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
637
- }
638
- }
648
+ else {
649
+ console.error(` Skipping ${folder.path}: ${e.message}`);
650
+ // Connection is probably dead — reconnect
651
+ await this.reconnectOps(accountId);
639
652
  }
640
- });
641
- this.accountErrorShown.delete(accountId);
642
- this.emit("syncComplete", accountId);
653
+ // Too many consecutive errors = connection fundamentally broken
654
+ if (consecutiveErrors >= 3) {
655
+ console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
656
+ break;
657
+ }
658
+ }
643
659
  }
644
- catch (e) {
645
- const errMsg = imapError(e);
646
- this.emit("syncError", accountId, errMsg);
647
- console.error(`Sync error for ${accountId}: ${errMsg}`);
648
- this.handleSyncError(accountId, errMsg);
660
+ this.accountErrorShown.delete(accountId);
661
+ this.emit("syncComplete", accountId);
662
+ }
663
+ catch (e) {
664
+ const errMsg = imapError(e);
665
+ this.emit("syncError", accountId, errMsg);
666
+ console.error(`Sync error for ${accountId}: ${errMsg}`);
667
+ this.handleSyncError(accountId, errMsg);
668
+ }
669
+ }
670
+ /** Kill and recreate the persistent ops connection */
671
+ async reconnectOps(accountId) {
672
+ const old = this.opsClients.get(accountId);
673
+ this.opsClients.delete(accountId);
674
+ if (old) {
675
+ try {
676
+ await (old._realLogout || old.logout)();
649
677
  }
678
+ catch { /* */ }
650
679
  }
680
+ console.log(` [conn] ${accountId}: reconnecting`);
651
681
  }
652
682
  /** Handle sync errors — classify and emit appropriate UI events */
653
683
  handleSyncError(accountId, errMsg) {
@@ -848,19 +878,28 @@ export class ImapManager extends EventEmitter {
848
878
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
849
879
  return this.bodyStore.getMessage(accountId, folderId, uid);
850
880
  }
881
+ // Body fetch uses a fresh connection — never waits behind background sync
882
+ let client = null;
851
883
  try {
852
- return await this.withConnection(accountId, async (client) => {
853
- const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
854
- if (!msg?.source)
855
- return null;
856
- const raw = Buffer.from(msg.source, "utf-8");
857
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
858
- this.db.updateBodyPath(accountId, uid, bodyPath);
859
- return raw;
860
- });
884
+ client = this.newClient(accountId);
885
+ const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
886
+ await client.logout();
887
+ client = null;
888
+ if (!msg?.source)
889
+ return null;
890
+ const raw = Buffer.from(msg.source, "utf-8");
891
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
892
+ this.db.updateBodyPath(accountId, uid, bodyPath);
893
+ return raw;
861
894
  }
862
895
  catch (e) {
863
896
  console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
897
+ if (client) {
898
+ try {
899
+ await client.logout();
900
+ }
901
+ catch { /* */ }
902
+ }
864
903
  return null;
865
904
  }
866
905
  });