@bobfrankston/mailx-sync 0.1.3 → 0.1.5
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/gmail.d.ts +9 -0
- package/gmail.js +100 -9
- package/package.json +10 -7
- package/types.d.ts +5 -0
package/gmail.d.ts
CHANGED
|
@@ -15,6 +15,9 @@ import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from
|
|
|
15
15
|
export declare class GmailApiProvider implements MailProvider {
|
|
16
16
|
private tokenProvider;
|
|
17
17
|
constructor(tokenProvider: () => Promise<string>);
|
|
18
|
+
/** Block until (a) cooldown has elapsed and (b) a token is available.
|
|
19
|
+
* Token-bucket refill happens lazily on each call. */
|
|
20
|
+
private acquireToken;
|
|
18
21
|
private fetch;
|
|
19
22
|
listFolders(): Promise<ProviderFolder[]>;
|
|
20
23
|
/** List message IDs matching a query, handling pagination.
|
|
@@ -42,6 +45,12 @@ export declare class GmailApiProvider implements MailProvider {
|
|
|
42
45
|
* fetches of multi-labeled messages. */
|
|
43
46
|
fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
|
|
44
47
|
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
48
|
+
/** Apply the absolute flag state to a message.
|
|
49
|
+
* Gmail model: flags are labels. `\Seen` is the *absence* of UNREAD;
|
|
50
|
+
* `\Flagged` is the presence of STARRED. We always send both add and
|
|
51
|
+
* remove so the end state matches regardless of what was there before,
|
|
52
|
+
* which makes the call idempotent and safe to retry. */
|
|
53
|
+
setFlags(folder: string, uid: number, flags: string[]): Promise<void>;
|
|
45
54
|
getUids(folder: string): Promise<number[]>;
|
|
46
55
|
close(): Promise<void>;
|
|
47
56
|
/** Map folder path to Gmail label query term */
|
package/gmail.js
CHANGED
|
@@ -47,11 +47,58 @@ function parseAddressList(raw) {
|
|
|
47
47
|
// Split on commas that aren't inside quotes
|
|
48
48
|
return raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(s => parseAddress(s.trim())).filter(a => a.address);
|
|
49
49
|
}
|
|
50
|
+
/** Rate-limit + cooldown state shared across all GmailApiProvider instances.
|
|
51
|
+
* Gmail enforces rate limits per Google user, not per JS object, so sharing
|
|
52
|
+
* state here prevents new `new GmailApiProvider(...)` from side-stepping the
|
|
53
|
+
* cooldown by creating a fresh instance. Single-account setups won't notice;
|
|
54
|
+
* multi-account setups are slightly over-throttled, which is safer than
|
|
55
|
+
* burning through Google's tolerance. */
|
|
56
|
+
const rateState = {
|
|
57
|
+
tokens: 0,
|
|
58
|
+
lastRefill: 0,
|
|
59
|
+
cooldownUntil: 0,
|
|
60
|
+
/** Tokens refilled per second (keeps us well under per-user-per-sec caps). */
|
|
61
|
+
rate: 8,
|
|
62
|
+
/** Max burst above steady state. */
|
|
63
|
+
burst: 8,
|
|
64
|
+
};
|
|
65
|
+
rateState.tokens = rateState.burst;
|
|
66
|
+
rateState.lastRefill = Date.now();
|
|
67
|
+
/** Extract Google's structured "reason" from a JSON error body when possible
|
|
68
|
+
* (e.g. `userRateLimitExceeded`, `rateLimitExceeded`, `quotaExceeded`). */
|
|
69
|
+
function parseGoogleReason(body) {
|
|
70
|
+
const m = body.match(/"reason"\s*:\s*"([^"]+)"/);
|
|
71
|
+
return m ? m[1] : "";
|
|
72
|
+
}
|
|
50
73
|
export class GmailApiProvider {
|
|
51
74
|
tokenProvider;
|
|
52
75
|
constructor(tokenProvider) {
|
|
53
76
|
this.tokenProvider = tokenProvider;
|
|
54
77
|
}
|
|
78
|
+
/** Block until (a) cooldown has elapsed and (b) a token is available.
|
|
79
|
+
* Token-bucket refill happens lazily on each call. */
|
|
80
|
+
async acquireToken() {
|
|
81
|
+
// Respect an active cooldown first — refuse to even try until it ends.
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (rateState.cooldownUntil > now) {
|
|
84
|
+
const waitMs = rateState.cooldownUntil - now;
|
|
85
|
+
console.log(` [gmail] cooldown: waiting ${(waitMs / 1000).toFixed(1)}s before next request`);
|
|
86
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
87
|
+
}
|
|
88
|
+
// Lazy refill, then spin-wait in chunks until a token is ready.
|
|
89
|
+
while (true) {
|
|
90
|
+
const t = Date.now();
|
|
91
|
+
const elapsedSec = (t - rateState.lastRefill) / 1000;
|
|
92
|
+
rateState.tokens = Math.min(rateState.burst, rateState.tokens + elapsedSec * rateState.rate);
|
|
93
|
+
rateState.lastRefill = t;
|
|
94
|
+
if (rateState.tokens >= 1) {
|
|
95
|
+
rateState.tokens -= 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const waitMs = Math.max(20, ((1 - rateState.tokens) / rateState.rate) * 1000);
|
|
99
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
55
102
|
async fetch(path, options = {}) {
|
|
56
103
|
const token = await this.tokenProvider();
|
|
57
104
|
const maxAttempts = 6;
|
|
@@ -59,6 +106,7 @@ export class GmailApiProvider {
|
|
|
59
106
|
const maxDelayMs = 60_000;
|
|
60
107
|
let lastStatus = 0;
|
|
61
108
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
109
|
+
await this.acquireToken();
|
|
62
110
|
const res = await globalThis.fetch(`${API}${path}`, {
|
|
63
111
|
...options,
|
|
64
112
|
headers: {
|
|
@@ -70,11 +118,13 @@ export class GmailApiProvider {
|
|
|
70
118
|
// 403 with "Quota exceeded" body is Google's per-minute quota limit.
|
|
71
119
|
// Not 429. Retry like a rate-limit, don't throw as a permanent failure.
|
|
72
120
|
let isQuota403 = false;
|
|
73
|
-
|
|
121
|
+
let reason = "";
|
|
122
|
+
if (res.status === 403 || res.status === 429) {
|
|
74
123
|
try {
|
|
75
124
|
const cloned = res.clone();
|
|
76
125
|
const body = await cloned.text();
|
|
77
|
-
|
|
126
|
+
reason = parseGoogleReason(body);
|
|
127
|
+
if (res.status === 403 && /quota exceeded|rate ?limit|user[-_ ]rate/i.test(body))
|
|
78
128
|
isQuota403 = true;
|
|
79
129
|
}
|
|
80
130
|
catch { /* ignore */ }
|
|
@@ -101,7 +151,13 @@ export class GmailApiProvider {
|
|
|
101
151
|
// Full jitter to avoid synchronized retries
|
|
102
152
|
delay = Math.min(maxDelayMs, delay);
|
|
103
153
|
delay = Math.floor(delay * (0.5 + Math.random() * 0.5));
|
|
104
|
-
|
|
154
|
+
// Install a shared cooldown so *other* in-flight workers wait too,
|
|
155
|
+
// not just this one. Prevents the common failure mode where 5
|
|
156
|
+
// parallel workers each retry independently and keep the
|
|
157
|
+
// per-user QPS pegged above the threshold.
|
|
158
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + delay);
|
|
159
|
+
const reasonTag = reason ? ` reason=${reason}` : (isQuota403 ? " (per-minute quota)" : "");
|
|
160
|
+
console.log(` [gmail] ${res.status}${reasonTag} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
|
|
105
161
|
await new Promise(r => setTimeout(r, delay));
|
|
106
162
|
continue;
|
|
107
163
|
}
|
|
@@ -111,6 +167,14 @@ export class GmailApiProvider {
|
|
|
111
167
|
}
|
|
112
168
|
return res.json();
|
|
113
169
|
}
|
|
170
|
+
// Exhausted all retries — Google is clearly unhappy. Park every
|
|
171
|
+
// subsequent request behind a long cooldown so the next periodic sync
|
|
172
|
+
// (30s) doesn't immediately fire another 6 retries into the same
|
|
173
|
+
// limit. 5 minutes is a floor, not a ceiling — an incoming Retry-After
|
|
174
|
+
// that's longer will still win via the per-attempt cooldown set above.
|
|
175
|
+
const TERMINAL_COOLDOWN_MS = 5 * 60_000;
|
|
176
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + TERMINAL_COOLDOWN_MS);
|
|
177
|
+
console.log(` [gmail] exhausted ${maxAttempts} retries — parking further requests for ${TERMINAL_COOLDOWN_MS / 1000}s`);
|
|
114
178
|
throw new Error(`Gmail API: failed after ${maxAttempts} retries (last status ${lastStatus})`);
|
|
115
179
|
}
|
|
116
180
|
async listFolders() {
|
|
@@ -285,12 +349,12 @@ export class GmailApiProvider {
|
|
|
285
349
|
}
|
|
286
350
|
if (wanted.length === 0)
|
|
287
351
|
return;
|
|
288
|
-
// Bounded concurrency —
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
const CONCURRENCY =
|
|
352
|
+
// Bounded concurrency — 2 in-flight, combined with the shared token
|
|
353
|
+
// bucket in `fetch()` (8 req/sec steady) keeps us comfortably below
|
|
354
|
+
// Gmail's per-user-per-second cap. Earlier 5-wide prefetch kept Google
|
|
355
|
+
// in sustained-abuse territory for hours, which triggered cooldowns
|
|
356
|
+
// that rejected even 1-unit calls like labels.list on fresh startup.
|
|
357
|
+
const CONCURRENCY = 2;
|
|
294
358
|
let cursor = 0;
|
|
295
359
|
const worker = async () => {
|
|
296
360
|
while (cursor < wanted.length) {
|
|
@@ -329,6 +393,33 @@ export class GmailApiProvider {
|
|
|
329
393
|
const msg = await this.fetch(`/messages/${id}?${params}`);
|
|
330
394
|
return this.parseMessage(msg, options);
|
|
331
395
|
}
|
|
396
|
+
/** Apply the absolute flag state to a message.
|
|
397
|
+
* Gmail model: flags are labels. `\Seen` is the *absence* of UNREAD;
|
|
398
|
+
* `\Flagged` is the presence of STARRED. We always send both add and
|
|
399
|
+
* remove so the end state matches regardless of what was there before,
|
|
400
|
+
* which makes the call idempotent and safe to retry. */
|
|
401
|
+
async setFlags(folder, uid, flags) {
|
|
402
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
403
|
+
const ids = await this.listMessageIds(query, 1000);
|
|
404
|
+
const id = ids.find(id => idToUid(id) === uid);
|
|
405
|
+
if (!id)
|
|
406
|
+
throw new Error(`Gmail setFlags: UID ${uid} not found in ${folder}`);
|
|
407
|
+
const flagSet = new Set(flags);
|
|
408
|
+
const addLabelIds = [];
|
|
409
|
+
const removeLabelIds = [];
|
|
410
|
+
if (flagSet.has("\\Flagged"))
|
|
411
|
+
addLabelIds.push("STARRED");
|
|
412
|
+
else
|
|
413
|
+
removeLabelIds.push("STARRED");
|
|
414
|
+
if (flagSet.has("\\Seen"))
|
|
415
|
+
removeLabelIds.push("UNREAD");
|
|
416
|
+
else
|
|
417
|
+
addLabelIds.push("UNREAD");
|
|
418
|
+
await this.fetch(`/messages/${id}/modify`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
body: JSON.stringify({ addLabelIds, removeLabelIds }),
|
|
421
|
+
});
|
|
422
|
+
}
|
|
332
423
|
async getUids(folder) {
|
|
333
424
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
334
425
|
const ids = await this.listMessageIds(query, 10000);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Platform-agnostic mail provider implementations + sync orchestration. Single source of truth for Gmail/IMAP/Outlook protocol code, consumed by both desktop (Node) and Android (WebView) — eliminates the parallel mailx-imap/mailx-store-web Gmail providers that drifted in practice.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.ts",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"author": "Bob Frankston",
|
|
20
20
|
"license": "ISC",
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@bobfrankston/
|
|
23
|
-
"@bobfrankston/
|
|
22
|
+
"@bobfrankston/iflow-direct": "^0.1.22",
|
|
23
|
+
"@bobfrankston/tcp-transport": "^0.1.4"
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
26
|
".": {
|
|
@@ -35,14 +35,17 @@
|
|
|
35
35
|
"type": "git",
|
|
36
36
|
"url": "https://github.com/BobFrankston/mailx-sync.git"
|
|
37
37
|
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.6.0"
|
|
40
|
+
},
|
|
38
41
|
".dependencies": {
|
|
39
|
-
"@bobfrankston/
|
|
40
|
-
"@bobfrankston/
|
|
42
|
+
"@bobfrankston/iflow-direct": "file:../iflow-direct",
|
|
43
|
+
"@bobfrankston/tcp-transport": "file:../tcp-transport"
|
|
41
44
|
},
|
|
42
45
|
".transformedSnapshot": {
|
|
43
46
|
"dependencies": {
|
|
44
|
-
"@bobfrankston/
|
|
45
|
-
"@bobfrankston/
|
|
47
|
+
"@bobfrankston/iflow-direct": "^0.1.22",
|
|
48
|
+
"@bobfrankston/tcp-transport": "^0.1.4"
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
}
|
package/types.d.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface MailProvider {
|
|
|
60
60
|
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
61
61
|
/** Get all UIDs in a folder (for reconciliation) */
|
|
62
62
|
getUids(folder: string): Promise<number[]>;
|
|
63
|
+
/** Replace the full flag set on a message (idempotent). The provider is
|
|
64
|
+
* responsible for translating IMAP flags like "\\Seen" / "\\Flagged" to
|
|
65
|
+
* its native model — e.g. Gmail's UNREAD / STARRED labels.
|
|
66
|
+
* Optional: IMAP uses the existing STORE path in sync-manager code. */
|
|
67
|
+
setFlags?(folder: string, uid: number, flags: string[]): Promise<void>;
|
|
63
68
|
/** Close/cleanup */
|
|
64
69
|
close(): Promise<void>;
|
|
65
70
|
}
|