@bobfrankston/mailx-sync 0.1.9 → 0.1.11

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/gmail.d.ts +10 -2
  2. package/gmail.js +128 -11
  3. package/package.json +3 -13
package/gmail.d.ts CHANGED
@@ -35,8 +35,9 @@ export declare class GmailApiProvider implements MailProvider {
35
35
  fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
36
36
  /** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
37
37
  * Lists the label once, builds UID→ID map, then streams bodies through
38
- * `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
39
- * `fetch()`'s built-in 429/5xx retry handles backoff automatically).
38
+ * `onBody`. Uses Gmail's HTTP batch endpoint (up to 100 sub-requests per
39
+ * round-trip) when available, with a single-request fallback so a batch
40
+ * protocol blip doesn't starve prefetch entirely.
40
41
  *
41
42
  * NOTE: Gmail's model is labels, not folders — a single message can be in
42
43
  * many labels. Treating each label as a folder causes duplicate fetches
@@ -44,6 +45,13 @@ export declare class GmailApiProvider implements MailProvider {
44
45
  * model"). For now we mirror the IMAP folder grouping, accepting duplicate
45
46
  * fetches of multi-labeled messages. */
46
47
  fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
48
+ /** POST /batch/gmail/v1 with up to 100 sub-requests. See
49
+ * https://developers.google.com/gmail/api/guides/batch
50
+ * for the multipart/mixed wire format. */
51
+ private batchFetchBodies;
52
+ /** Fallback path when batch fails — original bounded-concurrency loop.
53
+ * Kept on the degraded path so a single bad batch doesn't halt prefetch. */
54
+ private fetchBodiesIndividually;
47
55
  fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
48
56
  /** Apply the absolute flag state to a message.
49
57
  * Gmail model: flags are labels. `\Seen` is the *absence* of UNREAD;
package/gmail.js CHANGED
@@ -196,7 +196,17 @@ export class GmailApiProvider {
196
196
  name,
197
197
  delimiter: "/",
198
198
  specialUse,
199
+ <<<<<<< HEAD
199
200
  flags: label.type === "system" ? ["\\Noselect"] : [],
201
+ =======
202
+ // No \\Noselect on Gmail labels: the unwanted system labels
203
+ // (UNREAD/STARRED/IMPORTANT/CATEGORY_*/CHAT) are filtered
204
+ // above, leaving INBOX/SENT/DRAFT/TRASH/SPAM and user labels —
205
+ // ALL of which are selectable. The previous \\Noselect tag
206
+ // on system labels caused mailx-imap to skip them entirely,
207
+ // so INBOX never appeared in the folder tree.
208
+ flags: [],
209
+ >>>>>>> 4b735cd (Initial commit)
200
210
  });
201
211
  }
202
212
  return folders;
@@ -337,8 +347,9 @@ export class GmailApiProvider {
337
347
  }
338
348
  /** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
339
349
  * Lists the label once, builds UID→ID map, then streams bodies through
340
- * `onBody` with bounded concurrency (lets Gmail's HTTP/2 stream multiplex;
341
- * `fetch()`'s built-in 429/5xx retry handles backoff automatically).
350
+ * `onBody`. Uses Gmail's HTTP batch endpoint (up to 100 sub-requests per
351
+ * round-trip) when available, with a single-request fallback so a batch
352
+ * protocol blip doesn't starve prefetch entirely.
342
353
  *
343
354
  * NOTE: Gmail's model is labels, not folders — a single message can be in
344
355
  * many labels. Treating each label as a folder causes duplicate fetches
@@ -361,17 +372,124 @@ export class GmailApiProvider {
361
372
  }
362
373
  if (wanted.length === 0)
363
374
  return;
364
- // Bounded concurrency 2 in-flight, combined with the shared token
365
- // bucket in `fetch()` (8 req/sec steady) keeps us comfortably below
366
- // Gmail's per-user-per-second cap. Earlier 5-wide prefetch kept Google
367
- // in sustained-abuse territory for hours, which triggered cooldowns
368
- // that rejected even 1-unit calls like labels.list on fresh startup.
375
+ // Item 16 / C24 Gmail half: HTTP /batch prefetch. One multipart POST
376
+ // with up to 100 sub-requests replaces N round-trips. Typical ×10-20
377
+ // speedup on large folders. Each batch still costs the same tokens
378
+ // from the rate bucket as the individual GETs would have (no free
379
+ // ride), but wall-clock latency collapses to roughly one round-trip
380
+ // per 100 messages instead of per message.
381
+ const BATCH_SIZE = 100;
382
+ for (let i = 0; i < wanted.length; i += BATCH_SIZE) {
383
+ const slice = wanted.slice(i, i + BATCH_SIZE);
384
+ try {
385
+ await this.batchFetchBodies(slice, onBody);
386
+ }
387
+ catch (e) {
388
+ // Batch failed (malformed multipart, network reset, auth
389
+ // mid-flight). Fall back to a bounded-concurrency per-message
390
+ // loop for this slice so the user still gets bodies — we'd
391
+ // rather ship the feature degraded than lose prefetch.
392
+ console.error(` [gmail batch] fell back to per-message: ${e.message}`);
393
+ await this.fetchBodiesIndividually(slice, onBody);
394
+ }
395
+ }
396
+ }
397
+ /** POST /batch/gmail/v1 with up to 100 sub-requests. See
398
+ * https://developers.google.com/gmail/api/guides/batch
399
+ * for the multipart/mixed wire format. */
400
+ async batchFetchBodies(items, onBody) {
401
+ if (items.length === 0)
402
+ return;
403
+ // One token covers the whole batch endpoint round-trip; Google's
404
+ // per-user quota charges each inner request individually, but the
405
+ // per-second request limit counts the batch as a single request —
406
+ // so rate-bucket budget matches wall-clock cost.
407
+ await this.acquireToken();
408
+ const token = await this.tokenProvider();
409
+ const boundary = `batch_mailx_${Date.now()}_${Math.floor(Math.random() * 1e9)}`;
410
+ const parts = [];
411
+ for (const { id } of items) {
412
+ parts.push(`--${boundary}\r\n` +
413
+ `Content-Type: application/http\r\n` +
414
+ `Content-ID: <${id}>\r\n` +
415
+ `\r\n` +
416
+ `GET /gmail/v1/users/me/messages/${id}?format=raw\r\n` +
417
+ `Accept: application/json\r\n` +
418
+ `\r\n`);
419
+ }
420
+ parts.push(`--${boundary}--\r\n`);
421
+ const body = parts.join("");
422
+ const res = await globalThis.fetch("https://gmail.googleapis.com/batch/gmail/v1", {
423
+ method: "POST",
424
+ headers: {
425
+ "Authorization": `Bearer ${token}`,
426
+ "Content-Type": `multipart/mixed; boundary=${boundary}`,
427
+ },
428
+ body,
429
+ });
430
+ if (!res.ok) {
431
+ const text = await res.text().catch(() => "");
432
+ throw new Error(`batch HTTP ${res.status}: ${text.slice(0, 200)}`);
433
+ }
434
+ const respBody = await res.text();
435
+ // Parse multipart — extract each inner application/http response,
436
+ // match back to the Content-ID to recover the Gmail message id,
437
+ // then decode the raw body JSON field.
438
+ const respCt = res.headers.get("content-type") || "";
439
+ const bMatch = respCt.match(/boundary=([^;]+)/i);
440
+ if (!bMatch)
441
+ throw new Error("batch response missing boundary");
442
+ const respBoundary = bMatch[1].replace(/^"|"$/g, "");
443
+ // Google sometimes replies with its own "batch_..." boundary even if
444
+ // we sent a different one; parsing by the response's boundary is the
445
+ // reliable path. Split on boundary delimiters.
446
+ const chunks = respBody.split(`--${respBoundary}`);
447
+ for (const chunk of chunks) {
448
+ const trimmed = chunk.trim();
449
+ if (!trimmed || trimmed === "--")
450
+ continue;
451
+ // Each chunk is: outer headers, blank line, inner response
452
+ // (HTTP/1.1 status line, headers, blank line, JSON body).
453
+ // Locate the inner JSON body by finding the double blank after
454
+ // the inner headers section.
455
+ const idMatch = chunk.match(/Content-ID:\s*<?response-<?([^>\s]+)>?/i);
456
+ const gmailId = idMatch ? idMatch[1].replace(/^<|>$/g, "") : "";
457
+ // Skip the outer headers: first blank line ends them.
458
+ const firstBlank = chunk.indexOf("\r\n\r\n");
459
+ if (firstBlank < 0)
460
+ continue;
461
+ const inner = chunk.slice(firstBlank + 4);
462
+ // Skip the inner HTTP status + headers: second blank line ends them.
463
+ const secondBlank = inner.indexOf("\r\n\r\n");
464
+ if (secondBlank < 0)
465
+ continue;
466
+ const jsonBody = inner.slice(secondBlank + 4).trim();
467
+ if (!jsonBody || jsonBody.startsWith("--"))
468
+ continue;
469
+ try {
470
+ const parsed = JSON.parse(jsonBody);
471
+ if (!parsed?.raw)
472
+ continue;
473
+ const actualId = parsed.id || gmailId;
474
+ if (!actualId)
475
+ continue;
476
+ const uid = idToUid(actualId);
477
+ const base64 = parsed.raw.replace(/-/g, "+").replace(/_/g, "/");
478
+ const source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
479
+ onBody(uid, source);
480
+ }
481
+ catch { /* malformed sub-response — skip, don't poison the batch */ }
482
+ }
483
+ }
484
+ /** Fallback path when batch fails — original bounded-concurrency loop.
485
+ * Kept on the degraded path so a single bad batch doesn't halt prefetch. */
486
+ async fetchBodiesIndividually(items, onBody) {
369
487
  const CONCURRENCY = 2;
370
488
  let cursor = 0;
371
489
  const worker = async () => {
372
- while (cursor < wanted.length) {
490
+ while (cursor < items.length) {
373
491
  const idx = cursor++;
374
- const { uid, id } = wanted[idx];
492
+ const { uid, id } = items[idx];
375
493
  try {
376
494
  const msg = await this.fetch(`/messages/${id}?format=raw`);
377
495
  if (!msg?.raw)
@@ -381,12 +499,11 @@ export class GmailApiProvider {
381
499
  onBody(uid, source);
382
500
  }
383
501
  catch (e) {
384
- // Per-message failure is non-fatal; keep worker alive for the rest.
385
502
  console.error(` [gmail batch] UID ${uid}: ${e.message}`);
386
503
  }
387
504
  }
388
505
  };
389
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, wanted.length) }, () => worker()));
506
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker()));
390
507
  }
391
508
  async fetchOne(folder, uid, options = {}) {
392
509
  // Caller (mailx-imap) passes providerId straight from the DB row when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-sync",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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/iflow-direct": "^0.1.26",
23
- "@bobfrankston/tcp-transport": "^0.1.4"
22
+ "@bobfrankston/iflow-direct": "file:../iflow-direct",
23
+ "@bobfrankston/tcp-transport": "file:../tcp-transport"
24
24
  },
25
25
  "exports": {
26
26
  ".": {
@@ -37,15 +37,5 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^25.6.0"
40
- },
41
- ".dependencies": {
42
- "@bobfrankston/iflow-direct": "file:../iflow-direct",
43
- "@bobfrankston/tcp-transport": "file:../tcp-transport"
44
- },
45
- ".transformedSnapshot": {
46
- "dependencies": {
47
- "@bobfrankston/iflow-direct": "^0.1.26",
48
- "@bobfrankston/tcp-transport": "^0.1.4"
49
- }
50
40
  }
51
41
  }