@bobfrankston/mailx 1.0.169 → 1.0.172

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.
@@ -6,11 +6,12 @@
6
6
  import { createAutoImapConfig, CompatImapClient } from "@bobfrankston/iflow-direct";
7
7
  import { authenticateOAuth } from "@bobfrankston/oauthsupport";
8
8
  import { FileMessageStore } from "@bobfrankston/mailx-store";
9
- import { loadSettings, getStorePath, getConfigDir, getHistoryDays } from "@bobfrankston/mailx-settings";
9
+ import { loadSettings, getStorePath, getConfigDir, getHistoryDays, getPrefetch } from "@bobfrankston/mailx-settings";
10
10
  import { EventEmitter } from "node:events";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { simpleParser } from "mailparser";
14
+ import { GmailApiProvider } from "./providers/gmail-api.js";
14
15
  import { createTransport } from "nodemailer";
15
16
  import * as os from "node:os";
16
17
  /** Extract full error detail with provenance */
@@ -369,11 +370,46 @@ export class ImapManager extends EventEmitter {
369
370
  }
370
371
  else if (!this.accountErrorShown.has(account.id)) {
371
372
  this.accountErrorShown.add(account.id);
372
- this.emit("accountError", account.id, errMsg, "Authentication may have expired", true);
373
+ const config = this.configs.get(account.id);
374
+ this.emit("accountError", account.id, errMsg, errMsg, !!config?.tokenProvider);
373
375
  }
374
376
  }
375
377
  }
376
378
  }
379
+ /** Check if an account uses Gmail (should use API instead of IMAP) */
380
+ isGmailAccount(accountId) {
381
+ const settings = loadSettings();
382
+ const account = settings.accounts.find(a => a.id === accountId);
383
+ if (!account)
384
+ return false;
385
+ return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
386
+ }
387
+ /** Get a Gmail API provider for an account (reuses tokenProvider from IMAP config) */
388
+ getGmailProvider(accountId) {
389
+ const config = this.configs.get(accountId);
390
+ if (!config?.tokenProvider)
391
+ throw new Error(`No tokenProvider for ${accountId}`);
392
+ return new GmailApiProvider(config.tokenProvider);
393
+ }
394
+ /** Convert ProviderMessage to the shape expected by storeMessages/upsertMessage */
395
+ providerMsgToLocal(msg) {
396
+ return {
397
+ uid: msg.uid,
398
+ messageId: msg.messageId,
399
+ date: msg.date || new Date(),
400
+ subject: msg.subject,
401
+ from: msg.from,
402
+ to: msg.to,
403
+ cc: msg.cc,
404
+ seen: msg.seen,
405
+ flagged: msg.flagged,
406
+ answered: msg.answered,
407
+ draft: msg.draft,
408
+ size: msg.size,
409
+ source: msg.source,
410
+ providerId: msg.providerId,
411
+ };
412
+ }
377
413
  /** Sync folder list for an account */
378
414
  async syncFolders(accountId, client) {
379
415
  if (!client)
@@ -412,7 +448,7 @@ export class ImapManager extends EventEmitter {
412
448
  return dbFolders;
413
449
  }
414
450
  /** Store a batch of messages to DB immediately — used by onChunk for incremental sync */
415
- storeMessages(accountId, folderId, folder, msgs, highestUid) {
451
+ async storeMessages(accountId, folderId, folder, msgs, highestUid) {
416
452
  let stored = 0;
417
453
  this.db.beginTransaction();
418
454
  try {
@@ -426,7 +462,14 @@ export class ImapManager extends EventEmitter {
426
462
  continue; // already have it
427
463
  const source = msg.source || "";
428
464
  let bodyPath = "";
429
- // Skip body storage during sync — bodies fetched on demand
465
+ let preview = "";
466
+ let hasAttachments = false;
467
+ if (source) {
468
+ bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
469
+ const parsed = await extractPreview(source);
470
+ preview = parsed.preview;
471
+ hasAttachments = parsed.hasAttachments;
472
+ }
430
473
  const flags = [];
431
474
  if (msg.seen)
432
475
  flags.push("\\Seen");
@@ -444,7 +487,7 @@ export class ImapManager extends EventEmitter {
444
487
  from: toEmailAddress(msg.from?.[0] || {}),
445
488
  to: toEmailAddresses(msg.to || []),
446
489
  cc: toEmailAddresses(msg.cc || []),
447
- flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath
490
+ flags, size: msg.size || 0, hasAttachments, preview, bodyPath
448
491
  });
449
492
  stored++;
450
493
  }
@@ -460,6 +503,7 @@ export class ImapManager extends EventEmitter {
460
503
  async syncFolder(accountId, folderId, client) {
461
504
  if (!client)
462
505
  client = this.createClient(accountId);
506
+ const prefetch = getPrefetch();
463
507
  const folders = this.db.getFolders(accountId);
464
508
  const folder = folders.find(f => f.id === folderId);
465
509
  if (!folder)
@@ -477,7 +521,7 @@ export class ImapManager extends EventEmitter {
477
521
  ? new Date(Date.now() - effectiveDays * 86400000)
478
522
  : new Date(0);
479
523
  if (highestUid > 0) {
480
- // Incremental: fetch new messages — metadata only for speed, bodies on demand
524
+ // Incremental: fetch new messages — prefetch bodies for offline access
481
525
  const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
482
526
  // Filter out the last known message (IMAP * always returns at least one)
483
527
  messages = fetched.filter((m) => m.uid > highestUid);
@@ -526,8 +570,8 @@ export class ImapManager extends EventEmitter {
526
570
  else {
527
571
  // First sync: fetch in chunks, store each chunk immediately for instant UI
528
572
  let totalStored = 0;
529
- const onChunk = (chunk) => {
530
- const stored = this.storeMessages(accountId, folderId, folder, chunk, highestUid);
573
+ const onChunk = async (chunk) => {
574
+ const stored = await this.storeMessages(accountId, folderId, folder, chunk, highestUid);
531
575
  totalStored += stored;
532
576
  if (stored > 0) {
533
577
  this.db.recalcFolderCounts(folderId);
@@ -535,6 +579,7 @@ export class ImapManager extends EventEmitter {
535
579
  }
536
580
  };
537
581
  const tomorrow = new Date(Date.now() + 86400000); // IMAP BEFORE is exclusive
582
+ // First sync: metadata only for fast UI — bodies prefetched in background after
538
583
  messages = await client.fetchMessageByDate(folder.path, startDate, tomorrow, { source: false }, onChunk);
539
584
  if (totalStored > 0) {
540
585
  console.log(` ${folder.path}: ${totalStored} messages (streamed)`);
@@ -689,25 +734,44 @@ export class ImapManager extends EventEmitter {
689
734
  }
690
735
  /** Sync a single account — manages its own connection lifecycle */
691
736
  async syncAccount(accountId, priorityOrder) {
737
+ // Gmail: use REST API instead of IMAP
738
+ if (this.isGmailAccount(accountId)) {
739
+ return this.syncAccountViaApi(accountId, priorityOrder);
740
+ }
692
741
  try {
693
742
  // Step 1: Get folder list (fast — <1s typically)
694
743
  let client = await this.getOpsClient(accountId);
695
744
  const t0 = Date.now();
696
745
  const folders = await this.syncFolders(accountId, client);
697
746
  console.log(` [timing] ${accountId}: folder list ${Date.now() - t0}ms (${folders.length} folders)`);
698
- // Step 2: Sync INBOX first
747
+ // Step 2: Sync INBOX first — keep retrying on failure (most important folder)
699
748
  const inbox = folders.find(f => f.specialUse === "inbox");
700
749
  if (inbox) {
701
750
  console.log(` [sync] ${accountId}: starting INBOX sync (folder ${inbox.id})`);
702
- try {
703
- client = await this.getOpsClient(accountId);
704
- console.log(` [sync] ${accountId}: got client, calling syncFolder for INBOX`);
705
- await this.syncFolder(accountId, inbox.id, client);
706
- console.log(` [sync] ${accountId}: INBOX sync complete`);
751
+ const maxAttempts = 5;
752
+ let inboxDone = false;
753
+ for (let attempt = 1; attempt <= maxAttempts && !inboxDone; attempt++) {
754
+ try {
755
+ client = await this.getOpsClient(accountId);
756
+ if (attempt > 1)
757
+ console.log(` [sync] ${accountId}: INBOX retry #${attempt}`);
758
+ await this.syncFolder(accountId, inbox.id, client);
759
+ console.log(` [sync] ${accountId}: INBOX sync complete`);
760
+ inboxDone = true;
761
+ }
762
+ catch (e) {
763
+ console.error(` Inbox sync error for ${accountId} (attempt ${attempt}/${maxAttempts}): ${e.message}`);
764
+ await this.reconnectOps(accountId);
765
+ if (attempt < maxAttempts) {
766
+ const delay = Math.min(attempt * 5000, 15000);
767
+ console.log(` [sync] ${accountId}: waiting ${delay / 1000}s before INBOX retry`);
768
+ await new Promise(r => setTimeout(r, delay));
769
+ }
770
+ }
707
771
  }
708
- catch (e) {
709
- console.error(` Inbox sync error for ${accountId}: ${e.message}`);
710
- await this.reconnectOps(accountId);
772
+ if (!inboxDone) {
773
+ console.error(` [sync] ${accountId}: INBOX failed after ${maxAttempts} attempts — will retry next sync cycle`);
774
+ this.emit("syncError", accountId, `INBOX sync failed after ${maxAttempts} attempts`);
711
775
  }
712
776
  }
713
777
  else {
@@ -758,6 +822,145 @@ export class ImapManager extends EventEmitter {
758
822
  this.handleSyncError(accountId, errMsg);
759
823
  }
760
824
  }
825
+ /** Sync a Gmail account via REST API — no IMAP connections */
826
+ async syncAccountViaApi(accountId, priorityOrder) {
827
+ try {
828
+ const api = this.getGmailProvider(accountId);
829
+ const t0 = Date.now();
830
+ // Step 1: Sync folder list via API
831
+ console.log(` [api] ${accountId}: listing labels...`);
832
+ const apiFolders = await api.listFolders();
833
+ console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
834
+ // Store folders in DB (same as IMAP path)
835
+ for (const f of apiFolders) {
836
+ const specialUse = f.specialUse || "";
837
+ this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
838
+ }
839
+ this.emit("folderCountsChanged", accountId, {});
840
+ const dbFolders = this.db.getFolders(accountId);
841
+ // Step 2: Sync folders — INBOX first, then by priority
842
+ const inbox = dbFolders.find(f => f.specialUse === "inbox");
843
+ const remaining = dbFolders.filter(f => f.specialUse !== "inbox");
844
+ remaining.sort((a, b) => {
845
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
846
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
847
+ return pa - pb;
848
+ });
849
+ const foldersToSync = inbox ? [inbox, ...remaining] : remaining;
850
+ for (const folder of foldersToSync) {
851
+ try {
852
+ await this.syncFolderViaApi(accountId, folder, api);
853
+ }
854
+ catch (e) {
855
+ console.error(` [api] ${accountId}/${folder.path}: ${e.message}`);
856
+ }
857
+ }
858
+ await api.close();
859
+ this.accountErrorShown.delete(accountId);
860
+ this.emit("syncComplete", accountId);
861
+ }
862
+ catch (e) {
863
+ const errMsg = e.message || String(e);
864
+ this.emit("syncError", accountId, errMsg);
865
+ console.error(` [api] Sync error for ${accountId}: ${errMsg}`);
866
+ this.handleSyncError(accountId, errMsg);
867
+ }
868
+ }
869
+ /** Sync a single folder via Gmail/Outlook API */
870
+ async syncFolderViaApi(accountId, folder, api) {
871
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
872
+ const historyDays = getHistoryDays(accountId);
873
+ const effectiveDays = (historyDays === 0 && highestUid === 0) ? 30 : historyDays;
874
+ const startDate = effectiveDays > 0 ? new Date(Date.now() - effectiveDays * 86400000) : new Date(0);
875
+ const tomorrow = new Date(Date.now() + 86400000);
876
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
877
+ console.log(` [api] ${accountId}/${folder.path}: syncing (highestUid=${highestUid})...`);
878
+ let messages;
879
+ if (highestUid > 0) {
880
+ // Incremental: fetch messages since last known UID
881
+ messages = await api.fetchSince(folder.path, highestUid, { source: false });
882
+ }
883
+ else {
884
+ // First sync: fetch by date range
885
+ messages = await api.fetchByDate(folder.path, startDate, tomorrow, { source: false }, (chunk) => {
886
+ // Stream chunks to DB for instant UI
887
+ const stored = this.storeApiMessages(accountId, folder.id, chunk, highestUid);
888
+ if (stored > 0) {
889
+ this.db.recalcFolderCounts(folder.id);
890
+ this.emit("folderCountsChanged", accountId, {});
891
+ }
892
+ });
893
+ // First sync chunks already stored via onChunk — just update counts
894
+ this.db.recalcFolderCounts(folder.id);
895
+ this.emit("folderCountsChanged", accountId, {});
896
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
897
+ if (messages.length > 0)
898
+ console.log(` [api] ${accountId}/${folder.path}: ${messages.length} messages`);
899
+ return;
900
+ }
901
+ if (messages.length > 0) {
902
+ console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
903
+ this.storeApiMessages(accountId, folder.id, messages, highestUid);
904
+ }
905
+ // Reconcile deletions
906
+ try {
907
+ const serverUids = new Set(await api.getUids(folder.path));
908
+ const localUids = this.db.getUidsForFolder(accountId, folder.id);
909
+ let deleted = 0;
910
+ for (const uid of localUids) {
911
+ if (!serverUids.has(uid)) {
912
+ this.db.deleteMessage(accountId, uid);
913
+ this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
914
+ deleted++;
915
+ }
916
+ }
917
+ if (deleted > 0)
918
+ console.log(` [api] ${accountId}/${folder.path}: ${deleted} deleted`);
919
+ }
920
+ catch (e) {
921
+ console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
922
+ }
923
+ this.db.recalcFolderCounts(folder.id);
924
+ this.emit("folderCountsChanged", accountId, {});
925
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
926
+ }
927
+ /** Store API-fetched messages to DB */
928
+ storeApiMessages(accountId, folderId, msgs, highestUid) {
929
+ let stored = 0;
930
+ this.db.beginTransaction();
931
+ try {
932
+ for (const msg of msgs) {
933
+ if (msg.uid <= highestUid)
934
+ continue;
935
+ const flags = [];
936
+ if (msg.seen)
937
+ flags.push("\\Seen");
938
+ if (msg.flagged)
939
+ flags.push("\\Flagged");
940
+ if (msg.answered)
941
+ flags.push("\\Answered");
942
+ if (msg.draft)
943
+ flags.push("\\Draft");
944
+ this.db.upsertMessage({
945
+ accountId, folderId, uid: msg.uid,
946
+ messageId: msg.messageId || "", inReplyTo: "", references: [],
947
+ date: msg.date instanceof Date ? msg.date.getTime() : Date.now(),
948
+ subject: msg.subject || "",
949
+ from: toEmailAddress(msg.from?.[0] || {}),
950
+ to: toEmailAddresses(msg.to || []),
951
+ cc: toEmailAddresses(msg.cc || []),
952
+ flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: ""
953
+ });
954
+ stored++;
955
+ }
956
+ this.db.commitTransaction();
957
+ }
958
+ catch (e) {
959
+ this.db.rollbackTransaction();
960
+ console.error(` [api] storeApiMessages error: ${e.message}`);
961
+ }
962
+ return stored;
963
+ }
761
964
  /** Kill and recreate the persistent ops connection */
762
965
  async reconnectOps(accountId) {
763
966
  const old = this.opsClients.get(accountId);
@@ -795,7 +998,7 @@ export class ImapManager extends EventEmitter {
795
998
  }
796
999
  else if (!this.accountErrorShown.has(accountId)) {
797
1000
  this.accountErrorShown.add(accountId);
798
- this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
1001
+ this.emit("accountError", accountId, errMsg, errMsg, isOAuth);
799
1002
  }
800
1003
  }
801
1004
  /** Sync just INBOX for each account (fast check for new mail) */
@@ -832,6 +1035,8 @@ export class ImapManager extends EventEmitter {
832
1035
  return;
833
1036
  if (this.reauthenticating.has(accountId))
834
1037
  return;
1038
+ if (this.isGmailAccount(accountId))
1039
+ return; // Gmail uses API sync, not IMAP polling
835
1040
  this.quickCheckRunning.add(accountId);
836
1041
  let client = null;
837
1042
  try {
@@ -972,13 +1177,16 @@ export class ImapManager extends EventEmitter {
972
1177
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
973
1178
  if (!folder)
974
1179
  return null;
975
- // Serialize: only one body fetch per account at a time (IMAP can only handle one command)
1180
+ // Gmail: use API for body fetch (no IMAP connection needed)
1181
+ if (this.isGmailAccount(accountId)) {
1182
+ return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
1183
+ }
1184
+ // IMAP: serialize — only one body fetch per account at a time
976
1185
  return this.enqueueFetch(accountId, async () => {
977
1186
  // Re-check cache — may have been fetched while queued
978
1187
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
979
1188
  return this.bodyStore.getMessage(accountId, folderId, uid);
980
1189
  }
981
- // Body fetch uses a fresh connection — never waits behind background sync
982
1190
  let client = null;
983
1191
  try {
984
1192
  client = this.newClient(accountId);
@@ -1004,6 +1212,24 @@ export class ImapManager extends EventEmitter {
1004
1212
  }
1005
1213
  });
1006
1214
  }
1215
+ /** Fetch message body via Gmail/Outlook API */
1216
+ async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
1217
+ try {
1218
+ const api = this.getGmailProvider(accountId);
1219
+ const msg = await api.fetchOne(folderPath, uid, { source: true });
1220
+ await api.close();
1221
+ if (!msg?.source)
1222
+ return null;
1223
+ const raw = Buffer.from(msg.source, "utf-8");
1224
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1225
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1226
+ return raw;
1227
+ }
1228
+ catch (e) {
1229
+ console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
1230
+ return null;
1231
+ }
1232
+ }
1007
1233
  /** Get the body store for direct access */
1008
1234
  getBodyStore() {
1009
1235
  return this.bodyStore;
@@ -1449,6 +1675,12 @@ export class ImapManager extends EventEmitter {
1449
1675
  const outboxFolder = this.findFolder(accountId, "outbox");
1450
1676
  if (!outboxFolder)
1451
1677
  return;
1678
+ // Skip if this account's sync is failing — don't pile up connections
1679
+ if (this.connectionBackoff.has(accountId) && Date.now() < (this.connectionBackoff.get(accountId) || 0))
1680
+ return;
1681
+ // Gmail uses SMTP for sending (not IMAP outbox) — skip IMAP outbox check
1682
+ if (this.isGmailAccount(accountId))
1683
+ return;
1452
1684
  const settings = loadSettings();
1453
1685
  const account = settings.accounts.find(a => a.id === accountId);
1454
1686
  if (!account)
@@ -1598,9 +1830,9 @@ export class ImapManager extends EventEmitter {
1598
1830
  this.outboxBackoffDelay.delete(accountId);
1599
1831
  }
1600
1832
  catch (e) {
1601
- // Exponential backoff: 30s → 60s → 120s → 300s (max 5min)
1833
+ // Exponential backoff: 60s → 120s → 300s (max 5min)
1602
1834
  const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
1603
- const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 30000;
1835
+ const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
1604
1836
  this.outboxBackoffDelay.set(accountId, delay);
1605
1837
  this.outboxBackoff.set(accountId, now + delay);
1606
1838
  console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Gmail API provider — replaces IMAP for Gmail accounts.
3
+ * Uses REST API for fast, reliable sync without connection limit issues.
4
+ */
5
+ import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
6
+ export declare class GmailApiProvider implements MailProvider {
7
+ private tokenProvider;
8
+ constructor(tokenProvider: () => Promise<string>);
9
+ private fetch;
10
+ listFolders(): Promise<ProviderFolder[]>;
11
+ /** List message IDs matching a query, handling pagination */
12
+ private listMessageIds;
13
+ /** Batch-fetch message metadata or full content */
14
+ private batchFetch;
15
+ /** Parse a Gmail API message response into ProviderMessage */
16
+ private parseMessage;
17
+ fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
18
+ fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
19
+ fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
20
+ fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
21
+ getUids(folder: string): Promise<number[]>;
22
+ close(): Promise<void>;
23
+ /** Map folder path to Gmail label query term */
24
+ private folderToLabel;
25
+ /** Format date for Gmail query (YYYY/MM/DD) */
26
+ private formatDate;
27
+ }
28
+ //# sourceMappingURL=gmail-api.d.ts.map
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Gmail API provider — replaces IMAP for Gmail accounts.
3
+ * Uses REST API for fast, reliable sync without connection limit issues.
4
+ */
5
+ const API = "https://gmail.googleapis.com/gmail/v1/users/me";
6
+ /** Convert Gmail hex ID to integer UID (lower 48 bits — deterministic, stable) */
7
+ function idToUid(id) {
8
+ const hex = id.length > 12 ? id.slice(-12) : id;
9
+ return parseInt(hex, 16);
10
+ }
11
+ /** Map Gmail label to IMAP-style specialUse */
12
+ function labelSpecialUse(label) {
13
+ switch (label.id) {
14
+ case "INBOX": return "inbox";
15
+ case "SENT": return "sent";
16
+ case "DRAFT": return "drafts";
17
+ case "TRASH": return "trash";
18
+ case "SPAM": return "junk";
19
+ case "STARRED": return "";
20
+ case "IMPORTANT": return "";
21
+ default: return "";
22
+ }
23
+ }
24
+ /** Parse RFC 2822 headers from Gmail metadata payload */
25
+ function getHeader(headers, name) {
26
+ return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || "";
27
+ }
28
+ /** Parse "Name <addr>" or "addr" into { name, address } */
29
+ function parseAddress(raw) {
30
+ const match = raw.match(/^"?([^"<]*?)"?\s*<([^>]+)>/);
31
+ if (match)
32
+ return { name: match[1].trim(), address: match[2].trim() };
33
+ return { address: raw.trim() };
34
+ }
35
+ function parseAddressList(raw) {
36
+ if (!raw)
37
+ return [];
38
+ // Split on commas that aren't inside quotes
39
+ return raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(s => parseAddress(s.trim())).filter(a => a.address);
40
+ }
41
+ export class GmailApiProvider {
42
+ tokenProvider;
43
+ constructor(tokenProvider) {
44
+ this.tokenProvider = tokenProvider;
45
+ }
46
+ async fetch(path, options = {}) {
47
+ const token = await this.tokenProvider();
48
+ const res = await globalThis.fetch(`${API}${path}`, {
49
+ ...options,
50
+ headers: {
51
+ "Authorization": `Bearer ${token}`,
52
+ "Content-Type": "application/json",
53
+ ...options.headers,
54
+ },
55
+ });
56
+ if (!res.ok) {
57
+ const err = await res.text().catch(() => "");
58
+ throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
59
+ }
60
+ return res.json();
61
+ }
62
+ async listFolders() {
63
+ const data = await this.fetch("/labels");
64
+ const labels = data.labels || [];
65
+ const folders = [];
66
+ for (const label of labels) {
67
+ // Skip system labels that aren't useful as folders
68
+ if (["UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL",
69
+ "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES",
70
+ "CATEGORY_FORUMS", "CHAT"].includes(label.id))
71
+ continue;
72
+ const specialUse = labelSpecialUse(label);
73
+ // Map Gmail path separators (/) to IMAP-style
74
+ const path = label.name || label.id;
75
+ const name = path.includes("/") ? path.split("/").pop() : path;
76
+ folders.push({
77
+ path,
78
+ name,
79
+ delimiter: "/",
80
+ specialUse,
81
+ flags: label.type === "system" ? ["\\Noselect"] : [],
82
+ });
83
+ }
84
+ return folders;
85
+ }
86
+ /** List message IDs matching a query, handling pagination */
87
+ async listMessageIds(query, maxResults = 500) {
88
+ const ids = [];
89
+ let pageToken = "";
90
+ while (true) {
91
+ const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
92
+ if (pageToken)
93
+ params.set("pageToken", pageToken);
94
+ const data = await this.fetch(`/messages?${params}`);
95
+ for (const msg of data.messages || []) {
96
+ ids.push(msg.id);
97
+ }
98
+ if (!data.nextPageToken || ids.length >= maxResults)
99
+ break;
100
+ pageToken = data.nextPageToken;
101
+ }
102
+ return ids;
103
+ }
104
+ /** Batch-fetch message metadata or full content */
105
+ async batchFetch(ids, options = {}, onChunk) {
106
+ const all = [];
107
+ const chunkSize = options.source ? 10 : 50; // Smaller chunks for full bodies
108
+ const format = options.source ? "raw" : "metadata";
109
+ const metadataHeaders = "From,To,Cc,Subject,Message-ID,Date";
110
+ for (let i = 0; i < ids.length; i += chunkSize) {
111
+ const chunk = ids.slice(i, i + chunkSize);
112
+ const messages = await Promise.all(chunk.map(id => {
113
+ const params = new URLSearchParams({ format });
114
+ if (format === "metadata")
115
+ params.set("metadataHeaders", metadataHeaders);
116
+ return this.fetch(`/messages/${id}?${params}`);
117
+ }));
118
+ const parsed = messages.map(msg => this.parseMessage(msg, options));
119
+ all.push(...parsed);
120
+ if (onChunk)
121
+ onChunk(parsed);
122
+ }
123
+ return all;
124
+ }
125
+ /** Parse a Gmail API message response into ProviderMessage */
126
+ parseMessage(msg, options = {}) {
127
+ const labels = msg.labelIds || [];
128
+ const headers = msg.payload?.headers || [];
129
+ let source = "";
130
+ if (options.source && msg.raw) {
131
+ // Gmail returns URL-safe base64 — convert to standard base64 then decode
132
+ const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
133
+ source = Buffer.from(base64, "base64").toString("utf-8");
134
+ }
135
+ const fromRaw = getHeader(headers, "From");
136
+ const toRaw = getHeader(headers, "To");
137
+ const ccRaw = getHeader(headers, "Cc");
138
+ const dateRaw = getHeader(headers, "Date") || "";
139
+ const subject = getHeader(headers, "Subject") || msg.snippet || "";
140
+ const messageId = getHeader(headers, "Message-ID") || "";
141
+ return {
142
+ uid: idToUid(msg.id),
143
+ messageId,
144
+ providerId: msg.id,
145
+ date: dateRaw ? new Date(dateRaw) : (msg.internalDate ? new Date(Number(msg.internalDate)) : null),
146
+ subject,
147
+ from: parseAddressList(fromRaw),
148
+ to: parseAddressList(toRaw),
149
+ cc: parseAddressList(ccRaw),
150
+ seen: !labels.includes("UNREAD"),
151
+ flagged: labels.includes("STARRED"),
152
+ answered: false, // Gmail API doesn't expose this directly
153
+ draft: labels.includes("DRAFT"),
154
+ size: msg.sizeEstimate || 0,
155
+ source,
156
+ };
157
+ }
158
+ async fetchSince(folder, sinceUid, options = {}) {
159
+ // Gmail doesn't have UIDs — use date-based query for incremental
160
+ // For now, fetch recent messages and let the caller filter by UID
161
+ const query = `in:${this.folderToLabel(folder)}`;
162
+ const ids = await this.listMessageIds(query, 200);
163
+ const messages = await this.batchFetch(ids, options);
164
+ return messages.filter(m => m.uid > sinceUid);
165
+ }
166
+ async fetchByDate(folder, since, before, options = {}, onChunk) {
167
+ const afterDate = this.formatDate(since);
168
+ const beforeDate = this.formatDate(before);
169
+ const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
170
+ const ids = await this.listMessageIds(query);
171
+ return this.batchFetch(ids, options, onChunk);
172
+ }
173
+ async fetchByUids(folder, uids, options = {}) {
174
+ // UIDs are derived from Gmail IDs — we'd need a reverse lookup
175
+ // For now, fetch all messages in folder and filter
176
+ const query = `in:${this.folderToLabel(folder)}`;
177
+ const ids = await this.listMessageIds(query);
178
+ const uidSet = new Set(uids);
179
+ const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
180
+ return this.batchFetch(matchingIds, options);
181
+ }
182
+ async fetchOne(folder, uid, options = {}) {
183
+ // Need to find the Gmail ID from the UID — search all messages in folder
184
+ const query = `in:${this.folderToLabel(folder)}`;
185
+ const ids = await this.listMessageIds(query, 1000);
186
+ const id = ids.find(id => idToUid(id) === uid);
187
+ if (!id)
188
+ return null;
189
+ const format = options.source ? "raw" : "metadata";
190
+ const params = new URLSearchParams({ format });
191
+ if (format === "metadata")
192
+ params.set("metadataHeaders", "From,To,Cc,Subject,Message-ID,Date");
193
+ const msg = await this.fetch(`/messages/${id}?${params}`);
194
+ return this.parseMessage(msg, options);
195
+ }
196
+ async getUids(folder) {
197
+ const query = `in:${this.folderToLabel(folder)}`;
198
+ const ids = await this.listMessageIds(query, 10000);
199
+ return ids.map(idToUid);
200
+ }
201
+ async close() {
202
+ // No persistent connection to close
203
+ }
204
+ /** Map folder path to Gmail label query term */
205
+ folderToLabel(path) {
206
+ const lower = path.toLowerCase();
207
+ if (lower === "inbox")
208
+ return "inbox";
209
+ if (lower === "sent" || lower === "[gmail]/sent mail")
210
+ return "sent";
211
+ if (lower === "drafts" || lower === "[gmail]/drafts")
212
+ return "drafts";
213
+ if (lower === "trash" || lower === "[gmail]/trash")
214
+ return "trash";
215
+ if (lower === "spam" || lower === "[gmail]/spam" || lower === "junk email")
216
+ return "spam";
217
+ if (lower === "archive" || lower === "[gmail]/all mail")
218
+ return "all";
219
+ // Custom label — use exact name
220
+ return `"${path}"`;
221
+ }
222
+ /** Format date for Gmail query (YYYY/MM/DD) */
223
+ formatDate(d) {
224
+ return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
225
+ }
226
+ }
227
+ //# sourceMappingURL=gmail-api.js.map