@bobfrankston/mailx-imap 0.1.148 → 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.
Files changed (3) hide show
  1. package/index.d.ts +9 -0
  2. package/index.js +86 -0
  3. package/package.json +5 -5
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
@@ -3864,7 +3933,16 @@ export class ImapManager extends EventEmitter {
3864
3933
  // message in multiple labels gets fetched twice under current
3865
3934
  // grouping. A deeper label-native redesign is a separate TODO.
3866
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 = [];
3867
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
+ }
3868
3946
  let arr = byFolder.get(m.folderId);
3869
3947
  if (!arr) {
3870
3948
  arr = [];
@@ -3872,6 +3950,14 @@ export class ImapManager extends EventEmitter {
3872
3950
  }
3873
3951
  arr.push(m.uid);
3874
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
+ }
3875
3961
  const folders = this.db.getFolders(accountId);
3876
3962
  const api = this.getApiProvider(accountId);
3877
3963
  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.149",
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.42",
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.42",
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",