@bobfrankston/mailx 1.0.156 → 1.0.158

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,20 @@
33
39
  });
34
40
  }
35
41
 
42
+ function flushPending() {
43
+ _ready = true;
44
+ var pending = _pendingCalls.splice(0);
45
+ // Send diagnostic so it shows in Node.js IPC log
46
+ if (window.ipc && window.ipc.postMessage) {
47
+ window.ipc.postMessage(JSON.stringify({ _action: "_debug", _cbid: "0", info: "flush " + pending.length + " calls: " + pending.map(function(m) { return m._action; }).join(", ") }));
48
+ }
49
+ for (var i = 0; i < pending.length; i++) {
50
+ if (window.ipc && window.ipc.postMessage) {
51
+ window.ipc.postMessage(JSON.stringify(pending[i]));
52
+ }
53
+ }
54
+ }
55
+
36
56
  // Called by Rust to resolve promises
37
57
  window._mailxapiResolve = function(id, value) {
38
58
  var cb = _callbacks[id];
@@ -53,6 +73,11 @@
53
73
 
54
74
  // Called by Rust to push events (new mail, sync progress, etc.)
55
75
  window._mailxapiEvent = function(event) {
76
+ // "ready" signal from server — flush buffered IPC calls
77
+ if (event && event.type === "ready") {
78
+ flushPending();
79
+ return;
80
+ }
56
81
  for (var i = 0; i < _eventHandlers.length; i++) {
57
82
  try { _eventHandlers[i](event); } catch(e) { /* ignore */ }
58
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.156",
3
+ "version": "1.0.158",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -20,10 +20,10 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.53",
23
+ "@bobfrankston/iflow": "^1.0.54",
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.208",
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) {
@@ -391,6 +394,7 @@ export class ImapManager extends EventEmitter {
391
394
  this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
392
395
  // Get the highest UID we already have for this folder
393
396
  const highestUid = this.db.getHighestUid(accountId, folderId);
397
+ console.log(` [sync] ${accountId}/${folder.path}: highestUid=${highestUid}, fetching...`);
394
398
  let messages;
395
399
  const firstSync = highestUid === 0;
396
400
  const historyDays = getHistoryDays(accountId);
@@ -595,59 +599,92 @@ export class ImapManager extends EventEmitter {
595
599
  }
596
600
  async _syncAll() {
597
601
  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
- }
602
+ // Sync all accounts in parallel — each manages its own connection
603
+ const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
604
+ await Promise.allSettled(syncPromises);
605
+ }
606
+ /** Sync a single account — manages its own connection lifecycle */
607
+ async syncAccount(accountId, priorityOrder) {
608
+ try {
609
+ // Step 1: Get folder list (fast <1s typically)
610
+ let client = await this.getOpsClient(accountId);
611
+ const t0 = Date.now();
612
+ const folders = await this.syncFolders(accountId, client);
613
+ console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
614
+ // Step 2: Sync INBOX first
615
+ const inbox = folders.find(f => f.specialUse === "inbox");
616
+ if (inbox) {
617
+ console.log(` [sync] ${accountId}: starting INBOX sync (folder ${inbox.id})`);
618
+ try {
619
+ client = await this.getOpsClient(accountId);
620
+ console.log(` [sync] ${accountId}: got client, calling syncFolder for INBOX`);
621
+ await this.syncFolder(accountId, inbox.id, client);
622
+ console.log(` [sync] ${accountId}: INBOX sync complete`);
623
+ }
624
+ catch (e) {
625
+ console.error(` Inbox sync error for ${accountId}: ${e.message}`);
626
+ await this.reconnectOps(accountId);
627
+ }
628
+ }
629
+ else {
630
+ console.log(` [sync] ${accountId}: no INBOX folder found`);
631
+ }
632
+ // Step 3: Sync remaining folders
633
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
634
+ remaining.sort((a, b) => {
635
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
636
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
637
+ return pa - pb;
638
+ });
639
+ let consecutiveErrors = 0;
640
+ for (const folder of remaining) {
641
+ const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
642
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
643
+ if (isTrashChild && highestUid === 0)
644
+ continue;
645
+ try {
646
+ client = await this.getOpsClient(accountId);
647
+ await this.syncFolder(accountId, folder.id, client);
648
+ consecutiveErrors = 0;
649
+ }
650
+ catch (e) {
651
+ consecutiveErrors++;
652
+ if (e.responseText?.includes("doesn't exist")) {
653
+ this.db.deleteFolder(folder.id);
615
654
  }
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
- }
655
+ else {
656
+ console.error(` Skipping ${folder.path}: ${e.message}`);
657
+ // Connection is probably dead — reconnect
658
+ await this.reconnectOps(accountId);
639
659
  }
640
- });
641
- this.accountErrorShown.delete(accountId);
642
- this.emit("syncComplete", accountId);
660
+ // Too many consecutive errors = connection fundamentally broken
661
+ if (consecutiveErrors >= 3) {
662
+ console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
663
+ break;
664
+ }
665
+ }
643
666
  }
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);
667
+ this.accountErrorShown.delete(accountId);
668
+ this.emit("syncComplete", accountId);
669
+ }
670
+ catch (e) {
671
+ const errMsg = imapError(e);
672
+ this.emit("syncError", accountId, errMsg);
673
+ console.error(`Sync error for ${accountId}: ${errMsg}`);
674
+ this.handleSyncError(accountId, errMsg);
675
+ }
676
+ }
677
+ /** Kill and recreate the persistent ops connection */
678
+ async reconnectOps(accountId) {
679
+ const old = this.opsClients.get(accountId);
680
+ this.opsClients.delete(accountId);
681
+ if (old) {
682
+ try {
683
+ await (old._realLogout || old.logout)();
649
684
  }
685
+ catch { /* */ }
650
686
  }
687
+ console.log(` [conn] ${accountId}: reconnecting`);
651
688
  }
652
689
  /** Handle sync errors — classify and emit appropriate UI events */
653
690
  handleSyncError(accountId, errMsg) {
@@ -848,19 +885,28 @@ export class ImapManager extends EventEmitter {
848
885
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
849
886
  return this.bodyStore.getMessage(accountId, folderId, uid);
850
887
  }
888
+ // Body fetch uses a fresh connection — never waits behind background sync
889
+ let client = null;
851
890
  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
- });
891
+ client = this.newClient(accountId);
892
+ const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
893
+ await client.logout();
894
+ client = null;
895
+ if (!msg?.source)
896
+ return null;
897
+ const raw = Buffer.from(msg.source, "utf-8");
898
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
899
+ this.db.updateBodyPath(accountId, uid, bodyPath);
900
+ return raw;
861
901
  }
862
902
  catch (e) {
863
903
  console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
904
+ if (client) {
905
+ try {
906
+ await client.logout();
907
+ }
908
+ catch { /* */ }
909
+ }
864
910
  return null;
865
911
  }
866
912
  });