@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 +9 -0
- package/index.js +101 -1
- package/package.json +5 -5
- package/test/rate-limit-backoff.test.mjs +22 -0
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
|
-
|
|
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.
|
|
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.
|
|
13
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
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
|
-
"@bobfrankston/mailx-settings": "^0.1.
|
|
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");
|