@bobfrankston/mailx 1.0.135 → 1.0.136

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.135",
3
+ "version": "1.0.136",
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,7 +20,7 @@
20
20
  "postinstall": "node launcher/builder/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow": "^1.0.51",
23
+ "@bobfrankston/iflow": "^1.0.52",
24
24
  "@bobfrankston/miscinfo": "^1.0.7",
25
25
  "@bobfrankston/oauthsupport": "^1.0.20",
26
26
  "@bobfrankston/rust-builder": "^0.1.3",
@@ -34,6 +34,9 @@ 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;
37
40
  constructor(db: MailxDB);
38
41
  /** Get OAuth access token for an account (for SMTP auth) */
39
42
  getOAuthToken(accountId: string): Promise<string | null>;
@@ -51,9 +54,16 @@ export declare class ImapManager extends EventEmitter {
51
54
  createPublicClient(accountId: string): any;
52
55
  /** Track active IMAP connections for diagnostics */
53
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. */
62
+ createClientWithLimit(accountId: string): Promise<any>;
54
63
  /** Create a fresh IMAP client for an account (disposable, single-use).
55
64
  * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
56
- * The client's logout() is wrapped to auto-decrement the connection counter. */
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. */
57
67
  private createClient;
58
68
  /** Track client logout for connection counting (called automatically by wrapped logout) */
59
69
  private trackLogout;
@@ -71,6 +71,26 @@ async function extractPreview(source) {
71
71
  return { bodyHtml: "", bodyText: "", preview: "", hasAttachments: false };
72
72
  }
73
73
  }
74
+ /** Race a promise against a timeout. On timeout, forcibly logout the client to prevent hanging. */
75
+ async function withTimeout(promise, ms, client, label) {
76
+ let timer;
77
+ const timeout = new Promise((_, reject) => {
78
+ timer = setTimeout(() => {
79
+ // Force-close the client to unblock the hanging promise
80
+ try {
81
+ client.logout?.();
82
+ }
83
+ catch { /* ignore */ }
84
+ reject(new Error(`${label} timeout (${ms / 1000}s)`));
85
+ }, ms);
86
+ });
87
+ try {
88
+ return await Promise.race([promise, timeout]);
89
+ }
90
+ finally {
91
+ clearTimeout(timer);
92
+ }
93
+ }
74
94
  export class ImapManager extends EventEmitter {
75
95
  configs = new Map();
76
96
  watchers = new Map();
@@ -86,6 +106,9 @@ export class ImapManager extends EventEmitter {
86
106
  useNativeClient = false;
87
107
  /** Accounts hitting connection limits — back off until this time */
88
108
  connectionBackoff = new Map();
109
+ /** Per-account connection semaphore — limits concurrent IMAP connections */
110
+ connectionSemaphore = new Map();
111
+ static MAX_CONNECTIONS = 2; // 1 for sync/fetch, 1 for IDLE
89
112
  constructor(db) {
90
113
  super();
91
114
  this.db = db;
@@ -186,9 +209,68 @@ export class ImapManager extends EventEmitter {
186
209
  // private legacyFallbacks = new Set<string>();
187
210
  /** Track active IMAP connections for diagnostics */
188
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
+ }
238
+ }
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);
252
+ }
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);
259
+ }
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
+ }
269
+ }
189
270
  /** Create a fresh IMAP client for an account (disposable, single-use).
190
271
  * Returns CompatImapClient (native) or ImapClient (imapflow) based on useNativeClient flag.
191
- * The client's logout() is wrapped to auto-decrement the connection counter. */
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. */
192
274
  createClient(accountId) {
193
275
  if (this.reauthenticating.has(accountId))
194
276
  throw new Error(`Account ${accountId} is re-authenticating`);
@@ -201,6 +283,10 @@ export class ImapManager extends EventEmitter {
201
283
  if (!config)
202
284
  throw new Error(`No config for account ${accountId}`);
203
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
+ }
204
290
  this.activeConnections.set(accountId, count);
205
291
  const clientType = this.useNativeClient ? "native" : "imapflow";
206
292
  console.log(` [conn] ${accountId}: +1 ${clientType} (${count} active)`);
@@ -537,11 +623,8 @@ export class ImapManager extends EventEmitter {
537
623
  let client = null;
538
624
  try {
539
625
  const t0 = Date.now();
540
- client = this.createClient(accountId);
541
- const folders = await Promise.race([
542
- this.syncFolders(accountId, client),
543
- new Promise((_, reject) => setTimeout(() => reject(new Error("Folder list timeout (30s)")), 30000))
544
- ]);
626
+ client = await this.createClientWithLimit(accountId);
627
+ const folders = await withTimeout(this.syncFolders(accountId, client), 30000, client, "Folder list");
545
628
  console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
546
629
  // Legacy fallback removed — was doubling connections.
547
630
  // If native client has issues, set useNativeClient=false or use --legacy-imap flag.
@@ -552,11 +635,8 @@ export class ImapManager extends EventEmitter {
552
635
  const inbox = folders.find(f => f.specialUse === "inbox");
553
636
  if (inbox) {
554
637
  try {
555
- client = this.createClient(accountId);
556
- await Promise.race([
557
- this.syncFolder(accountId, inbox.id, client),
558
- new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
559
- ]);
638
+ client = await this.createClientWithLimit(accountId);
639
+ await withTimeout(this.syncFolder(accountId, inbox.id, client), 60000, client, "Inbox sync");
560
640
  await client.logout();
561
641
  client = null;
562
642
  }
@@ -644,7 +724,7 @@ export class ImapManager extends EventEmitter {
644
724
  // Reuse one IMAP connection per account for all folders (avoid 87+ TLS handshakes)
645
725
  let client = null;
646
726
  try {
647
- client = this.createClient(accountId);
727
+ client = await this.createClientWithLimit(accountId);
648
728
  for (const folder of remaining) {
649
729
  // Skip Trash subfolders on first sync — they're large and low priority
650
730
  const isTrashChild = folder.path.includes("/") && folder.path.toLowerCase().startsWith("trash");
@@ -656,10 +736,7 @@ export class ImapManager extends EventEmitter {
656
736
  // Longer timeout for folders we know are large (Trash, first sync)
657
737
  const timeout = highestUid === 0 ? 180000 : 60000;
658
738
  try {
659
- await Promise.race([
660
- this.syncFolder(accountId, folder.id, client),
661
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Sync timeout (${timeout / 1000}s) on ${folder.path}`)), timeout))
662
- ]);
739
+ await withTimeout(this.syncFolder(accountId, folder.id, client), timeout, client, `Sync ${folder.path}`);
663
740
  }
664
741
  catch (e) {
665
742
  if (e.responseText?.includes("doesn't exist")) {
@@ -673,7 +750,7 @@ export class ImapManager extends EventEmitter {
673
750
  await client.logout();
674
751
  }
675
752
  catch { /* */ }
676
- client = this.createClient(accountId);
753
+ client = await this.createClientWithLimit(accountId);
677
754
  }
678
755
  }
679
756
  }
@@ -706,7 +783,7 @@ export class ImapManager extends EventEmitter {
706
783
  // Try up to 2 times with fresh clients
707
784
  for (let attempt = 0; attempt < 2; attempt++) {
708
785
  try {
709
- client = this.createClient(accountId);
786
+ client = await this.createClientWithLimit(accountId);
710
787
  await this.syncFolder(accountId, inbox.id, client);
711
788
  await client.logout();
712
789
  client = null;
@@ -750,13 +827,17 @@ export class ImapManager extends EventEmitter {
750
827
  return;
751
828
  if (this.reauthenticating.has(accountId))
752
829
  return;
830
+ // Skip if at connection limit — don't queue, just skip this cycle
831
+ const sem = this.connectionSemaphore.get(accountId);
832
+ if (sem && sem.active >= ImapManager.MAX_CONNECTIONS)
833
+ return;
753
834
  this.quickCheckRunning.add(accountId);
754
835
  let client = null;
755
836
  try {
756
837
  const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
757
838
  if (!inbox)
758
839
  return;
759
- client = this.createClient(accountId);
840
+ client = await this.createClientWithLimit(accountId);
760
841
  const count = await client.getMessagesCount("INBOX");
761
842
  await client.logout();
762
843
  client = null;
@@ -764,7 +845,7 @@ export class ImapManager extends EventEmitter {
764
845
  this.lastInboxCounts.set(accountId, count);
765
846
  if (count !== prev) {
766
847
  console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
767
- client = this.createClient(accountId);
848
+ client = await this.createClientWithLimit(accountId);
768
849
  await this.syncFolder(accountId, inbox.id, client);
769
850
  await client.logout();
770
851
  client = null;
@@ -839,7 +920,7 @@ export class ImapManager extends EventEmitter {
839
920
  if (this.watchers.has(accountId))
840
921
  continue;
841
922
  try {
842
- const watchClient = this.createClient(accountId);
923
+ const watchClient = await this.createClientWithLimit(accountId);
843
924
  const stop = await watchClient.watchMailbox("INBOX", (newCount) => {
844
925
  console.log(` [idle] ${accountId}: ${newCount} new message(s)`);
845
926
  // Sync just INBOX for speed — full sync runs on the configured interval
@@ -877,10 +958,10 @@ export class ImapManager extends EventEmitter {
877
958
  return next;
878
959
  }
879
960
  /** Get or create a persistent client for body fetching */
880
- getFetchClient(accountId) {
961
+ async getFetchClient(accountId) {
881
962
  let client = this.fetchClients.get(accountId);
882
963
  if (!client) {
883
- client = this.createClient(accountId);
964
+ client = await this.createClientWithLimit(accountId);
884
965
  this.fetchClients.set(accountId, client);
885
966
  }
886
967
  return client;
@@ -904,12 +985,9 @@ export class ImapManager extends EventEmitter {
904
985
  }
905
986
  for (let attempt = 0; attempt < 2; attempt++) {
906
987
  try {
907
- const client = this.getFetchClient(accountId);
988
+ const client = await this.getFetchClient(accountId);
908
989
  // 30s timeout — prevents hanging on stale connections
909
- const msg = await Promise.race([
910
- client.fetchMessageByUid(folder.path, uid, { source: true }),
911
- new Promise((_, reject) => setTimeout(() => reject(new Error("Body fetch timeout (30s)")), 30000))
912
- ]);
990
+ const msg = await withTimeout(client.fetchMessageByUid(folder.path, uid, { source: true }), 30000, client, "Body fetch");
913
991
  if (!msg?.source)
914
992
  return null;
915
993
  const raw = Buffer.from(msg.source, "utf-8");
@@ -1359,7 +1437,7 @@ export class ImapManager extends EventEmitter {
1359
1437
  return;
1360
1438
  try {
1361
1439
  const outboxPath = await this.ensureOutbox(accountId);
1362
- const client = this.createClient(accountId);
1440
+ const client = await this.createClientWithLimit(accountId);
1363
1441
  try {
1364
1442
  for (const file of files) {
1365
1443
  const filePath = path.join(localQueue, file);
@@ -1389,7 +1467,7 @@ export class ImapManager extends EventEmitter {
1389
1467
  const account = settings.accounts.find(a => a.id === accountId);
1390
1468
  if (!account)
1391
1469
  return;
1392
- const client = this.createClient(accountId);
1470
+ const client = await this.createClientWithLimit(accountId);
1393
1471
  try {
1394
1472
  // Get all UIDs in Outbox
1395
1473
  const uids = await client.getUids(outboxFolder.path);