@bobfrankston/rmfmail 1.2.239 → 1.2.240

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.
@@ -282,6 +282,18 @@ export class ImapManager extends EventEmitter {
282
282
  * (rate limits, network) are NOT recorded here — they keep the normal
283
283
  * 60s deadman cadence. */
284
284
  private watchAuthFailed: Map<string, string> = new Map();
285
+ /** Accounts the SERVER has told us to back off, with when they may be
286
+ * tried again and how many consecutive refusals we've had.
287
+ *
288
+ * AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit
289
+ * hit.` — an explicit instruction, not a transient blip. mailx used to
290
+ * treat it as transient and keep the 60 s deadman cadence, so every
291
+ * refusal was followed by another LOGIN a minute later: measured on
292
+ * 2026-08-09, four separate runs of 4–22 minutes, one failed attempt per
293
+ * minute throughout, every one of them spending quota that keeps the
294
+ * limiter hot. Backing off is not politeness, it is how the account
295
+ * becomes usable again sooner. */
296
+ private rateLimited: Map<string, { until: number; strikes: number }> = new Map();
285
297
  private fetchClients: Map<string, any> = new Map();
286
298
  /** The Store is the architectural nexus — owner of MailxDB +
287
299
  * FileMessageStore + the event bus. This package (mailx-imap) is a
@@ -340,7 +352,7 @@ export class ImapManager extends EventEmitter {
340
352
  if (m) d.lastCommand = m[0].slice(0, 120);
341
353
  } else if (/UNAVAILABLE|Maximum number of connections|too many connections/i.test(errMsg)) {
342
354
  d.connCapHits++;
343
- } else if (/429|rate limit/i.test(errMsg)) {
355
+ } else if (/429|rate limit|\[LIMIT\]/i.test(errMsg)) {
344
356
  d.rateLimitWaits++;
345
357
  } else {
346
358
  return; // not a known diagnostic class — don't emit
@@ -903,7 +915,46 @@ export class ImapManager extends EventEmitter {
903
915
  * client; the slot is released when logout() or destroy() runs.
904
916
  * `purpose` is a short tag printed alongside the `[conn+]` log so we can
905
917
  * tell which code path (ops/idle/etc.) opened each connection. */
918
+ /** Remaining cooldown in ms for an account the server rate-limited, or 0. */
919
+ rateLimitRemaining(accountId: string): number {
920
+ const e = this.rateLimited.get(accountId);
921
+ if (!e) return 0;
922
+ const left = e.until - Date.now();
923
+ if (left <= 0) { this.rateLimited.delete(accountId); return 0; }
924
+ return left;
925
+ }
926
+
927
+ /** Record a server rate-limit refusal and widen the cooldown. 5 → 10 → 20
928
+ * → 30 min (capped): long enough to actually clear AOL's window, short
929
+ * enough that a mistake costs one poll cycle rather than an afternoon. */
930
+ private noteRateLimited(accountId: string, errMsg: string): void {
931
+ const prev = this.rateLimited.get(accountId);
932
+ const strikes = (prev?.strikes || 0) + 1;
933
+ const mins = Math.min(30, 5 * Math.pow(2, strikes - 1));
934
+ const until = Date.now() + mins * 60_000;
935
+ this.rateLimited.set(accountId, { until, strikes });
936
+ console.warn(` [rate-limit] ${accountId}: server said "${errMsg.trim().slice(0, 60)}" — pausing ALL lanes for ${mins} min (strike ${strikes})`);
937
+ this.emit("rateLimited", { accountId, untilMs: until, minutes: mins, strikes });
938
+ }
939
+
940
+ /** Clear the cooldown after a connection succeeds. */
941
+ private noteRateLimitCleared(accountId: string): void {
942
+ if (this.rateLimited.delete(accountId)) {
943
+ console.log(` [rate-limit] ${accountId}: connection accepted again — cooldown cleared`);
944
+ this.emit("rateLimitCleared", { accountId });
945
+ }
946
+ }
947
+
906
948
  private async newClient(accountId: string, purpose = "?"): Promise<any> {
949
+ // Refuse to spend a LOGIN the server has already told us it will not
950
+ // accept. Every lane funnels through newClient, so one check covers
951
+ // sync, quickCheck, IDLE, outbox and fetch alike — which matters,
952
+ // because the lanes retry independently and would otherwise take turns
953
+ // poking the limiter.
954
+ const cooldown = this.rateLimitRemaining(accountId);
955
+ if (cooldown > 0) {
956
+ throw new Error(`rate-limited by server — retrying in ${Math.ceil(cooldown / 60_000)} min (no LOGIN attempted)`);
957
+ }
907
958
  if (this.reauthenticating.has(accountId)) throw new Error(`Account ${accountId} is re-authenticating`);
908
959
  const config = this.configs.get(accountId);
909
960
  if (!config) throw new Error(`No config for account ${accountId}`);
@@ -991,10 +1042,20 @@ export class ImapManager extends EventEmitter {
991
1042
  // both.
992
1043
  const sock = client?.native?.transport?.socket;
993
1044
  if (typeof sock?.once === "function") sock.once("close", () => markClosed("socket-close"));
1045
+ // A LOGIN the server accepted is the only proof the
1046
+ // cooldown is over — clear it here rather than letting
1047
+ // it lapse on a timer alone.
1048
+ this.noteRateLimitCleared(accountId);
994
1049
  return r;
995
1050
  } catch (e: any) {
996
1051
  emitPhase("failed", e?.message || String(e));
997
1052
  markClosed("connect-failed");
1053
+ // Every lane (sync, quickCheck, IDLE, outbox, fetch)
1054
+ // connects through here, so this one line is what makes
1055
+ // a rate-limit refusal stop ALL of them instead of each
1056
+ // discovering it separately, once a minute, forever.
1057
+ const em = e?.message || String(e);
1058
+ if (/[LIMIT]|rate limit/i.test(em)) this.noteRateLimited(accountId, em);
998
1059
  throw e;
999
1060
  }
1000
1061
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.147",
3
+ "version": "0.1.148",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bobfrankston/mailx-imap",
9
- "version": "0.1.147",
9
+ "version": "0.1.148",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@bobfrankston/iflow-direct": "^0.1.27",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.147",
3
+ "version": "0.1.148",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,61 @@
1
+ /**
2
+ * AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit hit.`
3
+ * Before v1.2.240 every lane treated that as transient and retried on its own
4
+ * 60 s cadence, so a refusal was followed by another LOGIN a minute later for
5
+ * as long as it lasted (measured 2026-08-09: four runs, up to 22 minutes,
6
+ * ~one wasted LOGIN per minute).
7
+ *
8
+ * These checks drive the real ImapManager methods over the cooldown state.
9
+ * Run: node packages/mailx-imap/test/rate-limit-backoff.test.mjs
10
+ */
11
+ import assert from "node:assert";
12
+
13
+ const { ImapManager } = await import("file:///Y:/dev/email/mailx/app/packages/mailx-imap/index.js");
14
+
15
+ const mgr = Object.create(ImapManager.prototype);
16
+ mgr.rateLimited = new Map();
17
+ const events = [];
18
+ mgr.emit = (name, payload) => { events.push({ name, payload }); };
19
+
20
+ const LIMIT = "Login failed: [LIMIT] LOGIN Rate limit hit.";
21
+
22
+ // 1. A refusal starts a cooldown, and it is reported (not swallowed).
23
+ assert.strictEqual(mgr.rateLimitRemaining("aol"), 0, "clean account has no cooldown");
24
+ mgr.noteRateLimited("aol", LIMIT);
25
+ const first = mgr.rateLimitRemaining("aol");
26
+ assert.ok(first > 4 * 60_000 && first <= 5 * 60_000, `first cooldown should be ~5 min, got ${Math.round(first / 1000)}s`);
27
+ assert.ok(events.some(e => e.name === "rateLimited" && e.payload.accountId === "aol"),
28
+ "a cooldown must be announced — a silently paused account looks broken");
29
+
30
+ // 2. Repeated refusals widen it: 5 → 10 → 20 → 30 (capped).
31
+ const mins = [];
32
+ for (let i = 0; i < 4; i++) { mgr.noteRateLimited("aol", LIMIT); mins.push(Math.round(mgr.rateLimitRemaining("aol") / 60_000)); }
33
+ assert.deepStrictEqual(mins, [10, 20, 30, 30], `backoff should widen and cap at 30, got ${mins.join(",")}`);
34
+
35
+ // 3. Other accounts are untouched — the limit is per-account, not global.
36
+ assert.strictEqual(mgr.rateLimitRemaining("bobma"), 0, "one account's cooldown must not pause another");
37
+
38
+ // 4. A successful connection clears it, and says so.
39
+ mgr.noteRateLimitCleared("aol");
40
+ assert.strictEqual(mgr.rateLimitRemaining("aol"), 0, "success must clear the cooldown");
41
+ assert.ok(events.some(e => e.name === "rateLimitCleared"), "recovery must be announced too");
42
+
43
+ // 5. An expired cooldown lapses on its own (no success needed).
44
+ mgr.rateLimited.set("aol", { until: Date.now() - 1, strikes: 3 });
45
+ assert.strictEqual(mgr.rateLimitRemaining("aol"), 0, "a lapsed cooldown must not block forever");
46
+ assert.ok(!mgr.rateLimited.has("aol"), "lapsed entry should be dropped, not left to accumulate");
47
+
48
+ // 6. THE POINT: while cooling down, newClient must refuse to spend a LOGIN —
49
+ // for every lane, since each one used to discover the limit separately.
50
+ mgr.rateLimited.set("aol", { until: Date.now() + 5 * 60_000, strikes: 1 });
51
+ let attempted = 0;
52
+ mgr.acquireHostSlot = async () => { attempted++; return () => {}; }; // reached only if the guard lets it through
53
+ for (const lane of ["sync", "quickCheck", "idle", "outbox", "fetch"]) {
54
+ await assert.rejects(
55
+ () => ImapManager.prototype.newClient.call(mgr, "aol", lane),
56
+ /rate-limited by server/,
57
+ `lane "${lane}" must be refused while the account is cooling down`);
58
+ }
59
+ assert.strictEqual(attempted, 0, "no lane may reach the connect path during a cooldown");
60
+
61
+ console.log("rate-limit backoff: 6 scenarios passed");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-settings",
3
- "version": "0.1.50",
3
+ "version": "0.1.51",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-types",
3
- "version": "0.1.41",
3
+ "version": "0.1.42",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",