@bobfrankston/mailx 1.0.171 → 1.0.173

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.
@@ -11,6 +11,7 @@ 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)
@@ -486,7 +522,7 @@ export class ImapManager extends EventEmitter {
486
522
  : new Date(0);
487
523
  if (highestUid > 0) {
488
524
  // Incremental: fetch new messages — prefetch bodies for offline access
489
- const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: prefetch });
525
+ const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: false });
490
526
  // Filter out the last known message (IMAP * always returns at least one)
491
527
  messages = fetched.filter((m) => m.uid > highestUid);
492
528
  // Gap detection: check for missing UIDs within the range we've already synced
@@ -507,7 +543,7 @@ export class ImapManager extends EventEmitter {
507
543
  for (let i = 0; i < missingUids.length; i += chunkSize) {
508
544
  const chunk = missingUids.slice(i, i + chunkSize);
509
545
  const range = chunk.join(",");
510
- const recovered = await client.fetchMessages(folder.path, range, { source: prefetch });
546
+ const recovered = await client.fetchMessages(folder.path, range, { source: false });
511
547
  messages.push(...recovered);
512
548
  }
513
549
  }
@@ -523,7 +559,7 @@ export class ImapManager extends EventEmitter {
523
559
  const oldestDate = this.db.getOldestDate(accountId, folderId);
524
560
  if (oldestDate > 0 && startDate.getTime() < oldestDate) {
525
561
  const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
526
- const backfill = await client.fetchMessageByDate(folder.path, startDate, new Date(oldestDate), { source: prefetch });
562
+ const backfill = await client.fetchMessageByDate(folder.path, startDate, new Date(oldestDate), { source: false });
527
563
  const newBackfill = backfill.filter((m) => !existingUids.has(m.uid));
528
564
  if (newBackfill.length > 0) {
529
565
  console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages`);
@@ -543,7 +579,8 @@ export class ImapManager extends EventEmitter {
543
579
  }
544
580
  };
545
581
  const tomorrow = new Date(Date.now() + 86400000); // IMAP BEFORE is exclusive
546
- messages = await client.fetchMessageByDate(folder.path, startDate, tomorrow, { source: prefetch }, onChunk);
582
+ // First sync: metadata only for fast UI bodies prefetched in background after
583
+ messages = await client.fetchMessageByDate(folder.path, startDate, tomorrow, { source: false }, onChunk);
547
584
  if (totalStored > 0) {
548
585
  console.log(` ${folder.path}: ${totalStored} messages (streamed)`);
549
586
  this.db.recalcFolderCounts(folderId);
@@ -694,9 +731,19 @@ export class ImapManager extends EventEmitter {
694
731
  // Sync all accounts in parallel — each manages its own connection
695
732
  const syncPromises = [...this.configs.keys()].map(accountId => this.syncAccount(accountId, priorityOrder));
696
733
  await Promise.allSettled(syncPromises);
734
+ // Background body prefetch — after sync, fetch bodies for messages that don't have them
735
+ if (getPrefetch()) {
736
+ for (const accountId of this.configs.keys()) {
737
+ this.prefetchBodies(accountId).catch(e => console.error(` [prefetch] ${accountId}: ${e.message}`));
738
+ }
739
+ }
697
740
  }
698
741
  /** Sync a single account — manages its own connection lifecycle */
699
742
  async syncAccount(accountId, priorityOrder) {
743
+ // Gmail: use REST API instead of IMAP
744
+ if (this.isGmailAccount(accountId)) {
745
+ return this.syncAccountViaApi(accountId, priorityOrder);
746
+ }
700
747
  try {
701
748
  // Step 1: Get folder list (fast — <1s typically)
702
749
  let client = await this.getOpsClient(accountId);
@@ -781,6 +828,145 @@ export class ImapManager extends EventEmitter {
781
828
  this.handleSyncError(accountId, errMsg);
782
829
  }
783
830
  }
831
+ /** Sync a Gmail account via REST API — no IMAP connections */
832
+ async syncAccountViaApi(accountId, priorityOrder) {
833
+ try {
834
+ const api = this.getGmailProvider(accountId);
835
+ const t0 = Date.now();
836
+ // Step 1: Sync folder list via API
837
+ console.log(` [api] ${accountId}: listing labels...`);
838
+ const apiFolders = await api.listFolders();
839
+ console.log(` [api] ${accountId}: ${apiFolders.length} labels in ${Date.now() - t0}ms`);
840
+ // Store folders in DB (same as IMAP path)
841
+ for (const f of apiFolders) {
842
+ const specialUse = f.specialUse || "";
843
+ this.db.upsertFolder(accountId, f.path, f.name, specialUse, f.delimiter);
844
+ }
845
+ this.emit("folderCountsChanged", accountId, {});
846
+ const dbFolders = this.db.getFolders(accountId);
847
+ // Step 2: Sync folders — INBOX first, then by priority
848
+ const inbox = dbFolders.find(f => f.specialUse === "inbox");
849
+ const remaining = dbFolders.filter(f => f.specialUse !== "inbox");
850
+ remaining.sort((a, b) => {
851
+ const pa = priorityOrder.indexOf(a.specialUse || "") >= 0 ? priorityOrder.indexOf(a.specialUse || "") : 5;
852
+ const pb = priorityOrder.indexOf(b.specialUse || "") >= 0 ? priorityOrder.indexOf(b.specialUse || "") : 5;
853
+ return pa - pb;
854
+ });
855
+ const foldersToSync = inbox ? [inbox, ...remaining] : remaining;
856
+ for (const folder of foldersToSync) {
857
+ try {
858
+ await this.syncFolderViaApi(accountId, folder, api);
859
+ }
860
+ catch (e) {
861
+ console.error(` [api] ${accountId}/${folder.path}: ${e.message}`);
862
+ }
863
+ }
864
+ await api.close();
865
+ this.accountErrorShown.delete(accountId);
866
+ this.emit("syncComplete", accountId);
867
+ }
868
+ catch (e) {
869
+ const errMsg = e.message || String(e);
870
+ this.emit("syncError", accountId, errMsg);
871
+ console.error(` [api] Sync error for ${accountId}: ${errMsg}`);
872
+ this.handleSyncError(accountId, errMsg);
873
+ }
874
+ }
875
+ /** Sync a single folder via Gmail/Outlook API */
876
+ async syncFolderViaApi(accountId, folder, api) {
877
+ const highestUid = this.db.getHighestUid(accountId, folder.id);
878
+ const historyDays = getHistoryDays(accountId);
879
+ const effectiveDays = (historyDays === 0 && highestUid === 0) ? 30 : historyDays;
880
+ const startDate = effectiveDays > 0 ? new Date(Date.now() - effectiveDays * 86400000) : new Date(0);
881
+ const tomorrow = new Date(Date.now() + 86400000);
882
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 0);
883
+ console.log(` [api] ${accountId}/${folder.path}: syncing (highestUid=${highestUid})...`);
884
+ let messages;
885
+ if (highestUid > 0) {
886
+ // Incremental: fetch messages since last known UID
887
+ messages = await api.fetchSince(folder.path, highestUid, { source: false });
888
+ }
889
+ else {
890
+ // First sync: fetch by date range
891
+ messages = await api.fetchByDate(folder.path, startDate, tomorrow, { source: false }, (chunk) => {
892
+ // Stream chunks to DB for instant UI
893
+ const stored = this.storeApiMessages(accountId, folder.id, chunk, highestUid);
894
+ if (stored > 0) {
895
+ this.db.recalcFolderCounts(folder.id);
896
+ this.emit("folderCountsChanged", accountId, {});
897
+ }
898
+ });
899
+ // First sync chunks already stored via onChunk — just update counts
900
+ this.db.recalcFolderCounts(folder.id);
901
+ this.emit("folderCountsChanged", accountId, {});
902
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
903
+ if (messages.length > 0)
904
+ console.log(` [api] ${accountId}/${folder.path}: ${messages.length} messages`);
905
+ return;
906
+ }
907
+ if (messages.length > 0) {
908
+ console.log(` [api] ${accountId}/${folder.path}: ${messages.length} new messages`);
909
+ this.storeApiMessages(accountId, folder.id, messages, highestUid);
910
+ }
911
+ // Reconcile deletions
912
+ try {
913
+ const serverUids = new Set(await api.getUids(folder.path));
914
+ const localUids = this.db.getUidsForFolder(accountId, folder.id);
915
+ let deleted = 0;
916
+ for (const uid of localUids) {
917
+ if (!serverUids.has(uid)) {
918
+ this.db.deleteMessage(accountId, uid);
919
+ this.bodyStore.deleteMessage(accountId, folder.id, uid).catch(() => { });
920
+ deleted++;
921
+ }
922
+ }
923
+ if (deleted > 0)
924
+ console.log(` [api] ${accountId}/${folder.path}: ${deleted} deleted`);
925
+ }
926
+ catch (e) {
927
+ console.error(` [api] ${accountId}/${folder.path}: reconciliation error: ${e.message}`);
928
+ }
929
+ this.db.recalcFolderCounts(folder.id);
930
+ this.emit("folderCountsChanged", accountId, {});
931
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, 100);
932
+ }
933
+ /** Store API-fetched messages to DB */
934
+ storeApiMessages(accountId, folderId, msgs, highestUid) {
935
+ let stored = 0;
936
+ this.db.beginTransaction();
937
+ try {
938
+ for (const msg of msgs) {
939
+ if (msg.uid <= highestUid)
940
+ continue;
941
+ const flags = [];
942
+ if (msg.seen)
943
+ flags.push("\\Seen");
944
+ if (msg.flagged)
945
+ flags.push("\\Flagged");
946
+ if (msg.answered)
947
+ flags.push("\\Answered");
948
+ if (msg.draft)
949
+ flags.push("\\Draft");
950
+ this.db.upsertMessage({
951
+ accountId, folderId, uid: msg.uid,
952
+ messageId: msg.messageId || "", inReplyTo: "", references: [],
953
+ date: msg.date instanceof Date ? msg.date.getTime() : Date.now(),
954
+ subject: msg.subject || "",
955
+ from: toEmailAddress(msg.from?.[0] || {}),
956
+ to: toEmailAddresses(msg.to || []),
957
+ cc: toEmailAddresses(msg.cc || []),
958
+ flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: ""
959
+ });
960
+ stored++;
961
+ }
962
+ this.db.commitTransaction();
963
+ }
964
+ catch (e) {
965
+ this.db.rollbackTransaction();
966
+ console.error(` [api] storeApiMessages error: ${e.message}`);
967
+ }
968
+ return stored;
969
+ }
784
970
  /** Kill and recreate the persistent ops connection */
785
971
  async reconnectOps(accountId) {
786
972
  const old = this.opsClients.get(accountId);
@@ -818,7 +1004,7 @@ export class ImapManager extends EventEmitter {
818
1004
  }
819
1005
  else if (!this.accountErrorShown.has(accountId)) {
820
1006
  this.accountErrorShown.add(accountId);
821
- this.emit("accountError", accountId, errMsg, isOAuth ? "Authentication may have expired" : "Check server connectivity", isOAuth);
1007
+ this.emit("accountError", accountId, errMsg, errMsg, isOAuth);
822
1008
  }
823
1009
  }
824
1010
  /** Sync just INBOX for each account (fast check for new mail) */
@@ -855,6 +1041,8 @@ export class ImapManager extends EventEmitter {
855
1041
  return;
856
1042
  if (this.reauthenticating.has(accountId))
857
1043
  return;
1044
+ if (this.isGmailAccount(accountId))
1045
+ return; // Gmail uses API sync, not IMAP polling
858
1046
  this.quickCheckRunning.add(accountId);
859
1047
  let client = null;
860
1048
  try {
@@ -995,13 +1183,16 @@ export class ImapManager extends EventEmitter {
995
1183
  const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
996
1184
  if (!folder)
997
1185
  return null;
998
- // Serialize: only one body fetch per account at a time (IMAP can only handle one command)
1186
+ // Gmail: use API for body fetch (no IMAP connection needed)
1187
+ if (this.isGmailAccount(accountId)) {
1188
+ return this.fetchMessageBodyViaApi(accountId, folderId, uid, folder.path);
1189
+ }
1190
+ // IMAP: serialize — only one body fetch per account at a time
999
1191
  return this.enqueueFetch(accountId, async () => {
1000
1192
  // Re-check cache — may have been fetched while queued
1001
1193
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1002
1194
  return this.bodyStore.getMessage(accountId, folderId, uid);
1003
1195
  }
1004
- // Body fetch uses a fresh connection — never waits behind background sync
1005
1196
  let client = null;
1006
1197
  try {
1007
1198
  client = this.newClient(accountId);
@@ -1027,6 +1218,45 @@ export class ImapManager extends EventEmitter {
1027
1218
  }
1028
1219
  });
1029
1220
  }
1221
+ /** Fetch message body via Gmail/Outlook API */
1222
+ async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
1223
+ try {
1224
+ const api = this.getGmailProvider(accountId);
1225
+ const msg = await api.fetchOne(folderPath, uid, { source: true });
1226
+ await api.close();
1227
+ if (!msg?.source)
1228
+ return null;
1229
+ const raw = Buffer.from(msg.source, "utf-8");
1230
+ const bodyPath = await this.bodyStore.putMessage(accountId, folderId, uid, raw);
1231
+ this.db.updateBodyPath(accountId, uid, bodyPath);
1232
+ return raw;
1233
+ }
1234
+ catch (e) {
1235
+ console.error(` [api] Body fetch error (${accountId}/${uid}): ${e.message}`);
1236
+ return null;
1237
+ }
1238
+ }
1239
+ /** Background body prefetch — download bodies for messages that don't have them */
1240
+ async prefetchBodies(accountId) {
1241
+ const missing = this.db.getMessagesWithoutBody(accountId, 25);
1242
+ if (missing.length === 0)
1243
+ return;
1244
+ console.log(` [prefetch] ${accountId}: ${missing.length} bodies to fetch`);
1245
+ let fetched = 0;
1246
+ for (const msg of missing) {
1247
+ try {
1248
+ const result = await this.fetchMessageBody(accountId, msg.folderId, msg.uid);
1249
+ if (result)
1250
+ fetched++;
1251
+ }
1252
+ catch (e) {
1253
+ console.error(` [prefetch] ${accountId}/${msg.uid}: ${e.message}`);
1254
+ break; // Stop on error — don't hammer a broken connection
1255
+ }
1256
+ }
1257
+ if (fetched > 0)
1258
+ console.log(` [prefetch] ${accountId}: ${fetched} bodies cached`);
1259
+ }
1030
1260
  /** Get the body store for direct access */
1031
1261
  getBodyStore() {
1032
1262
  return this.bodyStore;
@@ -1472,6 +1702,12 @@ export class ImapManager extends EventEmitter {
1472
1702
  const outboxFolder = this.findFolder(accountId, "outbox");
1473
1703
  if (!outboxFolder)
1474
1704
  return;
1705
+ // Skip if this account's sync is failing — don't pile up connections
1706
+ if (this.connectionBackoff.has(accountId) && Date.now() < (this.connectionBackoff.get(accountId) || 0))
1707
+ return;
1708
+ // Gmail uses SMTP for sending (not IMAP outbox) — skip IMAP outbox check
1709
+ if (this.isGmailAccount(accountId))
1710
+ return;
1475
1711
  const settings = loadSettings();
1476
1712
  const account = settings.accounts.find(a => a.id === accountId);
1477
1713
  if (!account)
@@ -1621,9 +1857,9 @@ export class ImapManager extends EventEmitter {
1621
1857
  this.outboxBackoffDelay.delete(accountId);
1622
1858
  }
1623
1859
  catch (e) {
1624
- // Exponential backoff: 30s → 60s → 120s → 300s (max 5min)
1860
+ // Exponential backoff: 60s → 120s → 300s (max 5min)
1625
1861
  const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
1626
- const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 30000;
1862
+ const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
1627
1863
  this.outboxBackoffDelay.set(accountId, delay);
1628
1864
  this.outboxBackoff.set(accountId, now + delay);
1629
1865
  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,244 @@
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
+ for (let attempt = 0; attempt < 3; attempt++) {
49
+ const res = await globalThis.fetch(`${API}${path}`, {
50
+ ...options,
51
+ headers: {
52
+ "Authorization": `Bearer ${token}`,
53
+ "Content-Type": "application/json",
54
+ ...options.headers,
55
+ },
56
+ });
57
+ if (res.status === 429) {
58
+ // Rate limited — back off and retry
59
+ const delay = (attempt + 1) * 2000;
60
+ console.log(` [gmail] Rate limited, waiting ${delay / 1000}s...`);
61
+ await new Promise(r => setTimeout(r, delay));
62
+ continue;
63
+ }
64
+ if (!res.ok) {
65
+ const err = await res.text().catch(() => "");
66
+ throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
67
+ }
68
+ return res.json();
69
+ }
70
+ throw new Error("Gmail API: rate limited after 3 retries");
71
+ }
72
+ async listFolders() {
73
+ const data = await this.fetch("/labels");
74
+ const labels = data.labels || [];
75
+ const folders = [];
76
+ for (const label of labels) {
77
+ // Skip system labels that aren't useful as folders
78
+ if (["UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL",
79
+ "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES",
80
+ "CATEGORY_FORUMS", "CHAT"].includes(label.id))
81
+ continue;
82
+ const specialUse = labelSpecialUse(label);
83
+ // Map Gmail path separators (/) to IMAP-style
84
+ const path = label.name || label.id;
85
+ const name = path.includes("/") ? path.split("/").pop() : path;
86
+ folders.push({
87
+ path,
88
+ name,
89
+ delimiter: "/",
90
+ specialUse,
91
+ flags: label.type === "system" ? ["\\Noselect"] : [],
92
+ });
93
+ }
94
+ return folders;
95
+ }
96
+ /** List message IDs matching a query, handling pagination */
97
+ async listMessageIds(query, maxResults = 500) {
98
+ const ids = [];
99
+ let pageToken = "";
100
+ while (true) {
101
+ const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
102
+ if (pageToken)
103
+ params.set("pageToken", pageToken);
104
+ const data = await this.fetch(`/messages?${params}`);
105
+ for (const msg of data.messages || []) {
106
+ ids.push(msg.id);
107
+ }
108
+ if (!data.nextPageToken || ids.length >= maxResults)
109
+ break;
110
+ pageToken = data.nextPageToken;
111
+ }
112
+ return ids;
113
+ }
114
+ /** Batch-fetch message metadata or full content */
115
+ async batchFetch(ids, options = {}, onChunk) {
116
+ const all = [];
117
+ const chunkSize = options.source ? 10 : 50; // Smaller chunks for full bodies
118
+ const format = options.source ? "raw" : "metadata";
119
+ for (let i = 0; i < ids.length; i += chunkSize) {
120
+ const chunk = ids.slice(i, i + chunkSize);
121
+ // Sequential fetches to avoid Gmail 429 rate limits
122
+ const messages = [];
123
+ for (const id of chunk) {
124
+ const params = new URLSearchParams({ format });
125
+ if (format === "metadata") {
126
+ for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
127
+ params.append("metadataHeaders", h);
128
+ }
129
+ }
130
+ messages.push(await this.fetch(`/messages/${id}?${params}`));
131
+ }
132
+ const parsed = messages.map(msg => this.parseMessage(msg, options));
133
+ all.push(...parsed);
134
+ if (onChunk)
135
+ onChunk(parsed);
136
+ }
137
+ return all;
138
+ }
139
+ /** Parse a Gmail API message response into ProviderMessage */
140
+ parseMessage(msg, options = {}) {
141
+ const labels = msg.labelIds || [];
142
+ const headers = msg.payload?.headers || [];
143
+ let source = "";
144
+ if (options.source && msg.raw) {
145
+ // Gmail returns URL-safe base64 — convert to standard base64 then decode
146
+ const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
147
+ source = Buffer.from(base64, "base64").toString("utf-8");
148
+ }
149
+ const fromRaw = getHeader(headers, "From");
150
+ const toRaw = getHeader(headers, "To");
151
+ const ccRaw = getHeader(headers, "Cc");
152
+ const dateRaw = getHeader(headers, "Date") || "";
153
+ const subject = getHeader(headers, "Subject") || msg.snippet || "";
154
+ const messageId = getHeader(headers, "Message-ID") || "";
155
+ return {
156
+ uid: idToUid(msg.id),
157
+ messageId,
158
+ providerId: msg.id,
159
+ date: dateRaw ? new Date(dateRaw) : (msg.internalDate ? new Date(Number(msg.internalDate)) : null),
160
+ subject,
161
+ from: parseAddressList(fromRaw),
162
+ to: parseAddressList(toRaw),
163
+ cc: parseAddressList(ccRaw),
164
+ seen: !labels.includes("UNREAD"),
165
+ flagged: labels.includes("STARRED"),
166
+ answered: false, // Gmail API doesn't expose this directly
167
+ draft: labels.includes("DRAFT"),
168
+ size: msg.sizeEstimate || 0,
169
+ source,
170
+ };
171
+ }
172
+ async fetchSince(folder, sinceUid, options = {}) {
173
+ // Gmail doesn't have UIDs — use date-based query for incremental
174
+ // For now, fetch recent messages and let the caller filter by UID
175
+ const query = `in:${this.folderToLabel(folder)}`;
176
+ const ids = await this.listMessageIds(query, 200);
177
+ const messages = await this.batchFetch(ids, options);
178
+ return messages.filter(m => m.uid > sinceUid);
179
+ }
180
+ async fetchByDate(folder, since, before, options = {}, onChunk) {
181
+ const afterDate = this.formatDate(since);
182
+ const beforeDate = this.formatDate(before);
183
+ const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
184
+ const ids = await this.listMessageIds(query);
185
+ return this.batchFetch(ids, options, onChunk);
186
+ }
187
+ async fetchByUids(folder, uids, options = {}) {
188
+ // UIDs are derived from Gmail IDs — we'd need a reverse lookup
189
+ // For now, fetch all messages in folder and filter
190
+ const query = `in:${this.folderToLabel(folder)}`;
191
+ const ids = await this.listMessageIds(query);
192
+ const uidSet = new Set(uids);
193
+ const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
194
+ return this.batchFetch(matchingIds, options);
195
+ }
196
+ async fetchOne(folder, uid, options = {}) {
197
+ // Need to find the Gmail ID from the UID — search all messages in folder
198
+ const query = `in:${this.folderToLabel(folder)}`;
199
+ const ids = await this.listMessageIds(query, 1000);
200
+ const id = ids.find(id => idToUid(id) === uid);
201
+ if (!id)
202
+ return null;
203
+ const format = options.source ? "raw" : "metadata";
204
+ const params = new URLSearchParams({ format });
205
+ if (format === "metadata") {
206
+ for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
207
+ params.append("metadataHeaders", h);
208
+ }
209
+ }
210
+ const msg = await this.fetch(`/messages/${id}?${params}`);
211
+ return this.parseMessage(msg, options);
212
+ }
213
+ async getUids(folder) {
214
+ const query = `in:${this.folderToLabel(folder)}`;
215
+ const ids = await this.listMessageIds(query, 10000);
216
+ return ids.map(idToUid);
217
+ }
218
+ async close() {
219
+ // No persistent connection to close
220
+ }
221
+ /** Map folder path to Gmail label query term */
222
+ folderToLabel(path) {
223
+ const lower = path.toLowerCase();
224
+ if (lower === "inbox")
225
+ return "inbox";
226
+ if (lower === "sent" || lower === "[gmail]/sent mail")
227
+ return "sent";
228
+ if (lower === "drafts" || lower === "[gmail]/drafts")
229
+ return "drafts";
230
+ if (lower === "trash" || lower === "[gmail]/trash")
231
+ return "trash";
232
+ if (lower === "spam" || lower === "[gmail]/spam" || lower === "junk email")
233
+ return "spam";
234
+ if (lower === "archive" || lower === "[gmail]/all mail")
235
+ return "all";
236
+ // Custom label — use exact name
237
+ return `"${path}"`;
238
+ }
239
+ /** Format date for Gmail query (YYYY/MM/DD) */
240
+ formatDate(d) {
241
+ return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
242
+ }
243
+ }
244
+ //# sourceMappingURL=gmail-api.js.map