@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 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
- return this.batchFetch(ids, options);
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
- * Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews).
11
- * No Node-specific imports.
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 date filter */
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 (batch) */
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
- * Platform requirements: globalThis.fetch (Node 18+ and all browsers/WebViews).
11
- * No Node-specific imports.
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 res = await globalThis.fetch(`${GRAPH}${path}`, {
31
- ...options,
32
- headers: {
33
- "Authorization": `Bearer ${token}`,
34
- "Content-Type": "application/json",
35
- ...options.headers,
36
- },
37
- });
38
- if (!res.ok) {
39
- const err = await res.text().catch(() => "");
40
- throw new Error(`Graph API ${res.status}: ${err.substring(0, 200)}`);
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
- return res.json();
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 res = await globalThis.fetch(`${GRAPH}${path}`, {
48
- headers: { "Authorization": `Bearer ${token}` },
49
- });
50
- if (!res.ok) {
51
- throw new Error(`Graph API ${res.status} on raw fetch`);
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
- return res.text();
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: f.id,
64
- name: f.displayName || f.id,
249
+ path,
250
+ name,
65
251
  delimiter: "/",
66
252
  specialUse,
67
253
  flags: [],
68
254
  });
69
- // Fetch child folders
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, f.displayName);
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
- async fetchChildFolders(parentId, parentName) {
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
- const data = await this.fetch(`/mailFolders/${parentId}/childFolders?$top=100`);
82
- for (const f of data.value || []) {
83
- const path = `${parentName}/${f.displayName}`;
84
- folders.push({
85
- path: f.id,
86
- name: f.displayName,
87
- delimiter: "/",
88
- specialUse: "",
89
- flags: [],
90
- });
91
- if (f.childFolderCount > 0) {
92
- const children = await this.fetchChildFolders(f.id, path);
93
- folders.push(...children);
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 48-bit int
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 date filter */
136
- async listMessages(folderId, filter = "", top = 500) {
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
- let url = `/mailFolders/${folderId}/messages?$select=${select}&$top=${Math.min(top, 100)}&$orderby=receivedDateTime desc`;
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
- while (url && messages.length < top) {
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
- url = data["@odata.nextLink"]?.replace(`${GRAPH}`, "") || "";
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
- // Fetch recent messagescaller filters by UID
151
- const msgs = await this.listMessages(folder, "", 200);
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
- const msgs = await this.listMessages(folder, filter);
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, "", 1000);
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
- const msgs = await this.listMessages(folder, "", 500);
187
- const msg = msgs.find(m => this.idToUid(m.id) === uid);
188
- if (!msg)
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/${msg.id}/$value`);
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
- return this.parseMessage(msg, source);
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
- return msgs.map(m => this.idToUid(m.id));
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
- async close() { }
201
- /** Add RFC 2822 source to messages (batch) */
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.22",
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.53",
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.53",
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
  }