@bobfrankston/mailx-sync 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gmail.d.ts +20 -0
- package/gmail.js +55 -1
- package/outlook.d.ts +109 -8
- package/outlook.js +524 -66
- package/package.json +3 -3
- package/types.d.ts +13 -0
package/gmail.d.ts
CHANGED
|
@@ -71,6 +71,26 @@ export declare class GmailApiProvider implements MailProvider {
|
|
|
71
71
|
/** Folder path → Gmail label id. System folders map to uppercase label
|
|
72
72
|
* constants; anything else is treated as a user label (identical name). */
|
|
73
73
|
private folderPathToLabelId;
|
|
74
|
+
/** Gmail system label ids that the API forbids renaming — labels.patch on
|
|
75
|
+
* any of these returns 400. The folder tree must refuse a rename of the
|
|
76
|
+
* folders that map to them. */
|
|
77
|
+
private static readonly SYSTEM_LABELS;
|
|
78
|
+
/** Rename (and/or reparent) a Gmail label. Gmail has no folders — a
|
|
79
|
+
* "folder" is a label and the hierarchy is encoded in the label NAME as a
|
|
80
|
+
* slash-path ("Parent/Child"). So both rename-in-place and reparent are the
|
|
81
|
+
* same operation: PATCH the label to a new full name.
|
|
82
|
+
*
|
|
83
|
+
* - `oldPath` is the current label name (== path in our folder model).
|
|
84
|
+
* - `newName` is the new LEAF name.
|
|
85
|
+
* - `newParentPath` (optional) is the destination parent label's path; when
|
|
86
|
+
* given, the new full name becomes `<newParentPath>/<newName>`, otherwise
|
|
87
|
+
* we keep the old parent prefix and just swap the leaf.
|
|
88
|
+
*
|
|
89
|
+
* System labels (INBOX/SENT/…) can't be renamed — reject with a clear
|
|
90
|
+
* message the UI surfaces. We resolve the label id from the live label list
|
|
91
|
+
* rather than trusting name-as-id, because a user label's id is an opaque
|
|
92
|
+
* `Label_NN` on some accounts even though the name matches. */
|
|
93
|
+
renameFolder(oldPath: string, newName: string, newParentPath?: string): Promise<void>;
|
|
74
94
|
getUids(folder: string): Promise<number[]>;
|
|
75
95
|
close(): Promise<void>;
|
|
76
96
|
/** Map folder path to Gmail label query term */
|
package/gmail.js
CHANGED
|
@@ -322,7 +322,18 @@ export class GmailApiProvider {
|
|
|
322
322
|
// is self-limiting. Otherwise cap at the recent 200.
|
|
323
323
|
const cap = options.since ? 0 : 200;
|
|
324
324
|
const ids = await this.listMessageIds(query, cap);
|
|
325
|
-
|
|
325
|
+
// Skip IDs already in the local store: the listing above is one cheap
|
|
326
|
+
// call, but fetching each message's metadata is a sequential per-ID GET
|
|
327
|
+
// (~200ms). Without this, a routine sync re-fetched all ~200 recent
|
|
328
|
+
// messages every time (~40s) just to discover nothing was new. With the
|
|
329
|
+
// caller's known-UID set, steady state fetches zero (Bob 2026-06-27).
|
|
330
|
+
const fresh = options.knownUids
|
|
331
|
+
? ids.filter(id => !options.knownUids.has(idToUid(id)))
|
|
332
|
+
: ids;
|
|
333
|
+
if (options.knownUids) {
|
|
334
|
+
console.log(`[gmail] fetchSince ${folder}: ${ids.length} listed, ${ids.length - fresh.length} already stored, fetching ${fresh.length}`);
|
|
335
|
+
}
|
|
336
|
+
return this.batchFetch(fresh, options);
|
|
326
337
|
}
|
|
327
338
|
async fetchByDate(folder, since, before, options = {}, onChunk) {
|
|
328
339
|
const afterDate = this.formatDate(since);
|
|
@@ -630,6 +641,49 @@ export class GmailApiProvider {
|
|
|
630
641
|
return ""; // no-op — archive is absence-of-INBOX
|
|
631
642
|
return path; // user label — name-as-id
|
|
632
643
|
}
|
|
644
|
+
/** Gmail system label ids that the API forbids renaming — labels.patch on
|
|
645
|
+
* any of these returns 400. The folder tree must refuse a rename of the
|
|
646
|
+
* folders that map to them. */
|
|
647
|
+
static SYSTEM_LABELS = new Set([
|
|
648
|
+
"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "CHAT",
|
|
649
|
+
"IMPORTANT", "STARRED", "UNREAD",
|
|
650
|
+
]);
|
|
651
|
+
/** Rename (and/or reparent) a Gmail label. Gmail has no folders — a
|
|
652
|
+
* "folder" is a label and the hierarchy is encoded in the label NAME as a
|
|
653
|
+
* slash-path ("Parent/Child"). So both rename-in-place and reparent are the
|
|
654
|
+
* same operation: PATCH the label to a new full name.
|
|
655
|
+
*
|
|
656
|
+
* - `oldPath` is the current label name (== path in our folder model).
|
|
657
|
+
* - `newName` is the new LEAF name.
|
|
658
|
+
* - `newParentPath` (optional) is the destination parent label's path; when
|
|
659
|
+
* given, the new full name becomes `<newParentPath>/<newName>`, otherwise
|
|
660
|
+
* we keep the old parent prefix and just swap the leaf.
|
|
661
|
+
*
|
|
662
|
+
* System labels (INBOX/SENT/…) can't be renamed — reject with a clear
|
|
663
|
+
* message the UI surfaces. We resolve the label id from the live label list
|
|
664
|
+
* rather than trusting name-as-id, because a user label's id is an opaque
|
|
665
|
+
* `Label_NN` on some accounts even though the name matches. */
|
|
666
|
+
async renameFolder(oldPath, newName, newParentPath) {
|
|
667
|
+
const sysId = this.folderPathToLabelId(oldPath);
|
|
668
|
+
if (GmailApiProvider.SYSTEM_LABELS.has(sysId) || sysId === "" /* All Mail */) {
|
|
669
|
+
throw new Error(`"${oldPath}" is a Gmail system label and can't be renamed.`);
|
|
670
|
+
}
|
|
671
|
+
const data = await this.fetch("/labels");
|
|
672
|
+
const labels = data.labels || [];
|
|
673
|
+
const match = labels.find(l => l.name === oldPath);
|
|
674
|
+
if (!match)
|
|
675
|
+
throw new Error(`Gmail label "${oldPath}" not found`);
|
|
676
|
+
if (match.type === "system") {
|
|
677
|
+
throw new Error(`"${oldPath}" is a Gmail system label and can't be renamed.`);
|
|
678
|
+
}
|
|
679
|
+
const newFullName = (newParentPath && newParentPath.length)
|
|
680
|
+
? `${newParentPath}/${newName}`
|
|
681
|
+
: (oldPath.includes("/") ? `${oldPath.slice(0, oldPath.lastIndexOf("/"))}/${newName}` : newName);
|
|
682
|
+
await this.fetch(`/labels/${match.id}`, {
|
|
683
|
+
method: "PATCH",
|
|
684
|
+
body: JSON.stringify({ name: newFullName }),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
633
687
|
async getUids(folder) {
|
|
634
688
|
const query = `in:${this.folderToLabel(folder)}`;
|
|
635
689
|
const ids = await this.listMessageIds(query, 10000);
|
package/outlook.d.ts
CHANGED
|
@@ -1,37 +1,138 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Microsoft Graph API provider — replaces IMAP for Outlook.com/Office 365 accounts.
|
|
3
|
-
* Uses Graph API for reliable, fast mail sync.
|
|
3
|
+
* Uses Graph API for reliable, fast mail sync with native write-back.
|
|
4
4
|
*
|
|
5
5
|
* SOURCE OF TRUTH: this file is the canonical Outlook provider for mailx.
|
|
6
6
|
* Same Provider abstraction as Gmail and IMAP, lives alongside them so future
|
|
7
7
|
* Android Outlook support uses the same code with no re-implementation.
|
|
8
|
-
* Currently only desktop instantiates it.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Modeled on gmail.ts (the gold-standard provider): shared module-level token
|
|
10
|
+
* bucket, robust 429/5xx retry with Retry-After + shared cooldown + terminal
|
|
11
|
+
* cooldown, and provider_id-first identity for every per-message op. Graph's
|
|
12
|
+
* string IDs ARE identity; the integer uid is a sort/display convenience only.
|
|
13
|
+
* See the imap_uid_not_identity lesson — any per-message op keyed solely by the
|
|
14
|
+
* hashed uid must list-and-hash, which is capped and breaks on large folders,
|
|
15
|
+
* so we always prefer the caller-supplied Graph id.
|
|
16
|
+
*
|
|
17
|
+
* Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews),
|
|
18
|
+
* atob/btoa, Uint8Array, TextDecoder, TextEncoder. No Node-specific imports
|
|
19
|
+
* (no node:*, no Buffer) — this runs in the Android WebView too.
|
|
12
20
|
*/
|
|
13
21
|
import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./types.js";
|
|
14
22
|
export declare class OutlookApiProvider implements MailProvider {
|
|
15
23
|
private tokenProvider;
|
|
16
24
|
constructor(tokenProvider: () => Promise<string>);
|
|
25
|
+
/** Hierarchical display path → Graph folder id, plus id → id (self) so a raw
|
|
26
|
+
* Graph id passed back in still resolves. Populated by listFolders. Instance-
|
|
27
|
+
* scoped (not module-shared like rateState) because folder ids are per-
|
|
28
|
+
* mailbox: two accounts share throttle state but never folder identity. */
|
|
29
|
+
private folderIds;
|
|
30
|
+
/** Turn whatever the dispatcher hands us (a hierarchical display path like
|
|
31
|
+
* "Projects/2026/Invoices", a Graph well-known name, or a raw Graph id)
|
|
32
|
+
* into a Graph folder id usable in a /mailFolders/{id} URL or as a move
|
|
33
|
+
* destinationId. Disambiguation order:
|
|
34
|
+
* (a) Graph well-known name (inbox/drafts/…) → pass through; it IS an id.
|
|
35
|
+
* (b) Known hierarchical path or already-an-id in the map → mapped id.
|
|
36
|
+
* (c) Map empty (fresh instance — listFolders hasn't run this session,
|
|
37
|
+
* as happens for move/rename which never list first) → list once to
|
|
38
|
+
* populate, then re-check.
|
|
39
|
+
* (d) Still unknown → return as-is (back-compat: assume it's a raw Graph
|
|
40
|
+
* id we simply haven't catalogued, e.g. a destination outside the
|
|
41
|
+
* listed set). */
|
|
42
|
+
private resolveFolder;
|
|
43
|
+
/** Block until (a) cooldown has elapsed and (b) a token is available.
|
|
44
|
+
* Token-bucket refill happens lazily on each call. Copy of gmail.ts's
|
|
45
|
+
* acquireToken so all providers share one throttling discipline. */
|
|
46
|
+
private acquireToken;
|
|
47
|
+
/** Compute a retry delay from the attempt number + an optional Retry-After
|
|
48
|
+
* header (seconds OR HTTP-date). Full jitter, capped. Shared by the JSON
|
|
49
|
+
* fetch and the raw fetch so both back off identically. Graph signals
|
|
50
|
+
* throttling via 429 + Retry-After — it has no 403-quota equivalent, so
|
|
51
|
+
* (unlike gmail.ts) there's no quota-403 branch. */
|
|
52
|
+
private retryDelay;
|
|
17
53
|
private fetch;
|
|
18
|
-
/** Fetch raw bytes (for RFC 2822 $value endpoint)
|
|
54
|
+
/** Fetch raw bytes (for RFC 2822 $value endpoint). Goes through the same
|
|
55
|
+
* acquireToken + 429/5xx retry discipline as the JSON fetch — a body
|
|
56
|
+
* prefetch must not side-step the throttle and re-trigger a cooldown. */
|
|
19
57
|
private fetchRaw;
|
|
20
58
|
listFolders(): Promise<ProviderFolder[]>;
|
|
59
|
+
/** Recurse into child folders. `parentPath` is the HIERARCHICAL display path
|
|
60
|
+
* of the parent (e.g. "Projects/2026"), so a child's path nests fully
|
|
61
|
+
* ("Projects/2026/Invoices") rather than flattening to one level. */
|
|
21
62
|
private fetchChildFolders;
|
|
22
63
|
/** Convert Graph message to ProviderMessage */
|
|
23
64
|
private parseMessage;
|
|
24
|
-
/** Stable integer UID from Graph string ID
|
|
65
|
+
/** Stable integer UID from Graph string ID. NOT identity — only a
|
|
66
|
+
* sort/display convenience. Every per-message op prefers the Graph id. */
|
|
25
67
|
private idToUid;
|
|
26
|
-
/** List messages in a folder with optional
|
|
68
|
+
/** List messages in a folder with optional $filter. Tracks whether
|
|
69
|
+
* pagination was capped so getUids can flag truncation for reconcile. */
|
|
27
70
|
private listMessages;
|
|
28
71
|
fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
29
72
|
fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
|
|
30
73
|
fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
|
|
31
74
|
fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
|
|
75
|
+
/** Bulk-fetch raw bodies for many UIDs in one folder. Lists the folder
|
|
76
|
+
* once to build a uid→Graph-id map, then pulls each wanted message's MIME
|
|
77
|
+
* via /$value with bounded concurrency (4 workers). We deliberately do NOT
|
|
78
|
+
* use Graph $batch multipart here: bounded-concurrency individual GETs are
|
|
79
|
+
* simpler and Graph's per-request reliability doesn't need the fragile
|
|
80
|
+
* multipart parsing. Mirrors gmail.ts's fetchBodiesIndividually shape. */
|
|
81
|
+
fetchBodiesBatch(folder: string, uids: number[], onBody: (uid: number, source: string) => void): Promise<void>;
|
|
82
|
+
/** Apply the absolute flag state to a message. Graph model: isRead boolean
|
|
83
|
+
* + flag.flagStatus. We send both so the end state matches regardless of
|
|
84
|
+
* prior state (idempotent, safe to retry). */
|
|
85
|
+
setFlags(folder: string, uid: number, flags: string[], providerId?: string): Promise<void>;
|
|
86
|
+
/** Move a message to Deleted Items. Graph's well-known folder name
|
|
87
|
+
* "deleteditems" is accepted as a move destination. */
|
|
88
|
+
trashMessage(folder: string, uid: number, providerId?: string): Promise<void>;
|
|
89
|
+
/** Move a message to another folder. `toFolder` is the client's `path` (a
|
|
90
|
+
* hierarchical display path) — resolve it to the Graph destination id. The
|
|
91
|
+
* source folder is only needed for the resolveId list-and-hash fallback,
|
|
92
|
+
* which resolveFolder handles internally via listMessages. */
|
|
93
|
+
moveMessage(fromFolder: string, uid: number, toFolder: string, providerId?: string): Promise<void>;
|
|
94
|
+
/** POST /messages/{id}/move, treating 404 as already-done.
|
|
95
|
+
*
|
|
96
|
+
* CRITICAL (differs from Gmail): a Graph move returns a NEW message id in
|
|
97
|
+
* the destination — the source id we hold becomes invalid. So if a move
|
|
98
|
+
* succeeds server-side but the ACK is lost, the queued retry re-fires with
|
|
99
|
+
* the now-stale id and Graph answers 404. Throwing there would exhaust the
|
|
100
|
+
* sync-action retries and `clearTombstoneForUid` would RESURRECT a message
|
|
101
|
+
* that was actually moved (the exact "deletions un-happening" class the
|
|
102
|
+
* Gmail provider_id fix killed). A 404 on a move means "it's no longer
|
|
103
|
+
* here" — which is the goal — so we swallow it as success. */
|
|
104
|
+
private moveById;
|
|
105
|
+
/** Rename and/or reparent a mail folder. `folderPath` and `newParentPath`
|
|
106
|
+
* are the client's hierarchical display paths — resolve each to its Graph
|
|
107
|
+
* id before use.
|
|
108
|
+
*
|
|
109
|
+
* - Rename: PATCH /mailFolders/{id} { displayName }.
|
|
110
|
+
* - Reparent: POST /mailFolders/{id}/move { destinationId }.
|
|
111
|
+
* Both can apply in one call: move first (so the folder lands under the new
|
|
112
|
+
* parent), then patch the display name.
|
|
113
|
+
*
|
|
114
|
+
* Well-known folders (inbox/sentitems/drafts/deleteditems/junkemail/archive)
|
|
115
|
+
* must not be renamed — Graph rejects it, and it would corrupt the special-
|
|
116
|
+
* use mapping. We guard on both the well-known name (the path may be the bare
|
|
117
|
+
* name) and the folder's leaf display name, lower-cased. */
|
|
118
|
+
renameFolder(folderPath: string, newName: string, newParentPath?: string): Promise<void>;
|
|
119
|
+
/** Send a full RFC822 MIME message via Graph's /sendMail. Graph accepts a
|
|
120
|
+
* base64-encoded MIME payload when the request Content-Type is text/plain
|
|
121
|
+
* and the body is the base64 string. We don't route this through the JSON
|
|
122
|
+
* fetch() helper (different content type) but still acquire a token and
|
|
123
|
+
* apply a simple inline retry. */
|
|
124
|
+
sendRaw(mime: string): Promise<void>;
|
|
125
|
+
/** Resolve a Graph message id: prefer the caller-supplied provider_id;
|
|
126
|
+
* otherwise list-and-hash (capped) and throw if not found. Centralizes the
|
|
127
|
+
* rationale shared by setFlags/trash/move — the cap means a write to a
|
|
128
|
+
* message past the most-recent ~1000 fails loudly rather than silently
|
|
129
|
+
* hitting the wrong message. */
|
|
130
|
+
private resolveId;
|
|
32
131
|
getUids(folder: string): Promise<number[]>;
|
|
33
132
|
close(): Promise<void>;
|
|
34
|
-
/** Add RFC 2822 source to messages
|
|
133
|
+
/** Add RFC 2822 source to messages by pulling each /$value. Sequential —
|
|
134
|
+
* bodies-with-source paths are already date- or uid-bounded so the count
|
|
135
|
+
* is small; bulk prefetch uses fetchBodiesBatch's worker pool instead. */
|
|
35
136
|
private addSources;
|
|
36
137
|
}
|
|
37
138
|
//# sourceMappingURL=outlook.d.ts.map
|
package/outlook.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Microsoft Graph API provider — replaces IMAP for Outlook.com/Office 365 accounts.
|
|
3
|
-
* Uses Graph API for reliable, fast mail sync.
|
|
3
|
+
* Uses Graph API for reliable, fast mail sync with native write-back.
|
|
4
4
|
*
|
|
5
5
|
* SOURCE OF TRUTH: this file is the canonical Outlook provider for mailx.
|
|
6
6
|
* Same Provider abstraction as Gmail and IMAP, lives alongside them so future
|
|
7
7
|
* Android Outlook support uses the same code with no re-implementation.
|
|
8
|
-
* Currently only desktop instantiates it.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Modeled on gmail.ts (the gold-standard provider): shared module-level token
|
|
10
|
+
* bucket, robust 429/5xx retry with Retry-After + shared cooldown + terminal
|
|
11
|
+
* cooldown, and provider_id-first identity for every per-message op. Graph's
|
|
12
|
+
* string IDs ARE identity; the integer uid is a sort/display convenience only.
|
|
13
|
+
* See the imap_uid_not_identity lesson — any per-message op keyed solely by the
|
|
14
|
+
* hashed uid must list-and-hash, which is capped and breaks on large folders,
|
|
15
|
+
* so we always prefer the caller-supplied Graph id.
|
|
16
|
+
*
|
|
17
|
+
* Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews),
|
|
18
|
+
* atob/btoa, Uint8Array, TextDecoder, TextEncoder. No Node-specific imports
|
|
19
|
+
* (no node:*, no Buffer) — this runs in the Android WebView too.
|
|
12
20
|
*/
|
|
13
21
|
const GRAPH = "https://graph.microsoft.com/v1.0/me";
|
|
14
22
|
/** Map Graph well-known folder names to specialUse */
|
|
@@ -20,37 +28,208 @@ const WELL_KNOWN = {
|
|
|
20
28
|
junkemail: "junk",
|
|
21
29
|
archive: "archive",
|
|
22
30
|
};
|
|
31
|
+
/** Rate-limit + cooldown state shared across all OutlookApiProvider instances.
|
|
32
|
+
* Graph throttles per mailbox/app, not per JS object, so sharing state here
|
|
33
|
+
* prevents a fresh `new OutlookApiProvider(...)` from side-stepping the
|
|
34
|
+
* cooldown by creating a new instance. Single-account setups won't notice;
|
|
35
|
+
* multi-account setups are slightly over-throttled, which is safer than
|
|
36
|
+
* burning through Graph's tolerance. Mirrors gmail.ts's rateState exactly. */
|
|
37
|
+
const rateState = {
|
|
38
|
+
tokens: 0,
|
|
39
|
+
lastRefill: 0,
|
|
40
|
+
cooldownUntil: 0,
|
|
41
|
+
/** Tokens refilled per second (keeps us well under Graph's per-app caps). */
|
|
42
|
+
rate: 8,
|
|
43
|
+
/** Max burst above steady state. */
|
|
44
|
+
burst: 8,
|
|
45
|
+
};
|
|
46
|
+
rateState.tokens = rateState.burst;
|
|
47
|
+
rateState.lastRefill = Date.now();
|
|
48
|
+
/** Base64-encode raw bytes without Node's Buffer. btoa wants a binary string
|
|
49
|
+
* (one char per byte); building it with String.fromCharCode(...wholeArray)
|
|
50
|
+
* blows the call-stack on large MIME, so we chunk. Used for /sendMail, where
|
|
51
|
+
* Graph expects the full RFC822 message as a base64 payload. */
|
|
52
|
+
function toBase64(bytes) {
|
|
53
|
+
let binary = "";
|
|
54
|
+
const CHUNK = 0x8000; // 32 KB per fromCharCode call — safely under arg limits
|
|
55
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
56
|
+
const slice = bytes.subarray(i, i + CHUNK);
|
|
57
|
+
binary += String.fromCharCode.apply(null, slice);
|
|
58
|
+
}
|
|
59
|
+
return btoa(binary);
|
|
60
|
+
}
|
|
61
|
+
/** Graph well-known folder identifiers. These strings are valid folder ids in
|
|
62
|
+
* any /mailFolders/{id} URL or move destinationId — Graph accepts them
|
|
63
|
+
* interchangeably with the opaque GUID. resolveFolder lets them pass through
|
|
64
|
+
* untouched so callers (trash → "deleteditems") work even on a fresh instance
|
|
65
|
+
* whose hierarchical-path map hasn't been populated yet. */
|
|
66
|
+
const WELL_KNOWN_IDS = new Set([
|
|
67
|
+
"inbox", "drafts", "sentitems", "deleteditems", "junkemail", "archive",
|
|
68
|
+
]);
|
|
23
69
|
export class OutlookApiProvider {
|
|
24
70
|
tokenProvider;
|
|
25
71
|
constructor(tokenProvider) {
|
|
26
72
|
this.tokenProvider = tokenProvider;
|
|
27
73
|
}
|
|
74
|
+
/** Hierarchical display path → Graph folder id, plus id → id (self) so a raw
|
|
75
|
+
* Graph id passed back in still resolves. Populated by listFolders. Instance-
|
|
76
|
+
* scoped (not module-shared like rateState) because folder ids are per-
|
|
77
|
+
* mailbox: two accounts share throttle state but never folder identity. */
|
|
78
|
+
folderIds = new Map();
|
|
79
|
+
/** Turn whatever the dispatcher hands us (a hierarchical display path like
|
|
80
|
+
* "Projects/2026/Invoices", a Graph well-known name, or a raw Graph id)
|
|
81
|
+
* into a Graph folder id usable in a /mailFolders/{id} URL or as a move
|
|
82
|
+
* destinationId. Disambiguation order:
|
|
83
|
+
* (a) Graph well-known name (inbox/drafts/…) → pass through; it IS an id.
|
|
84
|
+
* (b) Known hierarchical path or already-an-id in the map → mapped id.
|
|
85
|
+
* (c) Map empty (fresh instance — listFolders hasn't run this session,
|
|
86
|
+
* as happens for move/rename which never list first) → list once to
|
|
87
|
+
* populate, then re-check.
|
|
88
|
+
* (d) Still unknown → return as-is (back-compat: assume it's a raw Graph
|
|
89
|
+
* id we simply haven't catalogued, e.g. a destination outside the
|
|
90
|
+
* listed set). */
|
|
91
|
+
async resolveFolder(pathOrId) {
|
|
92
|
+
if (WELL_KNOWN_IDS.has(pathOrId))
|
|
93
|
+
return pathOrId;
|
|
94
|
+
const hit = this.folderIds.get(pathOrId);
|
|
95
|
+
if (hit)
|
|
96
|
+
return hit;
|
|
97
|
+
if (this.folderIds.size === 0) {
|
|
98
|
+
// Lazy populate: move/rename are reached without a prior listFolders
|
|
99
|
+
// on a fresh provider instance. One list call fills the map.
|
|
100
|
+
await this.listFolders();
|
|
101
|
+
const after = this.folderIds.get(pathOrId);
|
|
102
|
+
if (after)
|
|
103
|
+
return after;
|
|
104
|
+
}
|
|
105
|
+
return pathOrId;
|
|
106
|
+
}
|
|
107
|
+
/** Block until (a) cooldown has elapsed and (b) a token is available.
|
|
108
|
+
* Token-bucket refill happens lazily on each call. Copy of gmail.ts's
|
|
109
|
+
* acquireToken so all providers share one throttling discipline. */
|
|
110
|
+
async acquireToken() {
|
|
111
|
+
// Respect an active cooldown first — refuse to even try until it ends.
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
if (rateState.cooldownUntil > now) {
|
|
114
|
+
const waitMs = rateState.cooldownUntil - now;
|
|
115
|
+
console.log(` [outlook] cooldown: waiting ${(waitMs / 1000).toFixed(1)}s before next request`);
|
|
116
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
117
|
+
}
|
|
118
|
+
// Lazy refill, then spin-wait in chunks until a token is ready.
|
|
119
|
+
while (true) {
|
|
120
|
+
const t = Date.now();
|
|
121
|
+
const elapsedSec = (t - rateState.lastRefill) / 1000;
|
|
122
|
+
rateState.tokens = Math.min(rateState.burst, rateState.tokens + elapsedSec * rateState.rate);
|
|
123
|
+
rateState.lastRefill = t;
|
|
124
|
+
if (rateState.tokens >= 1) {
|
|
125
|
+
rateState.tokens -= 1;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const waitMs = Math.max(20, ((1 - rateState.tokens) / rateState.rate) * 1000);
|
|
129
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Compute a retry delay from the attempt number + an optional Retry-After
|
|
133
|
+
* header (seconds OR HTTP-date). Full jitter, capped. Shared by the JSON
|
|
134
|
+
* fetch and the raw fetch so both back off identically. Graph signals
|
|
135
|
+
* throttling via 429 + Retry-After — it has no 403-quota equivalent, so
|
|
136
|
+
* (unlike gmail.ts) there's no quota-403 branch. */
|
|
137
|
+
retryDelay(attempt, retryAfter) {
|
|
138
|
+
const baseDelayMs = 1000;
|
|
139
|
+
const maxDelayMs = 60_000;
|
|
140
|
+
let delay = baseDelayMs * Math.pow(2, attempt);
|
|
141
|
+
if (retryAfter) {
|
|
142
|
+
const asInt = parseInt(retryAfter, 10);
|
|
143
|
+
if (!isNaN(asInt))
|
|
144
|
+
delay = asInt * 1000;
|
|
145
|
+
else {
|
|
146
|
+
const when = Date.parse(retryAfter);
|
|
147
|
+
if (!isNaN(when))
|
|
148
|
+
delay = Math.max(0, when - Date.now());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
delay = Math.min(maxDelayMs, delay);
|
|
152
|
+
// Full jitter to avoid synchronized retries across parallel workers.
|
|
153
|
+
return Math.floor(delay * (0.5 + Math.random() * 0.5));
|
|
154
|
+
}
|
|
28
155
|
async fetch(path, options = {}) {
|
|
29
156
|
const token = await this.tokenProvider();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
...options
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
157
|
+
const maxAttempts = 6;
|
|
158
|
+
let lastStatus = 0;
|
|
159
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
160
|
+
await this.acquireToken();
|
|
161
|
+
const res = await globalThis.fetch(`${GRAPH}${path}`, {
|
|
162
|
+
...options,
|
|
163
|
+
headers: {
|
|
164
|
+
"Authorization": `Bearer ${token}`,
|
|
165
|
+
"Content-Type": "application/json",
|
|
166
|
+
...options.headers,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
// Graph throttling = 429 with Retry-After. Treat 5xx the same way:
|
|
170
|
+
// transient server-side, worth a backed-off retry.
|
|
171
|
+
if (res.status === 429 || res.status >= 500) {
|
|
172
|
+
lastStatus = res.status;
|
|
173
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
174
|
+
const delay = this.retryDelay(attempt, retryAfter);
|
|
175
|
+
// Install a shared cooldown so *other* in-flight workers wait
|
|
176
|
+
// too, not just this one. Prevents 5 parallel workers each
|
|
177
|
+
// retrying independently and keeping the mailbox throttled.
|
|
178
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + delay);
|
|
179
|
+
console.log(` [outlook] ${res.status} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
|
|
180
|
+
await new Promise(r => setTimeout(r, delay));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
const err = await res.text().catch(() => "");
|
|
185
|
+
throw new Error(`Graph API ${res.status}: ${err.substring(0, 200)}`);
|
|
186
|
+
}
|
|
187
|
+
// 204 No Content (PATCH/move/etc.) has no JSON body — don't choke.
|
|
188
|
+
if (res.status === 204)
|
|
189
|
+
return null;
|
|
190
|
+
const text = await res.text();
|
|
191
|
+
return text ? JSON.parse(text) : null;
|
|
41
192
|
}
|
|
42
|
-
|
|
193
|
+
// Exhausted all retries — Graph is clearly throttling hard. Park every
|
|
194
|
+
// subsequent request behind a long cooldown so the next periodic sync
|
|
195
|
+
// doesn't immediately fire another 6 retries into the same limit.
|
|
196
|
+
// 5 minutes is a floor — a longer incoming Retry-After still wins via
|
|
197
|
+
// the per-attempt cooldown set above.
|
|
198
|
+
const TERMINAL_COOLDOWN_MS = 5 * 60_000;
|
|
199
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + TERMINAL_COOLDOWN_MS);
|
|
200
|
+
console.log(` [outlook] exhausted ${maxAttempts} retries — parking further requests for ${TERMINAL_COOLDOWN_MS / 1000}s`);
|
|
201
|
+
throw new Error(`Graph API: failed after ${maxAttempts} retries (last status ${lastStatus})`);
|
|
43
202
|
}
|
|
44
|
-
/** Fetch raw bytes (for RFC 2822 $value endpoint)
|
|
203
|
+
/** Fetch raw bytes (for RFC 2822 $value endpoint). Goes through the same
|
|
204
|
+
* acquireToken + 429/5xx retry discipline as the JSON fetch — a body
|
|
205
|
+
* prefetch must not side-step the throttle and re-trigger a cooldown. */
|
|
45
206
|
async fetchRaw(path) {
|
|
46
207
|
const token = await this.tokenProvider();
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
208
|
+
const maxAttempts = 6;
|
|
209
|
+
let lastStatus = 0;
|
|
210
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
211
|
+
await this.acquireToken();
|
|
212
|
+
const res = await globalThis.fetch(`${GRAPH}${path}`, {
|
|
213
|
+
headers: { "Authorization": `Bearer ${token}` },
|
|
214
|
+
});
|
|
215
|
+
if (res.status === 429 || res.status >= 500) {
|
|
216
|
+
lastStatus = res.status;
|
|
217
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
218
|
+
const delay = this.retryDelay(attempt, retryAfter);
|
|
219
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + delay);
|
|
220
|
+
console.log(` [outlook raw] ${res.status} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
|
|
221
|
+
await new Promise(r => setTimeout(r, delay));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
const err = await res.text().catch(() => "");
|
|
226
|
+
throw new Error(`Graph API ${res.status} on raw fetch: ${err.substring(0, 200)}`);
|
|
227
|
+
}
|
|
228
|
+
return res.text();
|
|
52
229
|
}
|
|
53
|
-
|
|
230
|
+
const TERMINAL_COOLDOWN_MS = 5 * 60_000;
|
|
231
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + TERMINAL_COOLDOWN_MS);
|
|
232
|
+
throw new Error(`Graph API raw: failed after ${maxAttempts} retries (last status ${lastStatus})`);
|
|
54
233
|
}
|
|
55
234
|
async listFolders() {
|
|
56
235
|
const folders = [];
|
|
@@ -58,17 +237,29 @@ export class OutlookApiProvider {
|
|
|
58
237
|
while (url) {
|
|
59
238
|
const data = await this.fetch(url);
|
|
60
239
|
for (const f of data.value || []) {
|
|
240
|
+
const name = f.displayName || f.id;
|
|
61
241
|
const specialUse = WELL_KNOWN[f.displayName?.toLowerCase()] || "";
|
|
242
|
+
// Top-level path is just the display name. The client builds its
|
|
243
|
+
// folder TREE by splitting `path` on the delimiter, so `path`
|
|
244
|
+
// must be the HIERARCHICAL display path, not the flat Graph id —
|
|
245
|
+
// otherwise the whole tree collapses to one level and same-leaf-
|
|
246
|
+
// name folders under different parents look like duplicates.
|
|
247
|
+
const path = name;
|
|
62
248
|
folders.push({
|
|
63
|
-
path
|
|
64
|
-
name
|
|
249
|
+
path,
|
|
250
|
+
name,
|
|
65
251
|
delimiter: "/",
|
|
66
252
|
specialUse,
|
|
67
253
|
flags: [],
|
|
68
254
|
});
|
|
69
|
-
//
|
|
255
|
+
// Record both directions so resolveFolder can turn the path back
|
|
256
|
+
// into a Graph id, and a raw id round-trips unchanged.
|
|
257
|
+
this.folderIds.set(path, f.id);
|
|
258
|
+
this.folderIds.set(f.id, f.id);
|
|
259
|
+
// Fetch child folders — pass the hierarchical path (not the bare
|
|
260
|
+
// display name) so deep paths nest correctly.
|
|
70
261
|
if (f.childFolderCount > 0) {
|
|
71
|
-
const children = await this.fetchChildFolders(f.id,
|
|
262
|
+
const children = await this.fetchChildFolders(f.id, path);
|
|
72
263
|
folders.push(...children);
|
|
73
264
|
}
|
|
74
265
|
}
|
|
@@ -76,22 +267,34 @@ export class OutlookApiProvider {
|
|
|
76
267
|
}
|
|
77
268
|
return folders;
|
|
78
269
|
}
|
|
79
|
-
|
|
270
|
+
/** Recurse into child folders. `parentPath` is the HIERARCHICAL display path
|
|
271
|
+
* of the parent (e.g. "Projects/2026"), so a child's path nests fully
|
|
272
|
+
* ("Projects/2026/Invoices") rather than flattening to one level. */
|
|
273
|
+
async fetchChildFolders(parentId, parentPath) {
|
|
80
274
|
const folders = [];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
delimiter
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
275
|
+
let url = `/mailFolders/${parentId}/childFolders?$top=100`;
|
|
276
|
+
while (url) {
|
|
277
|
+
const data = await this.fetch(url);
|
|
278
|
+
for (const f of data.value || []) {
|
|
279
|
+
const name = f.displayName || f.id;
|
|
280
|
+
// Child path = parent's hierarchical path + this leaf, joined by
|
|
281
|
+
// the delimiter the client splits on.
|
|
282
|
+
const path = `${parentPath}/${name}`;
|
|
283
|
+
folders.push({
|
|
284
|
+
path,
|
|
285
|
+
name,
|
|
286
|
+
delimiter: "/",
|
|
287
|
+
specialUse: "",
|
|
288
|
+
flags: [],
|
|
289
|
+
});
|
|
290
|
+
this.folderIds.set(path, f.id);
|
|
291
|
+
this.folderIds.set(f.id, f.id);
|
|
292
|
+
if (f.childFolderCount > 0) {
|
|
293
|
+
const children = await this.fetchChildFolders(f.id, path);
|
|
294
|
+
folders.push(...children);
|
|
295
|
+
}
|
|
94
296
|
}
|
|
297
|
+
url = data["@odata.nextLink"]?.replace(`${GRAPH}`, "") || "";
|
|
95
298
|
}
|
|
96
299
|
return folders;
|
|
97
300
|
}
|
|
@@ -101,12 +304,12 @@ export class OutlookApiProvider {
|
|
|
101
304
|
? [{ name: msg.from.emailAddress.name, address: msg.from.emailAddress.address }]
|
|
102
305
|
: [];
|
|
103
306
|
const to = (msg.toRecipients || []).map((r) => ({
|
|
104
|
-
name: r.emailAddress?.name, address: r.emailAddress?.address
|
|
307
|
+
name: r.emailAddress?.name, address: r.emailAddress?.address,
|
|
105
308
|
}));
|
|
106
309
|
const cc = (msg.ccRecipients || []).map((r) => ({
|
|
107
|
-
name: r.emailAddress?.name, address: r.emailAddress?.address
|
|
310
|
+
name: r.emailAddress?.name, address: r.emailAddress?.address,
|
|
108
311
|
}));
|
|
109
|
-
// Graph uses string IDs — hash to integer UID
|
|
312
|
+
// Graph uses string IDs — hash to integer UID for sort/display.
|
|
110
313
|
const uid = this.idToUid(msg.id);
|
|
111
314
|
return {
|
|
112
315
|
uid,
|
|
@@ -115,6 +318,13 @@ export class OutlookApiProvider {
|
|
|
115
318
|
date: msg.receivedDateTime ? new Date(msg.receivedDateTime) : null,
|
|
116
319
|
subject: msg.subject || "",
|
|
117
320
|
from, to, cc,
|
|
321
|
+
// Threading headers aren't reliably returned on the list path
|
|
322
|
+
// ($select=...,internetMessageHeaders is flaky / requires a per-item
|
|
323
|
+
// expand). We leave them empty here; the full MIME pulled by
|
|
324
|
+
// fetchOne/$value or fetchBodiesBatch carries In-Reply-To/References,
|
|
325
|
+
// so threading is recovered downstream from the raw source.
|
|
326
|
+
inReplyTo: "",
|
|
327
|
+
references: [],
|
|
118
328
|
seen: msg.isRead === true,
|
|
119
329
|
flagged: msg.flag?.flagStatus === "flagged",
|
|
120
330
|
answered: false,
|
|
@@ -123,34 +333,67 @@ export class OutlookApiProvider {
|
|
|
123
333
|
source,
|
|
124
334
|
};
|
|
125
335
|
}
|
|
126
|
-
/** Stable integer UID from Graph string ID
|
|
336
|
+
/** Stable integer UID from Graph string ID. NOT identity — only a
|
|
337
|
+
* sort/display convenience. Every per-message op prefers the Graph id. */
|
|
127
338
|
idToUid(id) {
|
|
128
|
-
// Graph IDs are long base64 strings — hash to
|
|
339
|
+
// Graph IDs are long base64 strings — hash to a positive int.
|
|
129
340
|
let hash = 0;
|
|
130
341
|
for (let i = 0; i < id.length; i++) {
|
|
131
342
|
hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
|
|
132
343
|
}
|
|
133
344
|
return Math.abs(hash);
|
|
134
345
|
}
|
|
135
|
-
/** List messages in a folder with optional
|
|
136
|
-
|
|
346
|
+
/** List messages in a folder with optional $filter. Tracks whether
|
|
347
|
+
* pagination was capped so getUids can flag truncation for reconcile. */
|
|
348
|
+
async listMessages(folderId, filter = "", top = 500, ordered = true) {
|
|
349
|
+
// folderId may be a hierarchical display path (the client's `path`) — map
|
|
350
|
+
// it back to the Graph id this URL needs. Resolving here covers every
|
|
351
|
+
// read path: fetchSince/fetchByDate/fetchByUids/getUids/fetchBodiesBatch
|
|
352
|
+
// and the fetchOne/resolveId list-and-hash fallbacks all route through.
|
|
353
|
+
const id = await this.resolveFolder(folderId);
|
|
137
354
|
const messages = [];
|
|
138
355
|
const select = "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,isDraft,flag,internetMessageId,hasAttachments";
|
|
139
|
-
|
|
356
|
+
// $orderby is only worth its cost when the caller needs newest-first
|
|
357
|
+
// (the display/sync fetches). For id-only sweeps (getUids,
|
|
358
|
+
// fetchBodiesBatch, resolveId) order is irrelevant, and Graph can throw
|
|
359
|
+
// InefficientFilter when $orderby is combined with deep pagination of a
|
|
360
|
+
// large unfiltered folder — so those callers pass ordered=false.
|
|
361
|
+
let url = `/mailFolders/${id}/messages?$select=${select}&$top=${Math.min(top, 100)}`;
|
|
362
|
+
if (ordered)
|
|
363
|
+
url += `&$orderby=receivedDateTime desc`;
|
|
140
364
|
if (filter)
|
|
141
|
-
url += `&$filter=${filter}`;
|
|
142
|
-
|
|
365
|
+
url += `&$filter=${encodeURIComponent(filter)}`;
|
|
366
|
+
let truncated = false;
|
|
367
|
+
while (url) {
|
|
143
368
|
const data = await this.fetch(url);
|
|
144
369
|
messages.push(...(data.value || []));
|
|
145
|
-
|
|
370
|
+
const next = data["@odata.nextLink"]?.replace(`${GRAPH}`, "") || "";
|
|
371
|
+
if (!next)
|
|
372
|
+
break;
|
|
373
|
+
if (messages.length >= top) {
|
|
374
|
+
// Hit the caller's cap but the server has more pages. Flag it
|
|
375
|
+
// so reconcile-style callers refuse to treat this as complete.
|
|
376
|
+
truncated = true;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
url = next;
|
|
146
380
|
}
|
|
381
|
+
messages._truncated = truncated;
|
|
147
382
|
return messages;
|
|
148
383
|
}
|
|
149
384
|
async fetchSince(folder, sinceUid, options = {}) {
|
|
150
|
-
//
|
|
151
|
-
|
|
385
|
+
// The hashed uid is NOT monotonic — filtering `m.uid > sinceUid`
|
|
386
|
+
// silently drops new mail whose hash falls below the high-water mark
|
|
387
|
+
// (same trap gmail.ts documents). When the caller bounds by date, use
|
|
388
|
+
// a receivedDateTime filter; otherwise just return the recent page and
|
|
389
|
+
// let the caller reconcile by Graph id.
|
|
390
|
+
void sinceUid;
|
|
391
|
+
const filter = options.since ? `receivedDateTime ge ${options.since.toISOString()}` : "";
|
|
392
|
+
const cap = options.since ? 0 : 200;
|
|
393
|
+
// cap 0 → effectively unbounded (date filter is self-limiting); use a
|
|
394
|
+
// high ceiling so listMessages still terminates.
|
|
395
|
+
const msgs = await this.listMessages(folder, filter, cap === 0 ? 10000 : cap);
|
|
152
396
|
let results = msgs.map(m => this.parseMessage(m));
|
|
153
|
-
results = results.filter(m => m.uid > sinceUid);
|
|
154
397
|
if (options.source) {
|
|
155
398
|
results = await this.addSources(results);
|
|
156
399
|
}
|
|
@@ -158,7 +401,8 @@ export class OutlookApiProvider {
|
|
|
158
401
|
}
|
|
159
402
|
async fetchByDate(folder, since, before, options = {}, onChunk) {
|
|
160
403
|
const filter = `receivedDateTime ge ${since.toISOString()} and receivedDateTime lt ${before.toISOString()}`;
|
|
161
|
-
|
|
404
|
+
// Date-bounded — let pagination drain the full range (high ceiling).
|
|
405
|
+
const msgs = await this.listMessages(folder, filter, 10000);
|
|
162
406
|
const all = [];
|
|
163
407
|
const chunkSize = 25;
|
|
164
408
|
for (let i = 0; i < msgs.length; i += chunkSize) {
|
|
@@ -175,7 +419,7 @@ export class OutlookApiProvider {
|
|
|
175
419
|
}
|
|
176
420
|
async fetchByUids(folder, uids, options = {}) {
|
|
177
421
|
const uidSet = new Set(uids);
|
|
178
|
-
const msgs = await this.listMessages(folder, "",
|
|
422
|
+
const msgs = await this.listMessages(folder, "", 10000, false);
|
|
179
423
|
let results = msgs.map(m => this.parseMessage(m)).filter(m => uidSet.has(m.uid));
|
|
180
424
|
if (options.source) {
|
|
181
425
|
results = await this.addSources(results);
|
|
@@ -183,22 +427,236 @@ export class OutlookApiProvider {
|
|
|
183
427
|
return results;
|
|
184
428
|
}
|
|
185
429
|
async fetchOne(folder, uid, options = {}) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
430
|
+
// Caller passes providerId straight from the DB row when available —
|
|
431
|
+
// skips the list-the-whole-folder round trip entirely (mirrors
|
|
432
|
+
// gmail.ts). With the Graph id we GET the message directly, and pull
|
|
433
|
+
// the full MIME via /$value only when source is requested.
|
|
434
|
+
const id = options.providerId;
|
|
435
|
+
if (id) {
|
|
436
|
+
const select = "id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,isDraft,flag,internetMessageId,hasAttachments";
|
|
437
|
+
const msg = await this.fetch(`/messages/${id}?$select=${select}`);
|
|
438
|
+
if (!msg)
|
|
439
|
+
return null;
|
|
440
|
+
let source = "";
|
|
441
|
+
if (options.source) {
|
|
442
|
+
source = await this.fetchRaw(`/messages/${id}/$value`);
|
|
443
|
+
}
|
|
444
|
+
return this.parseMessage(msg, source);
|
|
445
|
+
}
|
|
446
|
+
// Fallback: no providerId — list-and-hash, capped. Warn because this is
|
|
447
|
+
// the slow/lossy path (a message past the cap won't be found).
|
|
448
|
+
console.warn(` [outlook] fetchOne UID ${uid} in ${folder}: no providerId, falling back to capped list-and-hash`);
|
|
449
|
+
const msgs = await this.listMessages(folder, "", 1000, false);
|
|
450
|
+
const found = msgs.find(m => this.idToUid(m.id) === uid);
|
|
451
|
+
if (!found)
|
|
189
452
|
return null;
|
|
190
453
|
let source = "";
|
|
191
454
|
if (options.source) {
|
|
192
|
-
source = await this.fetchRaw(`/messages/${
|
|
455
|
+
source = await this.fetchRaw(`/messages/${found.id}/$value`);
|
|
456
|
+
}
|
|
457
|
+
return this.parseMessage(found, source);
|
|
458
|
+
}
|
|
459
|
+
/** Bulk-fetch raw bodies for many UIDs in one folder. Lists the folder
|
|
460
|
+
* once to build a uid→Graph-id map, then pulls each wanted message's MIME
|
|
461
|
+
* via /$value with bounded concurrency (4 workers). We deliberately do NOT
|
|
462
|
+
* use Graph $batch multipart here: bounded-concurrency individual GETs are
|
|
463
|
+
* simpler and Graph's per-request reliability doesn't need the fragile
|
|
464
|
+
* multipart parsing. Mirrors gmail.ts's fetchBodiesIndividually shape. */
|
|
465
|
+
async fetchBodiesBatch(folder, uids, onBody) {
|
|
466
|
+
if (uids.length === 0)
|
|
467
|
+
return;
|
|
468
|
+
const msgs = await this.listMessages(folder, "", 10000, false);
|
|
469
|
+
const uidToId = new Map();
|
|
470
|
+
for (const m of msgs)
|
|
471
|
+
uidToId.set(this.idToUid(m.id), m.id);
|
|
472
|
+
const wanted = [];
|
|
473
|
+
for (const uid of uids) {
|
|
474
|
+
const id = uidToId.get(uid);
|
|
475
|
+
if (id)
|
|
476
|
+
wanted.push({ uid, id }); // skip uids that don't resolve
|
|
477
|
+
}
|
|
478
|
+
if (wanted.length === 0)
|
|
479
|
+
return;
|
|
480
|
+
const CONCURRENCY = 4;
|
|
481
|
+
let cursor = 0;
|
|
482
|
+
const worker = async () => {
|
|
483
|
+
while (cursor < wanted.length) {
|
|
484
|
+
const idx = cursor++;
|
|
485
|
+
const { uid, id } = wanted[idx];
|
|
486
|
+
try {
|
|
487
|
+
const source = await this.fetchRaw(`/messages/${id}/$value`);
|
|
488
|
+
onBody(uid, source);
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
// Per-message error must not abort the whole batch.
|
|
492
|
+
console.error(` [outlook prefetch] UID ${uid}: ${e.message}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, wanted.length) }, () => worker()));
|
|
497
|
+
}
|
|
498
|
+
/** Apply the absolute flag state to a message. Graph model: isRead boolean
|
|
499
|
+
* + flag.flagStatus. We send both so the end state matches regardless of
|
|
500
|
+
* prior state (idempotent, safe to retry). */
|
|
501
|
+
async setFlags(folder, uid, flags, providerId) {
|
|
502
|
+
// Prefer the caller-supplied Graph id (stored provider_id). Without it
|
|
503
|
+
// we fall back to a capped list-and-hash, which misses any message past
|
|
504
|
+
// the cap — so flagging an older message would throw, the flag would
|
|
505
|
+
// never reach Graph, and the local star would revert on next sync.
|
|
506
|
+
// Same rationale as gmail.ts's setFlags.
|
|
507
|
+
const id = await this.resolveId(folder, uid, providerId, "setFlags");
|
|
508
|
+
const flagSet = new Set(flags);
|
|
509
|
+
await this.fetch(`/messages/${id}`, {
|
|
510
|
+
method: "PATCH",
|
|
511
|
+
body: JSON.stringify({
|
|
512
|
+
isRead: flagSet.has("\\Seen"),
|
|
513
|
+
flag: { flagStatus: flagSet.has("\\Flagged") ? "flagged" : "notFlagged" },
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
/** Move a message to Deleted Items. Graph's well-known folder name
|
|
518
|
+
* "deleteditems" is accepted as a move destination. */
|
|
519
|
+
async trashMessage(folder, uid, providerId) {
|
|
520
|
+
const id = await this.resolveId(folder, uid, providerId, "trashMessage");
|
|
521
|
+
await this.moveById(id, "deleteditems");
|
|
522
|
+
}
|
|
523
|
+
/** Move a message to another folder. `toFolder` is the client's `path` (a
|
|
524
|
+
* hierarchical display path) — resolve it to the Graph destination id. The
|
|
525
|
+
* source folder is only needed for the resolveId list-and-hash fallback,
|
|
526
|
+
* which resolveFolder handles internally via listMessages. */
|
|
527
|
+
async moveMessage(fromFolder, uid, toFolder, providerId) {
|
|
528
|
+
const id = await this.resolveId(fromFolder, uid, providerId, "moveMessage");
|
|
529
|
+
const dest = await this.resolveFolder(toFolder);
|
|
530
|
+
await this.moveById(id, dest);
|
|
531
|
+
}
|
|
532
|
+
/** POST /messages/{id}/move, treating 404 as already-done.
|
|
533
|
+
*
|
|
534
|
+
* CRITICAL (differs from Gmail): a Graph move returns a NEW message id in
|
|
535
|
+
* the destination — the source id we hold becomes invalid. So if a move
|
|
536
|
+
* succeeds server-side but the ACK is lost, the queued retry re-fires with
|
|
537
|
+
* the now-stale id and Graph answers 404. Throwing there would exhaust the
|
|
538
|
+
* sync-action retries and `clearTombstoneForUid` would RESURRECT a message
|
|
539
|
+
* that was actually moved (the exact "deletions un-happening" class the
|
|
540
|
+
* Gmail provider_id fix killed). A 404 on a move means "it's no longer
|
|
541
|
+
* here" — which is the goal — so we swallow it as success. */
|
|
542
|
+
async moveById(id, destinationId) {
|
|
543
|
+
try {
|
|
544
|
+
await this.fetch(`/messages/${id}/move`, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
body: JSON.stringify({ destinationId }),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
catch (e) {
|
|
550
|
+
if (/\b404\b/.test(e?.message || ""))
|
|
551
|
+
return; // already moved / gone
|
|
552
|
+
throw e;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/** Rename and/or reparent a mail folder. `folderPath` and `newParentPath`
|
|
556
|
+
* are the client's hierarchical display paths — resolve each to its Graph
|
|
557
|
+
* id before use.
|
|
558
|
+
*
|
|
559
|
+
* - Rename: PATCH /mailFolders/{id} { displayName }.
|
|
560
|
+
* - Reparent: POST /mailFolders/{id}/move { destinationId }.
|
|
561
|
+
* Both can apply in one call: move first (so the folder lands under the new
|
|
562
|
+
* parent), then patch the display name.
|
|
563
|
+
*
|
|
564
|
+
* Well-known folders (inbox/sentitems/drafts/deleteditems/junkemail/archive)
|
|
565
|
+
* must not be renamed — Graph rejects it, and it would corrupt the special-
|
|
566
|
+
* use mapping. We guard on both the well-known name (the path may be the bare
|
|
567
|
+
* name) and the folder's leaf display name, lower-cased. */
|
|
568
|
+
async renameFolder(folderPath, newName, newParentPath) {
|
|
569
|
+
// Guard against renaming a special-use folder. The path's leaf segment is
|
|
570
|
+
// the display name Graph would also expose as a well-known name.
|
|
571
|
+
const leaf = (folderPath.split("/").pop() || folderPath).toLowerCase();
|
|
572
|
+
if (WELL_KNOWN[leaf] || WELL_KNOWN_IDS.has(folderPath.toLowerCase())) {
|
|
573
|
+
throw new Error(`"${newName}" maps to a well-known Outlook folder and can't be renamed.`);
|
|
574
|
+
}
|
|
575
|
+
const id = await this.resolveFolder(folderPath);
|
|
576
|
+
if (newParentPath && newParentPath.length) {
|
|
577
|
+
const dest = await this.resolveFolder(newParentPath);
|
|
578
|
+
await this.fetch(`/mailFolders/${id}/move`, {
|
|
579
|
+
method: "POST",
|
|
580
|
+
body: JSON.stringify({ destinationId: dest }),
|
|
581
|
+
});
|
|
193
582
|
}
|
|
194
|
-
|
|
583
|
+
await this.fetch(`/mailFolders/${id}`, {
|
|
584
|
+
method: "PATCH",
|
|
585
|
+
body: JSON.stringify({ displayName: newName }),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/** Send a full RFC822 MIME message via Graph's /sendMail. Graph accepts a
|
|
589
|
+
* base64-encoded MIME payload when the request Content-Type is text/plain
|
|
590
|
+
* and the body is the base64 string. We don't route this through the JSON
|
|
591
|
+
* fetch() helper (different content type) but still acquire a token and
|
|
592
|
+
* apply a simple inline retry. */
|
|
593
|
+
async sendRaw(mime) {
|
|
594
|
+
// Encode the MIME as UTF-8 bytes, then base64 (Buffer-free). btoa on a
|
|
595
|
+
// unicode string would mangle multi-byte chars, so go via TextEncoder.
|
|
596
|
+
const bytes = new TextEncoder().encode(mime);
|
|
597
|
+
const payload = toBase64(bytes);
|
|
598
|
+
const maxAttempts = 6;
|
|
599
|
+
let lastStatus = 0;
|
|
600
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
601
|
+
await this.acquireToken();
|
|
602
|
+
const token = await this.tokenProvider();
|
|
603
|
+
const res = await globalThis.fetch(`${GRAPH}/sendMail`, {
|
|
604
|
+
method: "POST",
|
|
605
|
+
headers: {
|
|
606
|
+
"Authorization": `Bearer ${token}`,
|
|
607
|
+
"Content-Type": "text/plain",
|
|
608
|
+
},
|
|
609
|
+
body: payload,
|
|
610
|
+
});
|
|
611
|
+
if (res.status === 429 || res.status >= 500) {
|
|
612
|
+
lastStatus = res.status;
|
|
613
|
+
const retryAfter = res.headers.get("Retry-After");
|
|
614
|
+
const delay = this.retryDelay(attempt, retryAfter);
|
|
615
|
+
rateState.cooldownUntil = Math.max(rateState.cooldownUntil, Date.now() + delay);
|
|
616
|
+
console.log(` [outlook sendMail] ${res.status} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s...`);
|
|
617
|
+
await new Promise(r => setTimeout(r, delay));
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// /sendMail returns 202 Accepted with an empty body on success.
|
|
621
|
+
if (!res.ok) {
|
|
622
|
+
const err = await res.text().catch(() => "");
|
|
623
|
+
throw new Error(`Graph sendMail ${res.status}: ${err.substring(0, 200)}`);
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
throw new Error(`Graph sendMail: failed after ${maxAttempts} retries (last status ${lastStatus})`);
|
|
628
|
+
}
|
|
629
|
+
/** Resolve a Graph message id: prefer the caller-supplied provider_id;
|
|
630
|
+
* otherwise list-and-hash (capped) and throw if not found. Centralizes the
|
|
631
|
+
* rationale shared by setFlags/trash/move — the cap means a write to a
|
|
632
|
+
* message past the most-recent ~1000 fails loudly rather than silently
|
|
633
|
+
* hitting the wrong message. */
|
|
634
|
+
async resolveId(folder, uid, providerId, op) {
|
|
635
|
+
if (providerId)
|
|
636
|
+
return providerId;
|
|
637
|
+
const msgs = await this.listMessages(folder, "", 1000, false);
|
|
638
|
+
const found = msgs.find(m => this.idToUid(m.id) === uid);
|
|
639
|
+
if (!found)
|
|
640
|
+
throw new Error(`Graph ${op}: UID ${uid} not found in ${folder} (no providerId, past list cap)`);
|
|
641
|
+
return found.id;
|
|
195
642
|
}
|
|
196
643
|
async getUids(folder) {
|
|
197
|
-
const msgs = await this.listMessages(folder, "", 10000);
|
|
198
|
-
|
|
644
|
+
const msgs = await this.listMessages(folder, "", 10000, false);
|
|
645
|
+
const result = msgs.map(m => this.idToUid(m.id));
|
|
646
|
+
// Propagate the truncation flag so reconcile refuses to delete when the
|
|
647
|
+
// server list was capped (mirrors gmail.ts) — otherwise a truncated
|
|
648
|
+
// page would look like "these are all the messages" and the rest would
|
|
649
|
+
// be deleted from the local DB.
|
|
650
|
+
if (msgs._truncated)
|
|
651
|
+
result._truncated = true;
|
|
652
|
+
return result;
|
|
653
|
+
}
|
|
654
|
+
async close() {
|
|
655
|
+
// No persistent connection to close.
|
|
199
656
|
}
|
|
200
|
-
|
|
201
|
-
|
|
657
|
+
/** Add RFC 2822 source to messages by pulling each /$value. Sequential —
|
|
658
|
+
* bodies-with-source paths are already date- or uid-bounded so the count
|
|
659
|
+
* is small; bulk prefetch uses fetchBodiesBatch's worker pool instead. */
|
|
202
660
|
async addSources(messages) {
|
|
203
661
|
for (const msg of messages) {
|
|
204
662
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx-sync",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"description": "Platform-agnostic mail provider implementations + sync orchestration. Single source of truth for Gmail/IMAP/Outlook protocol code, consumed by both desktop (Node) and Android (WebView) — eliminates the parallel mailx-imap/mailx-store-web Gmail providers that drifted in practice.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"author": "Bob Frankston",
|
|
20
20
|
"license": "ISC",
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
22
|
+
"@bobfrankston/iflow-direct": "^0.1.55",
|
|
23
23
|
"@bobfrankston/tcp-transport": "^0.1.7"
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
".transformedSnapshot": {
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
47
|
+
"@bobfrankston/iflow-direct": "^0.1.55",
|
|
48
48
|
"@bobfrankston/tcp-transport": "^0.1.7"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/types.d.ts
CHANGED
|
@@ -47,6 +47,14 @@ export interface FetchOptions {
|
|
|
47
47
|
* the default page-count cap doesn't silently truncate a large folder
|
|
48
48
|
* to the last ~200 messages. */
|
|
49
49
|
since?: Date;
|
|
50
|
+
/** UIDs already present in the local store for this folder. Providers whose
|
|
51
|
+
* incremental sync re-lists a fixed recent page (Gmail — IDs aren't
|
|
52
|
+
* monotonic, so it can't use a high-water mark) use this to SKIP fetching
|
|
53
|
+
* metadata for messages already stored: list IDs (one cheap call), drop the
|
|
54
|
+
* known ones, fetch only the genuinely-new. Steady state (nothing new) then
|
|
55
|
+
* costs one list call and zero per-message GETs instead of re-fetching 200
|
|
56
|
+
* one-by-one (~40s on mobile → near-instant) (Bob 2026-06-27). */
|
|
57
|
+
knownUids?: Set<number>;
|
|
50
58
|
}
|
|
51
59
|
/**
|
|
52
60
|
* A mail provider that can list folders, fetch messages, and perform actions.
|
|
@@ -70,6 +78,11 @@ export interface MailProvider {
|
|
|
70
78
|
* its native model — e.g. Gmail's UNREAD / STARRED labels.
|
|
71
79
|
* Optional: IMAP uses the existing STORE path in sync-manager code. */
|
|
72
80
|
setFlags?(folder: string, uid: number, flags: string[]): Promise<void>;
|
|
81
|
+
/** Rename and/or reparent a folder/label. `newParentPath` is the
|
|
82
|
+
* destination parent (Gmail: parent label path; Outlook: parent Graph id);
|
|
83
|
+
* omit for a rename-in-place. Optional: IMAP renames via the iflow client's
|
|
84
|
+
* mailboxRename, not through this interface. */
|
|
85
|
+
renameFolder?(folderPath: string, newName: string, newParentPath?: string): Promise<void>;
|
|
73
86
|
/** Close/cleanup */
|
|
74
87
|
close(): Promise<void>;
|
|
75
88
|
}
|