@bobfrankston/mailx-imap 0.1.140 → 0.1.144

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.
Files changed (3) hide show
  1. package/index.d.ts +17 -0
  2. package/index.js +91 -3
  3. package/package.json +5 -5
package/index.d.ts CHANGED
@@ -60,6 +60,23 @@ export declare class ImapManager extends EventEmitter {
60
60
  private configs;
61
61
  private watchers;
62
62
  private watcherClients;
63
+ /** Accounts whose IDLE watcher is mid-establish. `watchers` only gains an
64
+ * entry AFTER connect+login+SELECT+IDLE completes (seconds), so two
65
+ * overlapping startWatching() sweeps both see "no watcher" and both open
66
+ * a socket — a duplicate IDLE connection per account. Bob 2026-08-05:
67
+ * boot's startWatching was still in flight when the 60s deadman fired,
68
+ * giving two watchers each for bobma/gmail and pushing aol into
69
+ * "[LIMIT] LOGIN Rate limit hit". This set closes that window. */
70
+ private watchStarting;
71
+ /** Accounts whose IDLE watcher was refused for a CREDENTIAL reason, with
72
+ * the error text. Retrying those every 60s forever is pure waste: the
73
+ * outlook account has failed `AUTHENTICATE failed.` on every boot for
74
+ * weeks (its mail syncs over Graph, so nothing is lost — but each attempt
75
+ * burns a connection slot and buries the log). Suppressed until the
76
+ * account re-authenticates, which clears the entry. Transient refusals
77
+ * (rate limits, network) are NOT recorded here — they keep the normal
78
+ * 60s deadman cadence. */
79
+ private watchAuthFailed;
63
80
  private fetchClients;
64
81
  /** The Store is the architectural nexus — owner of MailxDB +
65
82
  * FileMessageStore + the event bus. This package (mailx-imap) is a
package/index.js CHANGED
@@ -202,6 +202,23 @@ export class ImapManager extends EventEmitter {
202
202
  // (folder syncs holding all permits, timing out at 360s), leaving new mail
203
203
  // on 5-min polling for minutes at a time (Bob 2026-05-28).
204
204
  watcherClients = new Map();
205
+ /** Accounts whose IDLE watcher is mid-establish. `watchers` only gains an
206
+ * entry AFTER connect+login+SELECT+IDLE completes (seconds), so two
207
+ * overlapping startWatching() sweeps both see "no watcher" and both open
208
+ * a socket — a duplicate IDLE connection per account. Bob 2026-08-05:
209
+ * boot's startWatching was still in flight when the 60s deadman fired,
210
+ * giving two watchers each for bobma/gmail and pushing aol into
211
+ * "[LIMIT] LOGIN Rate limit hit". This set closes that window. */
212
+ watchStarting = new Set();
213
+ /** Accounts whose IDLE watcher was refused for a CREDENTIAL reason, with
214
+ * the error text. Retrying those every 60s forever is pure waste: the
215
+ * outlook account has failed `AUTHENTICATE failed.` on every boot for
216
+ * weeks (its mail syncs over Graph, so nothing is lost — but each attempt
217
+ * burns a connection slot and buries the log). Suppressed until the
218
+ * account re-authenticates, which clears the entry. Transient refusals
219
+ * (rate limits, network) are NOT recorded here — they keep the normal
220
+ * 60s deadman cadence. */
221
+ watchAuthFailed = new Map();
205
222
  fetchClients = new Map();
206
223
  /** The Store is the architectural nexus — owner of MailxDB +
207
224
  * FileMessageStore + the event bus. This package (mailx-imap) is a
@@ -363,6 +380,9 @@ export class ImapManager extends EventEmitter {
363
380
  if (config?.tokenProvider) {
364
381
  console.log(` [reauth] ${accountId}: success`);
365
382
  this.accountErrorShown.delete(accountId);
383
+ // Fresh credentials — let IDLE try again on an account whose
384
+ // watcher was suppressed for an auth failure.
385
+ this.watchAuthFailed.delete(accountId);
366
386
  this.syncInbox().catch(() => { });
367
387
  return true;
368
388
  }
@@ -895,6 +915,15 @@ export class ImapManager extends EventEmitter {
895
915
  const skipSemaphore = purpose === "idle";
896
916
  const releaseHostSlot = skipSemaphore ? (() => { }) : await this.acquireHostSlot(host);
897
917
  let client;
918
+ // Forward declaration: the connect wrapper below is DEFINED here but
919
+ // only CALLED after newClient returns, by which time the real
920
+ // markClosed is installed. A connect that throws (aol "[LIMIT] LOGIN
921
+ // Rate limit hit", outlook "AUTHENTICATE failed") used to leave the
922
+ // client registered in openClients forever AND never release its host
923
+ // semaphore permit — the counter climbed to 141 over a 37-hour session
924
+ // (Bob 2026-08-05 shutdown log) and every leaked permit is one fewer
925
+ // concurrent connection that account can ever open again.
926
+ let markClosed = () => { };
898
927
  try {
899
928
  // Verbose IMAP wire trace — diagnostic for silently-hanging
900
929
  // commands. It was left permanently ON for the ops/fast lanes, but
@@ -946,10 +975,21 @@ export class ImapManager extends EventEmitter {
946
975
  const total = Date.now() - connT0;
947
976
  if (total > 3000)
948
977
  console.log(` [conn-slow] ${accountId} (${purpose}): connect took ${total}ms — ${phaseTimings()}`);
978
+ // A socket that dies on its own — server drop, TLS
979
+ // reset, idle timeout — never routes through
980
+ // logout()/destroy(), so without this the connection
981
+ // stays "open" in our books and its semaphore permit
982
+ // is gone for the life of the process. Node always
983
+ // emits 'close' after 'error', so one listener covers
984
+ // both.
985
+ const sock = client?.native?.transport?.socket;
986
+ if (typeof sock?.once === "function")
987
+ sock.once("close", () => markClosed("socket-close"));
949
988
  return r;
950
989
  }
951
990
  catch (e) {
952
991
  emitPhase("failed", e?.message || String(e));
992
+ markClosed("connect-failed");
953
993
  throw e;
954
994
  }
955
995
  };
@@ -967,10 +1007,17 @@ export class ImapManager extends EventEmitter {
967
1007
  open.add(client);
968
1008
  console.log(` [conn+] ${accountId} (${purpose}) — ${open.size} open`);
969
1009
  let closed = false;
970
- const markClosed = (how) => {
1010
+ markClosed = (how) => {
971
1011
  if (closed)
972
1012
  return;
973
1013
  closed = true;
1014
+ // Flag the client itself so the IDLE watcher health-check (which
1015
+ // inspects `_dead` alongside the raw socket state) re-establishes
1016
+ // a watcher whose socket died under it.
1017
+ try {
1018
+ client._dead = true;
1019
+ }
1020
+ catch { /* frozen client — the socket checks still catch it */ }
974
1021
  open.delete(client);
975
1022
  releaseHostSlot();
976
1023
  console.log(` [conn-] ${accountId} (${purpose}/${how}) — ${open.size} open`);
@@ -3101,15 +3148,28 @@ export class ImapManager extends EventEmitter {
3101
3148
  // accounts with healthy IDLE are skipped). One log line per
3102
3149
  // detected gap so a chronically failing account is obvious in
3103
3150
  // the log.
3151
+ //
3152
+ // startWatching() sweeps EVERY account, so it must be called at most
3153
+ // once per tick: the old per-account call meant N missing accounts →
3154
+ // N concurrent sweeps, and since `watchers` is only populated after
3155
+ // the connect completes, every sweep opened its own socket for every
3156
+ // account (Bob 2026-08-05 boot: two IDLE watchers each for bobma and
3157
+ // gmail, aol refused with "[LIMIT] LOGIN Rate limit hit").
3104
3158
  const deadmanInterval = setInterval(() => {
3159
+ const missing = [];
3105
3160
  for (const [accountId] of this.configs) {
3106
3161
  if (this.isApiAccount(accountId))
3107
3162
  continue; // Gmail/Outlook = API, no IDLE
3108
3163
  if (this.watchers.has(accountId))
3109
3164
  continue;
3110
- console.log(` [idle-deadman] ${accountId}: no IDLE watcher — attempting restart`);
3111
- this.startWatching().catch(e => console.error(` [idle-deadman] ${accountId} restart failed: ${e?.message || e}`));
3165
+ if (this.watchStarting.has(accountId))
3166
+ continue; // first attempt still connecting
3167
+ missing.push(accountId);
3112
3168
  }
3169
+ if (missing.length === 0)
3170
+ return;
3171
+ console.log(` [idle-deadman] no IDLE watcher for ${missing.join(", ")} — attempting restart`);
3172
+ this.startWatching().catch(e => console.error(` [idle-deadman] restart failed: ${e?.message || e}`));
3113
3173
  }, 60_000);
3114
3174
  this.syncIntervals.set("idle-deadman", deadmanInterval);
3115
3175
  // Sync actions (sends + flags/deletes/moves) every 30 seconds — skip during active sync
@@ -3206,6 +3266,20 @@ export class ImapManager extends EventEmitter {
3206
3266
  this.watchers.delete(accountId);
3207
3267
  this.watcherClients.delete(accountId);
3208
3268
  }
3269
+ // Another sweep is already connecting this account. `watchers`
3270
+ // stays empty for the whole connect+login+SELECT+IDLE round trip,
3271
+ // so without this the second sweep opens a duplicate IDLE socket
3272
+ // (and on connection-limited servers the duplicate is what gets
3273
+ // refused). See the watchStarting field comment.
3274
+ if (this.watchStarting.has(accountId)) {
3275
+ console.log(` [idle] ${accountId}: watcher already being established — skipping duplicate`);
3276
+ continue;
3277
+ }
3278
+ // Credentials were refused last time. Don't re-offer them every
3279
+ // 60s — wait for a re-auth to clear this. See watchAuthFailed.
3280
+ if (this.watchAuthFailed.has(accountId))
3281
+ continue;
3282
+ this.watchStarting.add(accountId);
3209
3283
  try {
3210
3284
  // IDLE keeps its own dedicated socket — once the connection
3211
3285
  // is parked in IDLE, it's unusable for any other command, so
@@ -3305,6 +3379,20 @@ export class ImapManager extends EventEmitter {
3305
3379
  ? parts.join(" ")
3306
3380
  : `<no message> ${typeof e} ${e?.constructor?.name || ""}`.trim();
3307
3381
  console.error(` [idle] Failed to watch ${accountId}: ${desc}`);
3382
+ // Separate "these credentials are wrong" from "the server is
3383
+ // busy right now". A rate limit ([LIMIT] LOGIN Rate limit hit
3384
+ // — aol does this when we over-connect) says nothing about the
3385
+ // credentials and must stay retryable, so it is checked FIRST
3386
+ // even though its text also contains "Login failed".
3387
+ const transient = /\[LIMIT\]|rate limit|too many|try again|timed? ?out|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket/i.test(desc);
3388
+ const authFailed = !transient && /authenticat|login failed|AUTHENTICATIONFAILED|invalid credential|password|\[AUTH/i.test(desc);
3389
+ if (authFailed) {
3390
+ this.watchAuthFailed.set(accountId, desc);
3391
+ console.error(` [idle] ${accountId}: credentials refused — suppressing IDLE retries until this account re-authenticates`);
3392
+ }
3393
+ }
3394
+ finally {
3395
+ this.watchStarting.delete(accountId);
3308
3396
  }
3309
3397
  }
3310
3398
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.140",
3
+ "version": "0.1.144",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -11,8 +11,8 @@
11
11
  "dependencies": {
12
12
  "@bobfrankston/mailx-types": "^0.1.38",
13
13
  "@bobfrankston/mailx-settings": "^0.1.48",
14
- "@bobfrankston/mailx-store": "^0.1.79",
15
- "@bobfrankston/iflow-direct": "^0.1.63",
14
+ "@bobfrankston/mailx-store": "^0.1.80",
15
+ "@bobfrankston/iflow-direct": "^0.1.65",
16
16
  "@bobfrankston/tcp-transport": "^0.1.8",
17
17
  "@bobfrankston/smtp-direct": "^0.1.9",
18
18
  "@bobfrankston/mailx-sync": "^0.1.29",
@@ -39,8 +39,8 @@
39
39
  "dependencies": {
40
40
  "@bobfrankston/mailx-types": "^0.1.38",
41
41
  "@bobfrankston/mailx-settings": "^0.1.48",
42
- "@bobfrankston/mailx-store": "^0.1.79",
43
- "@bobfrankston/iflow-direct": "^0.1.63",
42
+ "@bobfrankston/mailx-store": "^0.1.80",
43
+ "@bobfrankston/iflow-direct": "^0.1.65",
44
44
  "@bobfrankston/tcp-transport": "^0.1.8",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",
46
46
  "@bobfrankston/mailx-sync": "^0.1.29",