@bobfrankston/mailx 1.0.155 → 1.0.156

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
@@ -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,14 +899,20 @@ 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
+ // Retry getVersion — first IPC calls may be lost before Rust process is ready
902
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));
903
906
  for (let i = 0; i < 5; i++) {
904
907
  try {
905
- return await getVersion();
908
+ const result = await Promise.race([
909
+ getVersion(),
910
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
911
+ ]);
912
+ return result;
906
913
  }
907
914
  catch {
908
- await new Promise(r => setTimeout(r, 1000));
915
+ await new Promise(r => setTimeout(r, 2000));
909
916
  }
910
917
  }
911
918
  return { version: "?", storage: {} };
@@ -1017,7 +1024,7 @@ function scheduleMiddnightRefresh() {
1017
1024
  }
1018
1025
  scheduleMiddnightRefresh();
1019
1026
  // ── Apply theme from settings ──
1020
- getVersion().then((d) => {
1027
+ versionPromise.then((d) => {
1021
1028
  if (d.theme === "dark")
1022
1029
  document.documentElement.classList.add("theme-dark");
1023
1030
  else if (d.theme === "light")
@@ -73,8 +73,8 @@
73
73
  getUnifiedInbox: function(page, pageSize) {
74
74
  return callNode("getUnifiedInbox", { page: page, pageSize: pageSize });
75
75
  },
76
- getMessage: function(accountId, uid, allowRemote) {
77
- return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote });
76
+ getMessage: function(accountId, uid, allowRemote, folderId) {
77
+ return callNode("getMessage", { accountId: accountId, uid: uid, allowRemote: allowRemote, folderId: folderId });
78
78
  },
79
79
 
80
80
  // 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.156",
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.206",
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,8 @@ export declare class ImapManager extends EventEmitter {
78
79
  /** Sync all folders for all accounts */
79
80
  syncAll(): Promise<void>;
80
81
  private _syncAll;
82
+ /** Handle sync errors — classify and emit appropriate UI events */
83
+ private handleSyncError;
81
84
  /** Sync just INBOX for each account (fast check for new mail) */
82
85
  syncInbox(): Promise<void>;
83
86
  /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
@@ -103,8 +106,6 @@ export declare class ImapManager extends EventEmitter {
103
106
  private fetchQueues;
104
107
  /** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
105
108
  private enqueueFetch;
106
- /** Get or create a persistent client for body fetching */
107
- private getFetchClient;
108
109
  /** Fetch a single message body on demand, caching in the store */
109
110
  fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
110
111
  /** 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 */
@@ -616,157 +594,87 @@ export class ImapManager extends EventEmitter {
616
594
  }
617
595
  }
618
596
  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();
597
+ const priorityOrder = ["sent", "drafts", "archive", "junk", "trash"];
598
+ // Sync each account sequentially one connection each, reused for all operations
622
599
  for (const [accountId] of this.configs) {
623
- let client = null;
624
600
  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;
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
+ }
643
615
  }
644
- catch (e) {
645
- if (client) {
646
- try {
647
- await client.logout();
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}`);
648
637
  }
649
- catch { /* ignore */ }
650
- this.trackLogout(accountId);
651
- client = null;
652
638
  }
653
- console.error(` Inbox sync error for ${accountId}: ${e.message}`);
654
639
  }
655
- }
640
+ });
641
+ this.accountErrorShown.delete(accountId);
642
+ this.emit("syncComplete", accountId);
656
643
  }
657
644
  catch (e) {
658
645
  const errMsg = imapError(e);
659
646
  this.emit("syncError", accountId, errMsg);
660
647
  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);
703
- }
704
- }
705
- finally {
706
- if (client) {
707
- try {
708
- await client.logout();
709
- }
710
- catch { /* ignore */ }
711
- this.trackLogout(accountId);
712
- }
648
+ this.handleSyncError(accountId, errMsg);
713
649
  }
714
650
  }
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
719
- const remaining = folders.filter(f => f.specialUse !== "inbox");
720
- remaining.sort((a, b) => {
721
- const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
722
- const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
723
- return pa - pb;
724
- });
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}`);
741
- }
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
- }
756
- }
757
- }
651
+ }
652
+ /** Handle sync errors classify and emit appropriate UI events */
653
+ handleSyncError(accountId, errMsg) {
654
+ if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
655
+ this.connectionBackoff.set(accountId, Date.now() + 60000);
656
+ }
657
+ const config = this.configs.get(accountId);
658
+ const isOAuth = !!config?.tokenProvider;
659
+ const isTransient = /timeout|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENETUNREACH|Too many/i.test(errMsg);
660
+ const isAuth = /auth|login|credential|token|AUTHENTICATIONFAILED/i.test(errMsg);
661
+ if (isTransient) {
662
+ console.log(` [transient] ${accountId}: ${errMsg} — will retry next cycle`);
663
+ }
664
+ else if (isAuth && isOAuth) {
665
+ const lastReauth = this.lastReauthAttempt.get(accountId) || 0;
666
+ if (Date.now() - lastReauth > 300000 && !this.reauthenticating.has(accountId)) {
667
+ this.lastReauthAttempt.set(accountId, Date.now());
668
+ this.reauthenticate(accountId).catch(() => { });
758
669
  }
759
- finally {
760
- if (client) {
761
- try {
762
- await client.logout();
763
- }
764
- catch { /* */ }
765
- this.trackLogout(accountId);
766
- }
670
+ if (!this.accountErrorShown.has(accountId)) {
671
+ this.accountErrorShown.add(accountId);
672
+ this.emit("accountError", accountId, errMsg, "Authentication expired — re-authenticate", true);
767
673
  }
768
- this.accountErrorShown.delete(accountId);
769
- this.emit("syncComplete", accountId);
674
+ }
675
+ else if (!this.accountErrorShown.has(accountId)) {
676
+ this.accountErrorShown.add(accountId);
677
+ this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
770
678
  }
771
679
  }
772
680
  /** Sync just INBOX for each account (fast check for new mail) */
@@ -776,42 +684,17 @@ export class ImapManager extends EventEmitter {
776
684
  this.inboxSyncing = true;
777
685
  try {
778
686
  for (const [accountId] of this.configs) {
779
- let client = null;
780
687
  try {
781
688
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
782
689
  if (!inbox)
783
690
  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
- }
691
+ await this.withConnection(accountId, async (client) => {
692
+ await this.syncFolder(accountId, inbox.id, client);
693
+ });
804
694
  }
805
695
  catch (e) {
806
696
  console.error(` [inbox] Sync error for ${accountId}: ${e.message}`);
807
697
  }
808
- finally {
809
- if (client)
810
- try {
811
- await client.logout();
812
- }
813
- catch { /* ignore */ }
814
- }
815
698
  }
816
699
  }
817
700
  finally {
@@ -828,40 +711,25 @@ export class ImapManager extends EventEmitter {
828
711
  return;
829
712
  if (this.reauthenticating.has(accountId))
830
713
  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
714
  this.quickCheckRunning.add(accountId);
836
- let client = null;
837
715
  try {
838
716
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
839
717
  if (!inbox)
840
718
  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
- }
719
+ await this.withConnection(accountId, async (client) => {
720
+ const count = await client.getMessagesCount("INBOX");
721
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
722
+ this.lastInboxCounts.set(accountId, count);
723
+ if (count !== prev) {
724
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
725
+ await this.syncFolder(accountId, inbox.id, client);
726
+ }
727
+ });
854
728
  }
855
729
  catch {
856
730
  // Lightweight check — silently ignore errors
857
731
  }
858
732
  finally {
859
- if (client) {
860
- try {
861
- await client.logout();
862
- }
863
- catch { /* ignore */ }
864
- }
865
733
  this.quickCheckRunning.delete(accountId);
866
734
  }
867
735
  }
@@ -923,7 +791,9 @@ export class ImapManager extends EventEmitter {
923
791
  if (this.watchers.has(accountId))
924
792
  continue;
925
793
  try {
926
- const watchClient = await this.createClientWithLimit(accountId);
794
+ // IDLE uses createClient (not createClientWithLimit) — it's a persistent
795
+ // background connection that must NOT consume a semaphore slot
796
+ const watchClient = this.createClient(accountId);
927
797
  const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
928
798
  console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
929
799
  // Sync just INBOX for speed — full sync runs on the configured interval
@@ -960,15 +830,7 @@ export class ImapManager extends EventEmitter {
960
830
  this.fetchQueues.set(accountId, next);
961
831
  return next;
962
832
  }
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
- }
833
+ // Body fetch uses withConnection no separate client needed
972
834
  /** Fetch a single message body on demand, caching in the store */
973
835
  async fetchMessageBody(accountId, folderId, uid) {
974
836
  // Already cached?
@@ -986,33 +848,21 @@ export class ImapManager extends EventEmitter {
986
848
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
987
849
  return this.bodyStore.getMessage(accountId, folderId, uid);
988
850
  }
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");
851
+ try {
852
+ return await this.withConnection(accountId, async (client) => {
853
+ const msg = await client.fetchMessageByUid(folder.path, uid, { source: true });
994
854
  if (!msg?.source)
995
855
  return null;
996
856
  const raw = Buffer.from(msg.source, "utf-8");
997
857
  const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
998
858
  this.db.updateBodyPath(accountId, uid, bodyPath);
999
859
  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 */ }
1010
- }
1011
- if (attempt === 1)
1012
- return null;
1013
- }
860
+ });
861
+ }
862
+ catch (e) {
863
+ console.error(` Body fetch error (${accountId}/${uid}): ${e.message}`);
864
+ return null;
1014
865
  }
1015
- return null;
1016
866
  });
1017
867
  }
1018
868
  /** Get the body store for direct access */
@@ -1148,8 +998,7 @@ export class ImapManager extends EventEmitter {
1148
998
  if (actions.length === 0)
1149
999
  return;
1150
1000
  const folders = this.db.getFolders(accountId);
1151
- const client = await this.createClientWithLimit(accountId);
1152
- try {
1001
+ await this.withConnection(accountId, async (client) => {
1153
1002
  for (const action of actions) {
1154
1003
  const folder = folders.find(f => f.id === action.folderId);
1155
1004
  if (!folder) {
@@ -1202,13 +1051,7 @@ export class ImapManager extends EventEmitter {
1202
1051
  }
1203
1052
  }
1204
1053
  }
1205
- }
1206
- finally {
1207
- try {
1208
- await client.logout();
1209
- }
1210
- catch { /* ignore */ }
1211
- }
1054
+ });
1212
1055
  }
1213
1056
  /** Find a folder by specialUse, case-insensitive */
1214
1057
  findFolder(accountId, specialUse) {
@@ -1731,27 +1574,10 @@ export class ImapManager extends EventEmitter {
1731
1574
  this.stopPeriodicSync();
1732
1575
  this.stopOutboxWorker();
1733
1576
  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;
1577
+ // Disconnect all persistent operational connections
1578
+ for (const [accountId] of this.opsClients) {
1579
+ await this.disconnectOps(accountId);
1752
1580
  }
1753
- this.connectionSemaphore.clear();
1754
- this.activeConnections.clear();
1755
1581
  }
1756
1582
  }
1757
1583
  //# sourceMappingURL=index.js.map