@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.
- package/client/app.js +9 -40
- package/client/components/folder-tree.js +5 -2
- package/client/components/message-list.js +95 -75
- package/client/components/message-viewer.js +33 -0
- package/client/lib/message-state.js +83 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +16 -0
- package/packages/mailx-imap/index.js +246 -10
- package/packages/mailx-imap/providers/gmail-api.d.ts +28 -0
- package/packages/mailx-imap/providers/gmail-api.js +244 -0
- package/packages/mailx-imap/providers/types.d.ts +60 -0
- package/packages/mailx-imap/providers/types.js +6 -0
- package/packages/mailx-store/db.d.ts +5 -0
- package/packages/mailx-store/db.js +4 -0
|
@@ -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.
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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:
|
|
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) :
|
|
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
|