@bobfrankston/mailx-sync 0.1.8 → 0.1.10
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 +22 -2
- package/gmail.js +173 -11
- package/package.json +5 -5
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
|
|
39
|
-
*
|
|
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;
|
|
@@ -51,6 +59,18 @@ export declare class GmailApiProvider implements MailProvider {
|
|
|
51
59
|
* remove so the end state matches regardless of what was there before,
|
|
52
60
|
* which makes the call idempotent and safe to retry. */
|
|
53
61
|
setFlags(folder: string, uid: number, flags: string[]): Promise<void>;
|
|
62
|
+
/** Move a message to the trash label. Gmail treats trash as a label, not
|
|
63
|
+
* as a destination folder — `POST /messages/{id}/trash` is the native
|
|
64
|
+
* path (equivalent to setting TRASH and removing INBOX in one op).
|
|
65
|
+
* Used by mailx's delete/trash path. */
|
|
66
|
+
trashMessage(folder: string, uid: number): Promise<void>;
|
|
67
|
+
/** Move between "folders" == swap one label for another via modifyLabels.
|
|
68
|
+
* System labels (INBOX/SENT/TRASH/SPAM) are translated from the folder
|
|
69
|
+
* path; user labels use the folder path verbatim as the label id. */
|
|
70
|
+
moveMessage(fromFolder: string, uid: number, toFolder: string): Promise<void>;
|
|
71
|
+
/** Folder path → Gmail label id. System folders map to uppercase label
|
|
72
|
+
* constants; anything else is treated as a user label (identical name). */
|
|
73
|
+
private folderPathToLabelId;
|
|
54
74
|
getUids(folder: string): Promise<number[]>;
|
|
55
75
|
close(): Promise<void>;
|
|
56
76
|
/** Map folder path to Gmail label query term */
|
package/gmail.js
CHANGED
|
@@ -337,8 +337,9 @@ export class GmailApiProvider {
|
|
|
337
337
|
}
|
|
338
338
|
/** Bulk-fetch raw bodies for many UIDs in one "folder" (Gmail label).
|
|
339
339
|
* Lists the label once, builds UID→ID map, then streams bodies through
|
|
340
|
-
* `onBody
|
|
341
|
-
*
|
|
340
|
+
* `onBody`. Uses Gmail's HTTP batch endpoint (up to 100 sub-requests per
|
|
341
|
+
* round-trip) when available, with a single-request fallback so a batch
|
|
342
|
+
* protocol blip doesn't starve prefetch entirely.
|
|
342
343
|
*
|
|
343
344
|
* NOTE: Gmail's model is labels, not folders — a single message can be in
|
|
344
345
|
* many labels. Treating each label as a folder causes duplicate fetches
|
|
@@ -361,17 +362,124 @@ export class GmailApiProvider {
|
|
|
361
362
|
}
|
|
362
363
|
if (wanted.length === 0)
|
|
363
364
|
return;
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
365
|
+
// Item 16 / C24 Gmail half: HTTP /batch prefetch. One multipart POST
|
|
366
|
+
// with up to 100 sub-requests replaces N round-trips. Typical ×10-20
|
|
367
|
+
// speedup on large folders. Each batch still costs the same tokens
|
|
368
|
+
// from the rate bucket as the individual GETs would have (no free
|
|
369
|
+
// ride), but wall-clock latency collapses to roughly one round-trip
|
|
370
|
+
// per 100 messages instead of per message.
|
|
371
|
+
const BATCH_SIZE = 100;
|
|
372
|
+
for (let i = 0; i < wanted.length; i += BATCH_SIZE) {
|
|
373
|
+
const slice = wanted.slice(i, i + BATCH_SIZE);
|
|
374
|
+
try {
|
|
375
|
+
await this.batchFetchBodies(slice, onBody);
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
// Batch failed (malformed multipart, network reset, auth
|
|
379
|
+
// mid-flight). Fall back to a bounded-concurrency per-message
|
|
380
|
+
// loop for this slice so the user still gets bodies — we'd
|
|
381
|
+
// rather ship the feature degraded than lose prefetch.
|
|
382
|
+
console.error(` [gmail batch] fell back to per-message: ${e.message}`);
|
|
383
|
+
await this.fetchBodiesIndividually(slice, onBody);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/** POST /batch/gmail/v1 with up to 100 sub-requests. See
|
|
388
|
+
* https://developers.google.com/gmail/api/guides/batch
|
|
389
|
+
* for the multipart/mixed wire format. */
|
|
390
|
+
async batchFetchBodies(items, onBody) {
|
|
391
|
+
if (items.length === 0)
|
|
392
|
+
return;
|
|
393
|
+
// One token covers the whole batch endpoint round-trip; Google's
|
|
394
|
+
// per-user quota charges each inner request individually, but the
|
|
395
|
+
// per-second request limit counts the batch as a single request —
|
|
396
|
+
// so rate-bucket budget matches wall-clock cost.
|
|
397
|
+
await this.acquireToken();
|
|
398
|
+
const token = await this.tokenProvider();
|
|
399
|
+
const boundary = `batch_mailx_${Date.now()}_${Math.floor(Math.random() * 1e9)}`;
|
|
400
|
+
const parts = [];
|
|
401
|
+
for (const { id } of items) {
|
|
402
|
+
parts.push(`--${boundary}\r\n` +
|
|
403
|
+
`Content-Type: application/http\r\n` +
|
|
404
|
+
`Content-ID: <${id}>\r\n` +
|
|
405
|
+
`\r\n` +
|
|
406
|
+
`GET /gmail/v1/users/me/messages/${id}?format=raw\r\n` +
|
|
407
|
+
`Accept: application/json\r\n` +
|
|
408
|
+
`\r\n`);
|
|
409
|
+
}
|
|
410
|
+
parts.push(`--${boundary}--\r\n`);
|
|
411
|
+
const body = parts.join("");
|
|
412
|
+
const res = await globalThis.fetch("https://gmail.googleapis.com/batch/gmail/v1", {
|
|
413
|
+
method: "POST",
|
|
414
|
+
headers: {
|
|
415
|
+
"Authorization": `Bearer ${token}`,
|
|
416
|
+
"Content-Type": `multipart/mixed; boundary=${boundary}`,
|
|
417
|
+
},
|
|
418
|
+
body,
|
|
419
|
+
});
|
|
420
|
+
if (!res.ok) {
|
|
421
|
+
const text = await res.text().catch(() => "");
|
|
422
|
+
throw new Error(`batch HTTP ${res.status}: ${text.slice(0, 200)}`);
|
|
423
|
+
}
|
|
424
|
+
const respBody = await res.text();
|
|
425
|
+
// Parse multipart — extract each inner application/http response,
|
|
426
|
+
// match back to the Content-ID to recover the Gmail message id,
|
|
427
|
+
// then decode the raw body JSON field.
|
|
428
|
+
const respCt = res.headers.get("content-type") || "";
|
|
429
|
+
const bMatch = respCt.match(/boundary=([^;]+)/i);
|
|
430
|
+
if (!bMatch)
|
|
431
|
+
throw new Error("batch response missing boundary");
|
|
432
|
+
const respBoundary = bMatch[1].replace(/^"|"$/g, "");
|
|
433
|
+
// Google sometimes replies with its own "batch_..." boundary even if
|
|
434
|
+
// we sent a different one; parsing by the response's boundary is the
|
|
435
|
+
// reliable path. Split on boundary delimiters.
|
|
436
|
+
const chunks = respBody.split(`--${respBoundary}`);
|
|
437
|
+
for (const chunk of chunks) {
|
|
438
|
+
const trimmed = chunk.trim();
|
|
439
|
+
if (!trimmed || trimmed === "--")
|
|
440
|
+
continue;
|
|
441
|
+
// Each chunk is: outer headers, blank line, inner response
|
|
442
|
+
// (HTTP/1.1 status line, headers, blank line, JSON body).
|
|
443
|
+
// Locate the inner JSON body by finding the double blank after
|
|
444
|
+
// the inner headers section.
|
|
445
|
+
const idMatch = chunk.match(/Content-ID:\s*<?response-<?([^>\s]+)>?/i);
|
|
446
|
+
const gmailId = idMatch ? idMatch[1].replace(/^<|>$/g, "") : "";
|
|
447
|
+
// Skip the outer headers: first blank line ends them.
|
|
448
|
+
const firstBlank = chunk.indexOf("\r\n\r\n");
|
|
449
|
+
if (firstBlank < 0)
|
|
450
|
+
continue;
|
|
451
|
+
const inner = chunk.slice(firstBlank + 4);
|
|
452
|
+
// Skip the inner HTTP status + headers: second blank line ends them.
|
|
453
|
+
const secondBlank = inner.indexOf("\r\n\r\n");
|
|
454
|
+
if (secondBlank < 0)
|
|
455
|
+
continue;
|
|
456
|
+
const jsonBody = inner.slice(secondBlank + 4).trim();
|
|
457
|
+
if (!jsonBody || jsonBody.startsWith("--"))
|
|
458
|
+
continue;
|
|
459
|
+
try {
|
|
460
|
+
const parsed = JSON.parse(jsonBody);
|
|
461
|
+
if (!parsed?.raw)
|
|
462
|
+
continue;
|
|
463
|
+
const actualId = parsed.id || gmailId;
|
|
464
|
+
if (!actualId)
|
|
465
|
+
continue;
|
|
466
|
+
const uid = idToUid(actualId);
|
|
467
|
+
const base64 = parsed.raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
468
|
+
const source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
|
|
469
|
+
onBody(uid, source);
|
|
470
|
+
}
|
|
471
|
+
catch { /* malformed sub-response — skip, don't poison the batch */ }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
/** Fallback path when batch fails — original bounded-concurrency loop.
|
|
475
|
+
* Kept on the degraded path so a single bad batch doesn't halt prefetch. */
|
|
476
|
+
async fetchBodiesIndividually(items, onBody) {
|
|
369
477
|
const CONCURRENCY = 2;
|
|
370
478
|
let cursor = 0;
|
|
371
479
|
const worker = async () => {
|
|
372
|
-
while (cursor <
|
|
480
|
+
while (cursor < items.length) {
|
|
373
481
|
const idx = cursor++;
|
|
374
|
-
const { uid, id } =
|
|
482
|
+
const { uid, id } = items[idx];
|
|
375
483
|
try {
|
|
376
484
|
const msg = await this.fetch(`/messages/${id}?format=raw`);
|
|
377
485
|
if (!msg?.raw)
|
|
@@ -381,12 +489,11 @@ export class GmailApiProvider {
|
|
|
381
489
|
onBody(uid, source);
|
|
382
490
|
}
|
|
383
491
|
catch (e) {
|
|
384
|
-
// Per-message failure is non-fatal; keep worker alive for the rest.
|
|
385
492
|
console.error(` [gmail batch] UID ${uid}: ${e.message}`);
|
|
386
493
|
}
|
|
387
494
|
}
|
|
388
495
|
};
|
|
389
|
-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY,
|
|
496
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker()));
|
|
390
497
|
}
|
|
391
498
|
async fetchOne(folder, uid, options = {}) {
|
|
392
499
|
// Caller (mailx-imap) passes providerId straight from the DB row when
|
|
@@ -439,6 +546,61 @@ export class GmailApiProvider {
|
|
|
439
546
|
body: JSON.stringify({ addLabelIds, removeLabelIds }),
|
|
440
547
|
});
|
|
441
548
|
}
|
|
549
|
+
/** Move a message to the trash label. Gmail treats trash as a label, not
|
|
550
|
+
* as a destination folder — `POST /messages/{id}/trash` is the native
|
|
551
|
+
* path (equivalent to setting TRASH and removing INBOX in one op).
|
|
552
|
+
* Used by mailx's delete/trash path. */
|
|
553
|
+
async trashMessage(folder, uid) {
|
|
554
|
+
const query = `in:${this.folderToLabel(folder)}`;
|
|
555
|
+
const ids = await this.listMessageIds(query, 1000);
|
|
556
|
+
const id = ids.find(id => idToUid(id) === uid);
|
|
557
|
+
if (!id)
|
|
558
|
+
throw new Error(`Gmail trashMessage: UID ${uid} not found in ${folder}`);
|
|
559
|
+
await this.fetch(`/messages/${id}/trash`, { method: "POST" });
|
|
560
|
+
}
|
|
561
|
+
/** Move between "folders" == swap one label for another via modifyLabels.
|
|
562
|
+
* System labels (INBOX/SENT/TRASH/SPAM) are translated from the folder
|
|
563
|
+
* path; user labels use the folder path verbatim as the label id. */
|
|
564
|
+
async moveMessage(fromFolder, uid, toFolder) {
|
|
565
|
+
const query = `in:${this.folderToLabel(fromFolder)}`;
|
|
566
|
+
const ids = await this.listMessageIds(query, 1000);
|
|
567
|
+
const id = ids.find(id => idToUid(id) === uid);
|
|
568
|
+
if (!id)
|
|
569
|
+
throw new Error(`Gmail moveMessage: UID ${uid} not found in ${fromFolder}`);
|
|
570
|
+
// Map the folder path to the label id. System labels are uppercased
|
|
571
|
+
// aliases; user labels are passed through as-is (Gmail's label ids
|
|
572
|
+
// for user-created labels match the visible label name).
|
|
573
|
+
const toLabel = this.folderPathToLabelId(toFolder);
|
|
574
|
+
const fromLabel = this.folderPathToLabelId(fromFolder);
|
|
575
|
+
const addLabelIds = [];
|
|
576
|
+
const removeLabelIds = [];
|
|
577
|
+
if (toLabel)
|
|
578
|
+
addLabelIds.push(toLabel);
|
|
579
|
+
if (fromLabel && fromLabel !== toLabel)
|
|
580
|
+
removeLabelIds.push(fromLabel);
|
|
581
|
+
await this.fetch(`/messages/${id}/modify`, {
|
|
582
|
+
method: "POST",
|
|
583
|
+
body: JSON.stringify({ addLabelIds, removeLabelIds }),
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
/** Folder path → Gmail label id. System folders map to uppercase label
|
|
587
|
+
* constants; anything else is treated as a user label (identical name). */
|
|
588
|
+
folderPathToLabelId(path) {
|
|
589
|
+
const lower = path.toLowerCase();
|
|
590
|
+
if (lower === "inbox")
|
|
591
|
+
return "INBOX";
|
|
592
|
+
if (lower === "sent" || lower === "[gmail]/sent mail")
|
|
593
|
+
return "SENT";
|
|
594
|
+
if (lower === "drafts" || lower === "[gmail]/drafts")
|
|
595
|
+
return "DRAFT";
|
|
596
|
+
if (lower === "trash" || lower === "[gmail]/trash")
|
|
597
|
+
return "TRASH";
|
|
598
|
+
if (lower === "spam" || lower === "junk email" || lower === "[gmail]/spam")
|
|
599
|
+
return "SPAM";
|
|
600
|
+
if (lower === "archive" || lower === "[gmail]/all mail")
|
|
601
|
+
return ""; // no-op — archive is absence-of-INBOX
|
|
602
|
+
return path; // user label — name-as-id
|
|
603
|
+
}
|
|
442
604
|
async getUids(folder) {
|
|
443
605
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
444
606
|
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.10",
|
|
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.
|
|
23
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
22
|
+
"@bobfrankston/iflow-direct": "^0.1.27",
|
|
23
|
+
"@bobfrankston/tcp-transport": "^0.1.5"
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
26
|
".": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
},
|
|
45
45
|
".transformedSnapshot": {
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
48
|
-
"@bobfrankston/tcp-transport": "^0.1.
|
|
47
|
+
"@bobfrankston/iflow-direct": "^0.1.27",
|
|
48
|
+
"@bobfrankston/tcp-transport": "^0.1.5"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
}
|