@bobfrankston/mailx-sync 0.1.4 → 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 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
- if (res.status === 403) {
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
- if (/quota exceeded|rate ?limit|user[-_ ]rate/i.test(body))
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
- console.log(` [gmail] ${res.status}${isQuota403 ? " (per-minute quota)" : ""} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
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 — 5 in-flight respects Gmail's PER-MINUTE quota
289
- // (~15k queries/user/min shared across a project; with 10 concurrent we
290
- // hit 403 "Quota exceeded for Queries per minute" on active accounts).
291
- // The fetch() helper now retries 403-quota like 429, but staying under
292
- // the threshold avoids the backoff pause in the first place.
293
- const CONCURRENCY = 5;
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.4",
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/tcp-transport": "^0.1.3",
23
- "@bobfrankston/iflow-direct": "^0.1.20"
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/tcp-transport": "file:../tcp-transport",
40
- "@bobfrankston/iflow-direct": "file:../iflow-direct"
42
+ "@bobfrankston/iflow-direct": "file:../iflow-direct",
43
+ "@bobfrankston/tcp-transport": "file:../tcp-transport"
41
44
  },
42
45
  ".transformedSnapshot": {
43
46
  "dependencies": {
44
- "@bobfrankston/tcp-transport": "^0.1.3",
45
- "@bobfrankston/iflow-direct": "^0.1.20"
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
  }