@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.
- 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 +31 -0
- package/client/lib/message-state.js +83 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +14 -0
- package/packages/mailx-imap/index.js +254 -22
- package/packages/mailx-imap/providers/gmail-api.d.ts +28 -0
- package/packages/mailx-imap/providers/gmail-api.js +227 -0
- package/packages/mailx-imap/providers/types.d.ts +60 -0
- package/packages/mailx-imap/providers/types.js +6 -0
- package/packages/mailx-settings/index.d.ts +3 -0
- package/packages/mailx-settings/index.js +6 -0
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
709
|
-
console.error(`
|
|
710
|
-
|
|
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,
|
|
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
|
-
//
|
|
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:
|
|
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) :
|
|
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
|