@bobfrankston/mailx-imap 0.1.147 → 0.1.149

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/index.d.ts CHANGED
@@ -77,6 +77,18 @@ export declare class ImapManager extends EventEmitter {
77
77
  * (rate limits, network) are NOT recorded here — they keep the normal
78
78
  * 60s deadman cadence. */
79
79
  private watchAuthFailed;
80
+ /** Accounts the SERVER has told us to back off, with when they may be
81
+ * tried again and how many consecutive refusals we've had.
82
+ *
83
+ * AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit
84
+ * hit.` — an explicit instruction, not a transient blip. mailx used to
85
+ * treat it as transient and keep the 60 s deadman cadence, so every
86
+ * refusal was followed by another LOGIN a minute later: measured on
87
+ * 2026-08-09, four separate runs of 4–22 minutes, one failed attempt per
88
+ * minute throughout, every one of them spending quota that keeps the
89
+ * limiter hot. Backing off is not politeness, it is how the account
90
+ * becomes usable again sooner. */
91
+ private rateLimited;
80
92
  private fetchClients;
81
93
  /** The Store is the architectural nexus — owner of MailxDB +
82
94
  * FileMessageStore + the event bus. This package (mailx-imap) is a
@@ -243,6 +255,23 @@ export declare class ImapManager extends EventEmitter {
243
255
  * client; the slot is released when logout() or destroy() runs.
244
256
  * `purpose` is a short tag printed alongside the `[conn+]` log so we can
245
257
  * tell which code path (ops/idle/etc.) opened each connection. */
258
+ /** Remaining cooldown in ms for an account the server rate-limited, or 0. */
259
+ rateLimitRemaining(accountId: string): number;
260
+ /** Record a server rate-limit refusal and widen the cooldown. 5 → 10 → 20
261
+ * → 30 min (capped): long enough to actually clear AOL's window, short
262
+ * enough that a mistake costs one poll cycle rather than an afternoon. */
263
+ private noteRateLimited;
264
+ /** Clear the cooldown after a connection succeeds. */
265
+ private noteRateLimitCleared;
266
+ /**
267
+ * Fetch bodies for rows that carry IMAP uids on an account whose normal
268
+ * body path is a REST API (Gmail today). Uses the account's IMAP
269
+ * connection — the same one the server search used to find them.
270
+ *
271
+ * Returns how many bodies landed. Failures are logged per row and do not
272
+ * abort the rest: one unreadable message must not stall the queue.
273
+ */
274
+ private fetchBodiesOverImap;
246
275
  private newClient;
247
276
  /** Force-close every IMAP socket for an account — both lane clients
248
277
  * (ops + fast) plus any lingering ones in openClients (e.g. an IDLE
package/index.js CHANGED
@@ -27,6 +27,21 @@ const OUTBOX_RETRY_DELAY_MS = 60000;
27
27
  * after boot always runs one (the map is empty); thereafter it's throttled to
28
28
  * this interval so the QRESYNC speedup is preserved in steady state. */
29
29
  const RECONCILE_THROTTLE_MS = 15 * 60 * 1000;
30
+ /**
31
+ * An IMAP UID is a 32-bit unsigned integer (RFC 3501 §2.3.1.1). The API
32
+ * providers mint their uid from the provider's own message id — Gmail uses
33
+ * `parseInt(id.slice(-12), 16)`, a 48-bit value — so the two uid spaces are
34
+ * disjoint and this bound tells them apart exactly, not heuristically.
35
+ *
36
+ * It matters because ONE ACCOUNT can hold rows from both: a Gmail account
37
+ * syncs bodies through the API, but a server search runs over IMAP and
38
+ * stores rows with IMAP UIDs. Those rows are unfetchable by the API path —
39
+ * it maps uid→id by listing the label and hashing each id, and an IMAP uid
40
+ * is not in that map at any depth. The body request simply returned nothing,
41
+ * forever, with the viewer counting seconds (Bob 2026-08-09: "why the
42
+ * forever fetch?" / "why did gmail search find them if they can't be read?").
43
+ */
44
+ const IMAP_UID_MAX = 0xFFFFFFFF;
30
45
  /** Parse X-Mailx-Retry* tracking headers from a raw RFC822 message. */
31
46
  function parseRetryInfo(raw) {
32
47
  const headerEnd = raw.search(/\r?\n\r?\n/);
@@ -219,6 +234,18 @@ export class ImapManager extends EventEmitter {
219
234
  * (rate limits, network) are NOT recorded here — they keep the normal
220
235
  * 60s deadman cadence. */
221
236
  watchAuthFailed = new Map();
237
+ /** Accounts the SERVER has told us to back off, with when they may be
238
+ * tried again and how many consecutive refusals we've had.
239
+ *
240
+ * AOL/Yahoo answer an over-quota LOGIN with `[LIMIT] LOGIN Rate limit
241
+ * hit.` — an explicit instruction, not a transient blip. mailx used to
242
+ * treat it as transient and keep the 60 s deadman cadence, so every
243
+ * refusal was followed by another LOGIN a minute later: measured on
244
+ * 2026-08-09, four separate runs of 4–22 minutes, one failed attempt per
245
+ * minute throughout, every one of them spending quota that keeps the
246
+ * limiter hot. Backing off is not politeness, it is how the account
247
+ * becomes usable again sooner. */
248
+ rateLimited = new Map();
222
249
  fetchClients = new Map();
223
250
  /** The Store is the architectural nexus — owner of MailxDB +
224
251
  * FileMessageStore + the event bus. This package (mailx-imap) is a
@@ -277,7 +304,7 @@ export class ImapManager extends EventEmitter {
277
304
  else if (/UNAVAILABLE|Maximum number of connections|too many connections/i.test(errMsg)) {
278
305
  d.connCapHits++;
279
306
  }
280
- else if (/429|rate limit/i.test(errMsg)) {
307
+ else if (/429|rate limit|\[LIMIT\]/i.test(errMsg)) {
281
308
  d.rateLimitWaits++;
282
309
  }
283
310
  else {
@@ -894,7 +921,101 @@ export class ImapManager extends EventEmitter {
894
921
  * client; the slot is released when logout() or destroy() runs.
895
922
  * `purpose` is a short tag printed alongside the `[conn+]` log so we can
896
923
  * tell which code path (ops/idle/etc.) opened each connection. */
924
+ /** Remaining cooldown in ms for an account the server rate-limited, or 0. */
925
+ rateLimitRemaining(accountId) {
926
+ const e = this.rateLimited.get(accountId);
927
+ if (!e)
928
+ return 0;
929
+ const left = e.until - Date.now();
930
+ if (left <= 0) {
931
+ this.rateLimited.delete(accountId);
932
+ return 0;
933
+ }
934
+ return left;
935
+ }
936
+ /** Record a server rate-limit refusal and widen the cooldown. 5 → 10 → 20
937
+ * → 30 min (capped): long enough to actually clear AOL's window, short
938
+ * enough that a mistake costs one poll cycle rather than an afternoon. */
939
+ noteRateLimited(accountId, errMsg) {
940
+ const prev = this.rateLimited.get(accountId);
941
+ const strikes = (prev?.strikes || 0) + 1;
942
+ const mins = Math.min(30, 5 * Math.pow(2, strikes - 1));
943
+ const until = Date.now() + mins * 60_000;
944
+ this.rateLimited.set(accountId, { until, strikes });
945
+ console.warn(` [rate-limit] ${accountId}: server said "${errMsg.trim().slice(0, 60)}" — pausing ALL lanes for ${mins} min (strike ${strikes})`);
946
+ this.emit("rateLimited", { accountId, untilMs: until, minutes: mins, strikes });
947
+ }
948
+ /** Clear the cooldown after a connection succeeds. */
949
+ noteRateLimitCleared(accountId) {
950
+ if (this.rateLimited.delete(accountId)) {
951
+ console.log(` [rate-limit] ${accountId}: connection accepted again — cooldown cleared`);
952
+ this.emit("rateLimitCleared", { accountId });
953
+ }
954
+ }
955
+ /**
956
+ * Fetch bodies for rows that carry IMAP uids on an account whose normal
957
+ * body path is a REST API (Gmail today). Uses the account's IMAP
958
+ * connection — the same one the server search used to find them.
959
+ *
960
+ * Returns how many bodies landed. Failures are logged per row and do not
961
+ * abort the rest: one unreadable message must not stall the queue.
962
+ */
963
+ async fetchBodiesOverImap(accountId, rows) {
964
+ const folders = this.db.getFolders(accountId);
965
+ let fetched = 0;
966
+ let client = null;
967
+ try {
968
+ client = await this.newClient(accountId, "fetch");
969
+ await client.connect?.();
970
+ for (const row of rows) {
971
+ const folder = folders.find(f => f.id === row.folderId);
972
+ if (!folder)
973
+ continue;
974
+ try {
975
+ const msg = await client.fetchMessageByUid(folder.path, row.uid, { source: true });
976
+ const source = msg?.source;
977
+ if (!source) {
978
+ // Not on the server either — stop asking for it every
979
+ // sweep, but say so once rather than silently.
980
+ console.log(` [prefetch] ${accountId}/${folder.path} uid ${row.uid}: not on the server over IMAP either — marking empty`);
981
+ this.markPrefetchEmpty(accountId, row.folderId, row.uid);
982
+ continue;
983
+ }
984
+ const raw = Buffer.from(source, "utf-8");
985
+ const bodyPath = await this.bodyStore.putMessage(accountId, row.folderId, row.uid, raw);
986
+ const parsed = await extractPreview(source);
987
+ this.db.updateBodyMeta(accountId, row.folderId, row.uid, bodyPath, parsed.hasAttachments, parsed.preview);
988
+ this.emit("bodyCached", accountId, row.uid);
989
+ fetched++;
990
+ }
991
+ catch (e) {
992
+ console.error(` [prefetch] ${accountId}/${folder.path} uid ${row.uid}: IMAP body fetch failed: ${e?.message || e}`);
993
+ }
994
+ }
995
+ }
996
+ catch (e) {
997
+ console.error(` [prefetch] ${accountId}: IMAP fallback could not open a connection: ${e?.message || e}`);
998
+ }
999
+ finally {
1000
+ try {
1001
+ await client?.logout?.();
1002
+ }
1003
+ catch { /* socket already gone */ }
1004
+ }
1005
+ if (fetched > 0)
1006
+ console.log(` [prefetch] ${accountId}: ${fetched} body(ies) fetched over IMAP for API-account rows`);
1007
+ return fetched;
1008
+ }
897
1009
  async newClient(accountId, purpose = "?") {
1010
+ // Refuse to spend a LOGIN the server has already told us it will not
1011
+ // accept. Every lane funnels through newClient, so one check covers
1012
+ // sync, quickCheck, IDLE, outbox and fetch alike — which matters,
1013
+ // because the lanes retry independently and would otherwise take turns
1014
+ // poking the limiter.
1015
+ const cooldown = this.rateLimitRemaining(accountId);
1016
+ if (cooldown > 0) {
1017
+ throw new Error(`rate-limited by server — retrying in ${Math.ceil(cooldown / 60_000)} min (no LOGIN attempted)`);
1018
+ }
898
1019
  if (this.reauthenticating.has(accountId))
899
1020
  throw new Error(`Account ${accountId} is re-authenticating`);
900
1021
  const config = this.configs.get(accountId);
@@ -985,11 +1106,22 @@ export class ImapManager extends EventEmitter {
985
1106
  const sock = client?.native?.transport?.socket;
986
1107
  if (typeof sock?.once === "function")
987
1108
  sock.once("close", () => markClosed("socket-close"));
1109
+ // A LOGIN the server accepted is the only proof the
1110
+ // cooldown is over — clear it here rather than letting
1111
+ // it lapse on a timer alone.
1112
+ this.noteRateLimitCleared(accountId);
988
1113
  return r;
989
1114
  }
990
1115
  catch (e) {
991
1116
  emitPhase("failed", e?.message || String(e));
992
1117
  markClosed("connect-failed");
1118
+ // Every lane (sync, quickCheck, IDLE, outbox, fetch)
1119
+ // connects through here, so this one line is what makes
1120
+ // a rate-limit refusal stop ALL of them instead of each
1121
+ // discovering it separately, once a minute, forever.
1122
+ const em = e?.message || String(e);
1123
+ if (/[LIMIT]|rate limit/i.test(em))
1124
+ this.noteRateLimited(accountId, em);
993
1125
  throw e;
994
1126
  }
995
1127
  };
@@ -3801,7 +3933,16 @@ export class ImapManager extends EventEmitter {
3801
3933
  // message in multiple labels gets fetched twice under current
3802
3934
  // grouping. A deeper label-native redesign is a separate TODO.
3803
3935
  const byFolder = new Map();
3936
+ // Rows whose uid is in the IMAP space cannot be addressed by
3937
+ // the API provider at all. Route them to the IMAP fetcher —
3938
+ // the same transport that created them — instead of handing
3939
+ // the API a name it cannot resolve.
3940
+ const imapSpaceRows = [];
3804
3941
  for (const m of missing) {
3942
+ if (m.uid <= IMAP_UID_MAX) {
3943
+ imapSpaceRows.push({ folderId: m.folderId, uid: m.uid });
3944
+ continue;
3945
+ }
3805
3946
  let arr = byFolder.get(m.folderId);
3806
3947
  if (!arr) {
3807
3948
  arr = [];
@@ -3809,6 +3950,14 @@ export class ImapManager extends EventEmitter {
3809
3950
  }
3810
3951
  arr.push(m.uid);
3811
3952
  }
3953
+ if (imapSpaceRows.length > 0) {
3954
+ console.log(` [prefetch] ${accountId}: ${imapSpaceRows.length} row(s) carry IMAP uids on an API account (server-search results) — fetching those over IMAP`);
3955
+ const fetchedOverImap = await this.fetchBodiesOverImap(accountId, imapSpaceRows);
3956
+ if (fetchedOverImap > 0) {
3957
+ madeProgress = true;
3958
+ counters.totalFetched += fetchedOverImap;
3959
+ }
3960
+ }
3812
3961
  const folders = this.db.getFolders(accountId);
3813
3962
  const api = this.getApiProvider(accountId);
3814
3963
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.147",
3
+ "version": "0.1.149",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,9 +9,9 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.39",
13
- "@bobfrankston/mailx-settings": "^0.1.48",
14
- "@bobfrankston/mailx-store": "^0.1.82",
12
+ "@bobfrankston/mailx-types": "^0.1.42",
13
+ "@bobfrankston/mailx-settings": "^0.1.51",
14
+ "@bobfrankston/mailx-store": "^0.1.83",
15
15
  "@bobfrankston/iflow-direct": "^0.1.65",
16
16
  "@bobfrankston/tcp-transport": "^0.1.8",
17
17
  "@bobfrankston/smtp-direct": "^0.1.9",
@@ -37,9 +37,9 @@
37
37
  },
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
- "@bobfrankston/mailx-types": "^0.1.39",
41
- "@bobfrankston/mailx-settings": "^0.1.48",
42
- "@bobfrankston/mailx-store": "^0.1.82",
40
+ "@bobfrankston/mailx-types": "^0.1.42",
41
+ "@bobfrankston/mailx-settings": "^0.1.51",
42
+ "@bobfrankston/mailx-store": "^0.1.83",
43
43
  "@bobfrankston/iflow-direct": "^0.1.65",
44
44
  "@bobfrankston/tcp-transport": "^0.1.8",
45
45
  "@bobfrankston/smtp-direct": "^0.1.9",
@@ -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");