@bobfrankston/mailx 1.0.155 → 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
@@ -292,7 +292,8 @@ async function openCompose(mode) {
292
292
  }
293
293
  // Store init data for compose window to pick up
294
294
  sessionStorage.setItem("composeInit", JSON.stringify(init));
295
- window.open("/compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
295
+ // Use relative URL so it works with both HTTP and custom protocol (msger://)
296
+ window.open("compose/compose.html", "_blank", "width=800,height=600,menubar=no,toolbar=no,status=no");
296
297
  }
297
298
  function quoteBody(msg) {
298
299
  const date = new Date(msg.date).toLocaleString();
@@ -898,19 +899,8 @@ optAutocomplete?.addEventListener("change", () => {
898
899
  }).catch(() => { });
899
900
  });
900
901
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
901
- // Retry getVersion IPC may not be ready on first call
902
- async function getVersionWithRetry() {
903
- for (let i = 0; i < 5; i++) {
904
- try {
905
- return await getVersion();
906
- }
907
- catch {
908
- await new Promise(r => setTimeout(r, 1000));
909
- }
910
- }
911
- return { version: "?", storage: {} };
912
- }
913
- const versionPromise = getVersionWithRetry();
902
+ // Wait for server ready signal, then fetch version
903
+ const versionPromise = getVersion();
914
904
  versionPromise.then((d) => {
915
905
  const el = document.getElementById("app-version");
916
906
  const storage = d.storage || {};
@@ -1017,7 +1007,7 @@ function scheduleMiddnightRefresh() {
1017
1007
  }
1018
1008
  scheduleMiddnightRefresh();
1019
1009
  // ── Apply theme from settings ──
1020
- getVersion().then((d) => {
1010
+ versionPromise.then((d) => {
1021
1011
  if (d.theme === "dark")
1022
1012
  document.documentElement.classList.add("theme-dark");
1023
1013
  else if (d.theme === "light")
@@ -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
  }
@@ -73,8 +94,8 @@
73
94
  getUnifiedInbox: function(page, pageSize) {
74
95
  return callNode("getUnifiedInbox", { page: page, pageSize: pageSize });
75
96
  },
76
- getMessage: function(accountId, uid, allowRemote) {
77
- return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote });
97
+ getMessage: function(accountId, uid, allowRemote, folderId) {
98
+ return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote, folderId: folderId });
78
99
  },
79
100
 
80
101
  // Actions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.155",
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.205",
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",
@@ -34,9 +34,6 @@ export declare class ImapManager extends EventEmitter {
34
34
  useNativeClient: boolean;
35
35
  /** Accounts hitting connection limits — back off until this time */
36
36
  private connectionBackoff;
37
- /** Per-account connection semaphore — limits concurrent IMAP connections */
38
- private connectionSemaphore;
39
- private static MAX_CONNECTIONS;
40
37
  constructor(db: MailxDB);
41
38
  /** Get OAuth access token for an account (for SMTP auth) */
42
39
  getOAuthToken(accountId: string): Promise<string | null>;
@@ -52,20 +49,24 @@ export declare class ImapManager extends EventEmitter {
52
49
  searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
53
50
  /** Create a fresh IMAP client for an account (public access for API endpoints) */
54
51
  createPublicClient(accountId: string): any;
55
- /** Track active IMAP connections for diagnostics */
56
- private activeConnections;
57
- /** Acquire a connection slot. Resolves when a slot is available. */
58
- private acquireConnection;
59
- /** Release a connection slot, unblocking the next waiter. */
60
- private releaseConnection;
61
- /** Create client with semaphore — acquires slot, wraps logout to release it. */
52
+ /** Persistent operational connections — one per account, reused for all operations */
53
+ private opsClients;
54
+ /** Operation queues ensures sequential access per account */
55
+ private opsQueues;
56
+ /** Get (or create) the persistent operational connection for an account.
57
+ * logout() is wrapped as a no-op so legacy callers don't close it. */
58
+ private getOpsClient;
59
+ /** Run an operation on the account's connection — queued, sequential, no concurrency */
60
+ withConnection<T>(accountId: string, fn: (client: any) => Promise<T>): Promise<T>;
61
+ /** Create a new IMAP client (internal — callers use getOpsClient or withConnection) */
62
+ private newClient;
63
+ /** Disconnect the persistent operational connection for an account */
64
+ disconnectOps(accountId: string): Promise<void>;
65
+ /** Legacy API — callers that still create/destroy connections.
66
+ * These return the persistent ops client. logout() is a no-op
67
+ * (the connection stays alive for reuse). */
62
68
  createClientWithLimit(accountId: string): Promise<any>;
63
- /** Create a fresh IMAP client for an account (disposable, single-use).
64
- * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
65
- * The client's logout() is wrapped to auto-decrement the connection counter.
66
- * Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
67
69
  private createClient;
68
- /** Track client logout for connection counting (called automatically by wrapped logout) */
69
70
  private trackLogout;
70
71
  /** Number of registered IMAP accounts */
71
72
  getAccountCount(): number;
@@ -78,6 +79,12 @@ export declare class ImapManager extends EventEmitter {
78
79
  /** Sync all folders for all accounts */
79
80
  syncAll(): Promise<void>;
80
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;
86
+ /** Handle sync errors — classify and emit appropriate UI events */
87
+ private handleSyncError;
81
88
  /** Sync just INBOX for each account (fast check for new mail) */
82
89
  syncInbox(): Promise<void>;
83
90
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
@@ -103,8 +110,6 @@ export declare class ImapManager extends EventEmitter {
103
110
  private fetchQueues;
104
111
  /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
105
112
  private enqueueFetch;
106
- /** Get or create a persistent client for body fetching */
107
- private getFetchClient;
108
113
  /** Fetch a single message body on demand, caching in the store */
109
114
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
110
115
  /** Get the body store for direct access */
@@ -106,9 +106,7 @@ export class ImapManager extends EventEmitter {
106
106
  useNativeClient = false;
107
107
  /** Accounts hitting connection limits — back off until this time */
108
108
  connectionBackoff = new Map();
109
- /** Per-account connection semaphore limits concurrent IMAP connections */
110
- connectionSemaphore = new Map();
111
- static MAX_CONNECTIONS = 2; // 1 for all operations (sequential), 1 for IDLE
109
+ // Connection management: see withConnection() below no semaphore needed
112
110
  constructor(db) {
113
111
  super();
114
112
  this.db = db;
@@ -207,74 +205,74 @@ export class ImapManager extends EventEmitter {
207
205
  // Legacy fallback disabled — was doubling connections without helping.
208
206
  // To re-enable: uncomment legacyFallbacks logic in createClient and _syncAll.
209
207
  // private legacyFallbacks = new Set<string>();
210
- /** Track active IMAP connections for diagnostics */
211
- activeConnections = new Map(); // accountId count
212
- /** Acquire a connection slot. Resolves when a slot is available. */
213
- acquireConnection(accountId) {
214
- let sem = this.connectionSemaphore.get(accountId);
215
- if (!sem) {
216
- sem = { active: 0, waiting: [] };
217
- this.connectionSemaphore.set(accountId, sem);
218
- }
219
- if (sem.active < ImapManager.MAX_CONNECTIONS) {
220
- sem.active++;
221
- return Promise.resolve();
222
- }
223
- // At limit — queue and wait
224
- return new Promise((resolve) => {
225
- sem.waiting.push(() => { sem.active++; resolve(); });
226
- });
227
- }
228
- /** Release a connection slot, unblocking the next waiter. */
229
- releaseConnection(accountId) {
230
- const sem = this.connectionSemaphore.get(accountId);
231
- if (!sem)
232
- return;
233
- sem.active = Math.max(0, sem.active - 1);
234
- if (sem.waiting.length > 0 && sem.active < ImapManager.MAX_CONNECTIONS) {
235
- const next = sem.waiting.shift();
236
- next();
237
- }
208
+ // ── Connection management: one persistent connection per account ──
209
+ // All operations on an account are serialized through an operation queue.
210
+ // No semaphore, no pool, no per-operation connect/disconnect.
211
+ // IDLE uses a separate connection (see startWatching).
212
+ /** Persistent operational connections — one per account, reused for all operations */
213
+ opsClients = new Map();
214
+ /** Operation queues ensures sequential access per account */
215
+ opsQueues = new Map();
216
+ /** Get (or create) the persistent operational connection for an account.
217
+ * logout() is wrapped as a no-op so legacy callers don't close it. */
218
+ async getOpsClient(accountId) {
219
+ let client = this.opsClients.get(accountId);
220
+ if (client)
221
+ return client;
222
+ client = this.newClient(accountId);
223
+ // Wrap logout as no-op — this is a persistent connection
224
+ const realLogout = client.logout.bind(client);
225
+ client.logout = async () => { };
226
+ client._realLogout = realLogout; // stash for actual disconnect
227
+ this.opsClients.set(accountId, client);
228
+ console.log(` [conn] ${accountId}: connected`);
229
+ return client;
238
230
  }
239
- /** Create client with semaphore acquires slot, wraps logout to release it. */
240
- async createClientWithLimit(accountId) {
241
- await this.acquireConnection(accountId);
242
- try {
243
- const client = this.createClient(accountId);
244
- // Wrap logout to also release the semaphore slot
245
- const originalLogout = client.logout;
246
- let released = false;
247
- client.logout = async () => {
248
- await originalLogout.call(client);
249
- if (!released) {
250
- released = true;
251
- this.releaseConnection(accountId);
231
+ /** Run an operation on the account's connection queued, sequential, no concurrency */
232
+ async withConnection(accountId, fn) {
233
+ const prev = this.opsQueues.get(accountId) || Promise.resolve();
234
+ const next = prev.then(async () => {
235
+ try {
236
+ const client = await this.getOpsClient(accountId);
237
+ return await fn(client);
238
+ }
239
+ catch (e) {
240
+ // Connection broken — discard it so next operation reconnects
241
+ const stale = this.opsClients.get(accountId);
242
+ this.opsClients.delete(accountId);
243
+ if (stale) {
244
+ try {
245
+ await stale.logout();
246
+ }
247
+ catch { /* */ }
252
248
  }
253
- };
254
- // Safety: release slot if client is never logged out (leak protection)
255
- const leakRelease = setTimeout(() => {
256
- if (!released) {
257
- released = true;
258
- this.releaseConnection(accountId);
249
+ throw e;
250
+ }
251
+ }, async () => {
252
+ // Previous operation failed — still try this one with a fresh connection
253
+ try {
254
+ const client = await this.getOpsClient(accountId);
255
+ return await fn(client);
256
+ }
257
+ catch (e) {
258
+ const stale = this.opsClients.get(accountId);
259
+ this.opsClients.delete(accountId);
260
+ if (stale) {
261
+ try {
262
+ await stale.logout();
263
+ }
264
+ catch { /* */ }
259
265
  }
260
- }, 310000); // slightly after the 5min leak timer in createClient
261
- if (leakRelease.unref)
262
- leakRelease.unref();
263
- return client;
264
- }
265
- catch (e) {
266
- this.releaseConnection(accountId);
267
- throw e;
268
- }
266
+ throw e;
267
+ }
268
+ });
269
+ this.opsQueues.set(accountId, next.catch(() => { }));
270
+ return next;
269
271
  }
270
- /** Create a fresh IMAP client for an account (disposable, single-use).
271
- * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
272
- * The client's logout() is wrapped to auto-decrement the connection counter.
273
- * Prefer createClientWithLimit() for concurrent operations — it waits for a semaphore slot. */
274
- createClient(accountId) {
272
+ /** Create a new IMAP client (internal callers use getOpsClient or withConnection) */
273
+ newClient(accountId) {
275
274
  if (this.reauthenticating.has(accountId))
276
275
  throw new Error(`Account ${accountId} is re-authenticating`);
277
- // Check connection backoff
278
276
  const backoffUntil = this.connectionBackoff.get(accountId);
279
277
  if (backoffUntil && Date.now() < backoffUntil) {
280
278
  throw new Error(`Account ${accountId} in connection backoff (${Math.round((backoffUntil - Date.now()) / 1000)}s remaining)`);
@@ -282,54 +280,34 @@ export class ImapManager extends EventEmitter {
282
280
  const config = this.configs.get(accountId);
283
281
  if (!config)
284
282
  throw new Error(`No config for account ${accountId}`);
285
- const count = (this.activeConnections.get(accountId) || 0) + 1;
286
- // Hard limit: warn if exceeding max, but still allow (callers should use createClientWithLimit)
287
- if (count > ImapManager.MAX_CONNECTIONS) {
288
- console.warn(` [conn] ${accountId}: WARNING exceeding limit (${count} > ${ImapManager.MAX_CONNECTIONS})`);
289
- }
290
- this.activeConnections.set(accountId, count);
291
- const clientType = this.useNativeClient ? "native" : "imapflow";
292
- console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
293
- let client;
294
283
  if (this.useNativeClient) {
295
- client = new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
284
+ return new CompatImapClient(config, () => new NodeTransport({ rejectUnauthorized: config.rejectUnauthorized !== false }));
296
285
  }
297
- else {
298
- client = new ImapClient(config);
299
- }
300
- // Wrap logout to auto-decrement connection counter (prevents leaks from missed trackLogout calls)
301
- const originalLogout = client.logout.bind(client);
302
- let loggedOut = false;
303
- const doTrackLogout = () => {
304
- if (!loggedOut) {
305
- loggedOut = true;
306
- this.trackLogout(accountId);
307
- }
308
- };
309
- client.logout = async () => {
310
- await originalLogout();
311
- doTrackLogout();
312
- };
313
- // Safety net: if client isn't logged out within 5 minutes, assume it leaked
314
- const leakTimer = setTimeout(() => {
315
- if (!loggedOut) {
316
- console.warn(` [conn] ${accountId}: connection leaked (5min timeout) — forcing decrement`);
317
- doTrackLogout();
286
+ return new ImapClient(config);
287
+ }
288
+ /** Disconnect the persistent operational connection for an account */
289
+ async disconnectOps(accountId) {
290
+ const client = this.opsClients.get(accountId);
291
+ this.opsClients.delete(accountId);
292
+ if (client) {
293
+ try {
294
+ await (client._realLogout || client.logout)();
318
295
  }
319
- }, 300000);
320
- // Clear the timer if logout happens normally
321
- const origDoTrack = doTrackLogout;
322
- // Prevent timer from keeping process alive
323
- if (leakTimer.unref)
324
- leakTimer.unref();
325
- return client;
296
+ catch { /* */ }
297
+ console.log(` [conn] ${accountId}: disconnected`);
298
+ }
326
299
  }
327
- /** Track client logout for connection counting (called automatically by wrapped logout) */
328
- trackLogout(accountId) {
329
- const count = Math.max(0, (this.activeConnections.get(accountId) || 1) - 1);
330
- this.activeConnections.set(accountId, count);
331
- console.log(` [conn] ${accountId}: -1 (${count} active)`);
300
+ /** Legacy API callers that still create/destroy connections.
301
+ * These return the persistent ops client. logout() is a no-op
302
+ * (the connection stays alive for reuse). */
303
+ async createClientWithLimit(accountId) {
304
+ return this.getOpsClient(accountId);
332
305
  }
306
+ createClient(accountId) {
307
+ // Return a fresh disposable client (used by IDLE watcher and one-off operations)
308
+ return this.newClient(accountId);
309
+ }
310
+ trackLogout(_accountId) { }
333
311
  /** Number of registered IMAP accounts */
334
312
  getAccountCount() { return this.configs.size; }
335
313
  /** Register an account */
@@ -400,7 +378,10 @@ export class ImapManager extends EventEmitter {
400
378
  this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
401
379
  }
402
380
  this.emit("syncProgress", accountId, "folders", 100);
403
- 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;
404
385
  }
405
386
  /** Sync messages for a specific folder */
406
387
  async syncFolder(accountId, folderId, client) {
@@ -616,158 +597,115 @@ export class ImapManager extends EventEmitter {
616
597
  }
617
598
  }
618
599
  async _syncAll() {
619
- // Phase 1: Sync folder lists and inboxes for ALL accounts first
620
- // so every account has content visible quickly
621
- const accountFolders = new Map();
622
- for (const [accountId] of this.configs) {
623
- let client = null;
624
- try {
625
- const t0 = Date.now();
626
- client = await this.createClientWithLimit(accountId);
627
- const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
628
- console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
629
- // Legacy fallback removed — was doubling connections.
630
- // If native client has issues, set useNativeClient=false or use --legacy-imap flag.
631
- await client.logout();
632
- client = null;
633
- accountFolders.set(accountId, folders);
634
- // Sync inbox immediately
635
- const inbox = folders.find(f => f.specialUse === "inbox");
636
- if (inbox) {
637
- try {
638
- client = await this.createClientWithLimit(accountId);
639
- const inboxTimeout = this.db.getHighestUid(accountId, inbox.id) === 0 ? 300000 : 60000;
640
- await withTimeout(this.syncFolder(accountId, inbox.id, client), inboxTimeout, client, "Inbox sync");
641
- await client.logout();
642
- client = null;
643
- }
644
- catch (e) {
645
- if (client) {
646
- try {
647
- await client.logout();
648
- }
649
- catch { /* ignore */ }
650
- this.trackLogout(accountId);
651
- client = null;
652
- }
653
- console.error(` Inbox sync error for ${accountId}: ${e.message}`);
654
- }
655
- }
656
- }
657
- catch (e) {
658
- const errMsg = imapError(e);
659
- this.emit("syncError", accountId, errMsg);
660
- console.error(`Sync error for ${accountId}: ${errMsg}`);
661
- const config = this.configs.get(accountId);
662
- const isOAuth = !!config?.tokenProvider;
663
- // Connection limit — back off for 60 seconds
664
- if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
665
- this.connectionBackoff.set(accountId, Date.now() + 60000);
666
- console.log(` [backoff] ${accountId}: connection limit hit, backing off 60s`);
667
- }
668
- // Classify error: transient (timeout, connection) vs auth (credentials, token)
669
- const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
670
- const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
671
- if (isTransient) {
672
- // Transient: just log, will auto-retry on next sync cycle
673
- console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
674
- }
675
- else if (isAuth && isOAuth) {
676
- // OAuth auth error: auto-reauth ONCE, then show banner
677
- const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
678
- const cooldown = Date.now() - lastReauth > 300000; // 5 min cooldown
679
- if (cooldown && !this.reauthenticating.has(accountId)) {
680
- this.lastReauthAttempt.set(accountId, Date.now());
681
- console.log(` [auth] ${accountId}: attempting automatic re-authentication...`);
682
- this.reauthenticate(accountId).then(ok => {
683
- if (!ok && !this.accountErrorShown.has(accountId)) {
684
- this.accountErrorShown.add(accountId);
685
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
686
- }
687
- }).catch(() => {
688
- if (!this.accountErrorShown.has(accountId)) {
689
- this.accountErrorShown.add(accountId);
690
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
691
- }
692
- });
693
- }
694
- else if (!this.accountErrorShown.has(accountId)) {
695
- this.accountErrorShown.add(accountId);
696
- this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
697
- }
698
- }
699
- else if (!this.accountErrorShown.has(accountId)) {
700
- // Non-transient, non-OAuth: show error banner
701
- this.accountErrorShown.add(accountId);
702
- this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
600
+ const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
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);
703
619
  }
704
- }
705
- finally {
706
- if (client) {
707
- try {
708
- await client.logout();
709
- }
710
- catch { /* ignore */ }
711
- this.trackLogout(accountId);
620
+ catch (e) {
621
+ console.error(` Inbox sync error for ${accountId}: ${e.message}`);
622
+ await this.reconnectOps(accountId);
712
623
  }
713
624
  }
714
- }
715
- // Phase 2: Sync remaining folders — priority order, skip Trash subfolders on first sync
716
- const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
717
- for (const [accountId, folders] of accountFolders) {
718
- // Sort: sent/drafts first, then regular, then trash subfolders last
625
+ // Step 3: Sync remaining folders
719
626
  const remaining = folders.filter(f => f.specialUse !== "inbox");
720
627
  remaining.sort((a, b) => {
721
628
  const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
722
629
  const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
723
630
  return pa - pb;
724
631
  });
725
- // Reuse one IMAP connection per account for all folders (avoid 87+ TLS handshakes)
726
- let client = null;
727
- try {
728
- client = await this.createClientWithLimit(accountId);
729
- for (const folder of remaining) {
730
- // Skip Trash subfolders on first sync — they're large and low priority
731
- const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
732
- const highestUid = this.db.getHighestUid(accountId, folder.id);
733
- if (isTrashChild && highestUid === 0) {
734
- console.log(` Deferring first sync of ${folder.path} (Trash subfolder)`);
735
- continue;
736
- }
737
- // Longer timeout for folders we know are large (Trash, first sync)
738
- const timeout = highestUid === 0 ? 180000 : 60000;
739
- try {
740
- await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
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);
741
647
  }
742
- catch (e) {
743
- if (e.responseText?.includes("doesn't exist")) {
744
- console.log(` Removing non-existent folder: ${folder.path}`);
745
- this.db.deleteFolder(folder.id);
746
- }
747
- else {
748
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
749
- // Connection may be broken — reconnect
750
- try {
751
- await client.logout();
752
- }
753
- catch { /* */ }
754
- client = await this.createClientWithLimit(accountId);
755
- }
648
+ else {
649
+ console.error(` Skipping ${folder.path}: ${e.message}`);
650
+ // Connection is probably dead — reconnect
651
+ await this.reconnectOps(accountId);
756
652
  }
757
- }
758
- }
759
- finally {
760
- if (client) {
761
- try {
762
- await client.logout();
653
+ // Too many consecutive errors = connection fundamentally broken
654
+ if (consecutiveErrors >= 3) {
655
+ console.error(` [sync] ${accountId}: ${consecutiveErrors} consecutive errors — aborting sync`);
656
+ break;
763
657
  }
764
- catch { /* */ }
765
- this.trackLogout(accountId);
766
658
  }
767
659
  }
768
660
  this.accountErrorShown.delete(accountId);
769
661
  this.emit("syncComplete", accountId);
770
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)();
677
+ }
678
+ catch { /* */ }
679
+ }
680
+ console.log(` [conn] ${accountId}: reconnecting`);
681
+ }
682
+ /** Handle sync errors — classify and emit appropriate UI events */
683
+ handleSyncError(accountId, errMsg) {
684
+ if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
685
+ this.connectionBackoff.set(accountId, Date.now() + 60000);
686
+ }
687
+ const config = this.configs.get(accountId);
688
+ const isOAuth = !!config?.tokenProvider;
689
+ const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
690
+ const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
691
+ if (isTransient) {
692
+ console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
693
+ }
694
+ else if (isAuth && isOAuth) {
695
+ const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
696
+ if (Date.now() - lastReauth > 300000 && !this.reauthenticating.has(accountId)) {
697
+ this.lastReauthAttempt.set(accountId, Date.now());
698
+ this.reauthenticate(accountId).catch(() => { });
699
+ }
700
+ if (!this.accountErrorShown.has(accountId)) {
701
+ this.accountErrorShown.add(accountId);
702
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
703
+ }
704
+ }
705
+ else if (!this.accountErrorShown.has(accountId)) {
706
+ this.accountErrorShown.add(accountId);
707
+ this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
708
+ }
771
709
  }
772
710
  /** Sync just INBOX for each account (fast check for new mail) */
773
711
  async syncInbox() {
@@ -776,42 +714,17 @@ export class ImapManager extends EventEmitter {
776
714
  this.inboxSyncing = true;
777
715
  try {
778
716
  for (const [accountId] of this.configs) {
779
- let client = null;
780
717
  try {
781
718
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
782
719
  if (!inbox)
783
720
  continue;
784
- // Try up to 2 times with fresh clients
785
- for (let attempt = 0; attempt < 2; attempt++) {
786
- try {
787
- client = await this.createClientWithLimit(accountId);
788
- await this.syncFolder(accountId, inbox.id, client);
789
- await client.logout();
790
- client = null;
791
- break;
792
- }
793
- catch (retryErr) {
794
- if (client)
795
- try {
796
- await client.logout();
797
- }
798
- catch { /* ignore */ }
799
- client = null;
800
- if (attempt === 1)
801
- throw retryErr;
802
- }
803
- }
721
+ await this.withConnection(accountId, async (client) => {
722
+ await this.syncFolder(accountId, inbox.id, client);
723
+ });
804
724
  }
805
725
  catch (e) {
806
726
  console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
807
727
  }
808
- finally {
809
- if (client)
810
- try {
811
- await client.logout();
812
- }
813
- catch { /* ignore */ }
814
- }
815
728
  }
816
729
  }
817
730
  finally {
@@ -828,40 +741,25 @@ export class ImapManager extends EventEmitter {
828
741
  return;
829
742
  if (this.reauthenticating.has(accountId))
830
743
  return;
831
- // Skip if at connection limit — don't queue, just skip this cycle
832
- const sem = this.connectionSemaphore.get(accountId);
833
- if (sem && sem.active >= ImapManager.MAX_CONNECTIONS)
834
- return;
835
744
  this.quickCheckRunning.add(accountId);
836
- let client = null;
837
745
  try {
838
746
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
839
747
  if (!inbox)
840
748
  return;
841
- client = await this.createClientWithLimit(accountId);
842
- const count = await client.getMessagesCount("INBOX");
843
- await client.logout();
844
- client = null;
845
- const prev = this.lastInboxCounts.get(accountId) ?? count;
846
- this.lastInboxCounts.set(accountId, count);
847
- if (count !== prev) {
848
- console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
849
- client = await this.createClientWithLimit(accountId);
850
- await this.syncFolder(accountId, inbox.id, client);
851
- await client.logout();
852
- client = null;
853
- }
749
+ await this.withConnection(accountId, async (client) => {
750
+ const count = await client.getMessagesCount("INBOX");
751
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
752
+ this.lastInboxCounts.set(accountId, count);
753
+ if (count !== prev) {
754
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
755
+ await this.syncFolder(accountId, inbox.id, client);
756
+ }
757
+ });
854
758
  }
855
759
  catch {
856
760
  // Lightweight check — silently ignore errors
857
761
  }
858
762
  finally {
859
- if (client) {
860
- try {
861
- await client.logout();
862
- }
863
- catch { /* ignore */ }
864
- }
865
763
  this.quickCheckRunning.delete(accountId);
866
764
  }
867
765
  }
@@ -923,7 +821,9 @@ export class ImapManager extends EventEmitter {
923
821
  if (this.watchers.has(accountId))
924
822
  continue;
925
823
  try {
926
- const watchClient = await this.createClientWithLimit(accountId);
824
+ // IDLE uses createClient (not createClientWithLimit) — it's a persistent
825
+ // background connection that must NOT consume a semaphore slot
826
+ const watchClient = this.createClient(accountId);
927
827
  const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
928
828
  console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
929
829
  // Sync just INBOX for speed — full sync runs on the configured interval
@@ -960,15 +860,7 @@ export class ImapManager extends EventEmitter {
960
860
  this.fetchQueues.set(accountId, next);
961
861
  return next;
962
862
  }
963
- /** Get or create a persistent client for body fetching */
964
- async getFetchClient(accountId) {
965
- let client = this.fetchClients.get(accountId);
966
- if (!client) {
967
- client = await this.createClientWithLimit(accountId);
968
- this.fetchClients.set(accountId, client);
969
- }
970
- return client;
971
- }
863
+ // Body fetch uses withConnection no separate client needed
972
864
  /** Fetch a single message body on demand, caching in the store */
973
865
  async fetchMessageBody(accountId, folderId, uid) {
974
866
  // Already cached?
@@ -986,33 +878,30 @@ export class ImapManager extends EventEmitter {
986
878
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
987
879
  return this.bodyStore.getMessage(accountId, folderId, uid);
988
880
  }
989
- for (let attempt = 0; attempt < 2; attempt++) {
990
- try {
991
- const client = await this.getFetchClient(accountId);
992
- // 30s timeout — prevents hanging on stale connections
993
- const msg = await withTimeout(client.fetchMessageByUid(folder.path, uid, { source: true }), 30000, client, "Body fetch");
994
- if (!msg?.source)
995
- return null;
996
- const raw = Buffer.from(msg.source, "utf-8");
997
- const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
998
- this.db.updateBodyPath(accountId, uid, bodyPath);
999
- return raw;
1000
- }
1001
- catch (e) {
1002
- console.error(` Body fetch error (${accountId}/${uid} attempt ${attempt + 1}): ${e.message}`);
1003
- const stale = this.fetchClients.get(accountId);
1004
- this.fetchClients.delete(accountId);
1005
- if (stale) {
1006
- try {
1007
- await stale.logout();
1008
- }
1009
- catch { /* ignore */ }
881
+ // Body fetch uses a fresh connection never waits behind background sync
882
+ let client = null;
883
+ try {
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;
894
+ }
895
+ catch (e) {
896
+ console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
897
+ if (client) {
898
+ try {
899
+ await client.logout();
1010
900
  }
1011
- if (attempt === 1)
1012
- return null;
901
+ catch { /* */ }
1013
902
  }
903
+ return null;
1014
904
  }
1015
- return null;
1016
905
  });
1017
906
  }
1018
907
  /** Get the body store for direct access */
@@ -1148,8 +1037,7 @@ export class ImapManager extends EventEmitter {
1148
1037
  if (actions.length === 0)
1149
1038
  return;
1150
1039
  const folders = this.db.getFolders(accountId);
1151
- const client = await this.createClientWithLimit(accountId);
1152
- try {
1040
+ await this.withConnection(accountId, async (client) => {
1153
1041
  for (const action of actions) {
1154
1042
  const folder = folders.find(f => f.id === action.folderId);
1155
1043
  if (!folder) {
@@ -1202,13 +1090,7 @@ export class ImapManager extends EventEmitter {
1202
1090
  }
1203
1091
  }
1204
1092
  }
1205
- }
1206
- finally {
1207
- try {
1208
- await client.logout();
1209
- }
1210
- catch { /* ignore */ }
1211
- }
1093
+ });
1212
1094
  }
1213
1095
  /** Find a folder by specialUse, case-insensitive */
1214
1096
  findFolder(accountId, specialUse) {
@@ -1731,27 +1613,10 @@ export class ImapManager extends EventEmitter {
1731
1613
  this.stopPeriodicSync();
1732
1614
  this.stopOutboxWorker();
1733
1615
  await this.stopWatching();
1734
- // Disconnect all persistent fetch clients
1735
- for (const [, client] of this.fetchClients) {
1736
- try {
1737
- await client.logout();
1738
- }
1739
- catch { /* ignore */ }
1740
- }
1741
- this.fetchClients.clear();
1742
- // Force-release all semaphore slots to unblock any waiting operations
1743
- for (const [accountId, sem] of this.connectionSemaphore) {
1744
- sem.active = 0;
1745
- for (const waiter of sem.waiting) {
1746
- try {
1747
- waiter();
1748
- }
1749
- catch { /* */ }
1750
- }
1751
- sem.waiting.length = 0;
1616
+ // Disconnect all persistent operational connections
1617
+ for (const [accountId] of this.opsClients) {
1618
+ await this.disconnectOps(accountId);
1752
1619
  }
1753
- this.connectionSemaphore.clear();
1754
- this.activeConnections.clear();
1755
1620
  }
1756
1621
  }
1757
1622
  //# sourceMappingURL=index.js.map