@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.
- package/package.json +1 -1
- package/packages/mailx-imap/index.d.ts +20 -0
- package/packages/mailx-imap/index.d.ts.map +1 -1
- package/packages/mailx-imap/index.js +64 -1
- package/packages/mailx-imap/index.js.map +1 -1
- package/packages/mailx-imap/index.ts +62 -1
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-imap/test/rate-limit-backoff.test.mjs +61 -0
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-types/package.json +1 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-57024 → node_modules.npmglobalize-stash-54868}/.package-lock.json +0 -0
|
@@ -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.
|
|
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.
|
|
9
|
+
"version": "0.1.148",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bobfrankston/iflow-direct": "^0.1.27",
|
|
@@ -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");
|