@bobfrankston/mailx-imap 0.1.148 → 0.1.150

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
@@ -263,6 +263,15 @@ export declare class ImapManager extends EventEmitter {
263
263
  private noteRateLimited;
264
264
  /** Clear the cooldown after a connection succeeds. */
265
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;
266
275
  private newClient;
267
276
  /** Force-close every IMAP socket for an account — both lane clients
268
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/);
@@ -937,6 +952,60 @@ export class ImapManager extends EventEmitter {
937
952
  this.emit("rateLimitCleared", { accountId });
938
953
  }
939
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
+ }
940
1009
  async newClient(accountId, purpose = "?") {
941
1010
  // Refuse to spend a LOGIN the server has already told us it will not
942
1011
  // accept. Every lane funnels through newClient, so one check covers
@@ -1051,8 +1120,22 @@ export class ImapManager extends EventEmitter {
1051
1120
  // a rate-limit refusal stop ALL of them instead of each
1052
1121
  // discovering it separately, once a minute, forever.
1053
1122
  const em = e?.message || String(e);
1054
- if (/[LIMIT]|rate limit/i.test(em))
1123
+ // NOTE the escaping: [LIMIT] is a literal bracketed
1124
+ // response code. Written unescaped, [LIMIT] is a
1125
+ // CHARACTER CLASS matching any L/I/M/T — which is to
1126
+ // say almost every error message ever produced, so a
1127
+ // stray parse error would pause the whole account for
1128
+ // half an hour (shipped that way in v1.2.240, caught
1129
+ // 2026-08-10 when a connection-cap error tripped it).
1130
+ //
1131
+ // A connection cap belongs here too, deliberately:
1132
+ // Dovecot's "Maximum number of connections from
1133
+ // user+IP exceeded" is the same instruction as a rate
1134
+ // limit — stop opening sockets — and standing down for
1135
+ // a few minutes is what frees the slots.
1136
+ if (/\[LIMIT\]|rate limit|Maximum number of connections|too many connections/i.test(em)) {
1055
1137
  this.noteRateLimited(accountId, em);
1138
+ }
1056
1139
  throw e;
1057
1140
  }
1058
1141
  };
@@ -3864,7 +3947,16 @@ export class ImapManager extends EventEmitter {
3864
3947
  // message in multiple labels gets fetched twice under current
3865
3948
  // grouping. A deeper label-native redesign is a separate TODO.
3866
3949
  const byFolder = new Map();
3950
+ // Rows whose uid is in the IMAP space cannot be addressed by
3951
+ // the API provider at all. Route them to the IMAP fetcher —
3952
+ // the same transport that created them — instead of handing
3953
+ // the API a name it cannot resolve.
3954
+ const imapSpaceRows = [];
3867
3955
  for (const m of missing) {
3956
+ if (m.uid <= IMAP_UID_MAX) {
3957
+ imapSpaceRows.push({ folderId: m.folderId, uid: m.uid });
3958
+ continue;
3959
+ }
3868
3960
  let arr = byFolder.get(m.folderId);
3869
3961
  if (!arr) {
3870
3962
  arr = [];
@@ -3872,6 +3964,14 @@ export class ImapManager extends EventEmitter {
3872
3964
  }
3873
3965
  arr.push(m.uid);
3874
3966
  }
3967
+ if (imapSpaceRows.length > 0) {
3968
+ console.log(` [prefetch] ${accountId}: ${imapSpaceRows.length} row(s) carry IMAP uids on an API account (server-search results) — fetching those over IMAP`);
3969
+ const fetchedOverImap = await this.fetchBodiesOverImap(accountId, imapSpaceRows);
3970
+ if (fetchedOverImap > 0) {
3971
+ madeProgress = true;
3972
+ counters.totalFetched += fetchedOverImap;
3973
+ }
3974
+ }
3875
3975
  const folders = this.db.getFolders(accountId);
3876
3976
  const api = this.getApiProvider(accountId);
3877
3977
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-imap",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -9,8 +9,8 @@
9
9
  },
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
- "@bobfrankston/mailx-types": "^0.1.41",
13
- "@bobfrankston/mailx-settings": "^0.1.50",
12
+ "@bobfrankston/mailx-types": "^0.1.43",
13
+ "@bobfrankston/mailx-settings": "^0.1.51",
14
14
  "@bobfrankston/mailx-store": "^0.1.83",
15
15
  "@bobfrankston/iflow-direct": "^0.1.65",
16
16
  "@bobfrankston/tcp-transport": "^0.1.8",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  ".transformedSnapshot": {
39
39
  "dependencies": {
40
- "@bobfrankston/mailx-types": "^0.1.41",
41
- "@bobfrankston/mailx-settings": "^0.1.50",
40
+ "@bobfrankston/mailx-types": "^0.1.43",
41
+ "@bobfrankston/mailx-settings": "^0.1.51",
42
42
  "@bobfrankston/mailx-store": "^0.1.83",
43
43
  "@bobfrankston/iflow-direct": "^0.1.65",
44
44
  "@bobfrankston/tcp-transport": "^0.1.8",
@@ -59,3 +59,25 @@ for (const lane of ["sync", "quickCheck", "idle", "outbox", "fetch"]) {
59
59
  assert.strictEqual(attempted, 0, "no lane may reach the connect path during a cooldown");
60
60
 
61
61
  console.log("rate-limit backoff: 6 scenarios passed");
62
+
63
+ // 7. THE CLASSIFIER ITSELF. Shipped in v1.2.240 as /[LIMIT]|rate limit/i —
64
+ // an unescaped character class matching any L, I, M or T, i.e. nearly
65
+ // every error string in existence. A stray parse error would have paused
66
+ // the account for up to 30 minutes. These assertions pin the intent:
67
+ // back off when the SERVER says stop, never on an ordinary failure.
68
+ const shouldBackOff = (m) => /\[LIMIT\]|rate limit|Maximum number of connections|too many connections/i.test(m);
69
+ for (const m of [
70
+ "Login failed: [LIMIT] LOGIN Rate limit hit.",
71
+ "Login failed: [UNAVAILABLE] Maximum number of connections from user+IP exceeded (mail_max_userip_connections=20)",
72
+ "429 rate limit exceeded",
73
+ "too many connections",
74
+ ]) assert.ok(shouldBackOff(m), `should back off: ${m}`);
75
+ for (const m of [
76
+ "Login failed: AUTHENTICATE failed.",
77
+ "inactivity timeout",
78
+ "SELECT INBOX failed: [NONEXISTENT] Unknown Mailbox",
79
+ "socket hang up",
80
+ "ETIMEDOUT",
81
+ "simpleParser threw on MIME boundary",
82
+ ]) assert.ok(!shouldBackOff(m), `must NOT pause the account for: ${m}`);
83
+ console.log("rate-limit classifier: 10 messages classified correctly");