Skip to content

Commit a32ada2

Browse files
TobinBraunTobinBraun
andauthored
fix(outlook): connection scopes, sync reliability, and rate-limit retries (#339)
* Connection works * Unread connected to Outlook --------- Co-authored-by: TobinBraun <your.email@example.com>
1 parent e88d897 commit a32ada2

14 files changed

Lines changed: 273 additions & 174 deletions

File tree

‎connectors/outlook/src/calendar/channels.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { GraphApi } from "./graph-api";
88
// ---------------------------------------------------------------------------
99

1010
export const OUTLOOK_CALENDAR_SCOPE =
11-
"https://graph.microsoft.com/calendars.readwrite";
11+
"https://graph.microsoft.com/Calendars.ReadWrite";
1212

1313
// ---------------------------------------------------------------------------
1414
// Calendar type

‎connectors/outlook/src/calendar/graph-api.ts‎

Lines changed: 94 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -117,19 +117,27 @@ export type OutlookEvent = {
117117

118118
export type SyncState = {
119119
calendarId: string;
120-
state?: string; // deltaToken or deltaLink
120+
state?: string; // nextLink (listing mode) or deltaToken/deltaLink (delta mode)
121121
more?: boolean;
122122
min?: Date;
123123
max?: Date;
124124
sequence?: number;
125125
/**
126126
* Two-pass initial sync phase. `"quick"` walks `timeMin = now` to
127-
* front-load upcoming meetings; `"full"` walks the 2-year historical
128-
* backfill afterwards. Both phases share one sync lock; the
129-
* quick→full transition swaps state without releasing. Absent for
130-
* incremental syncs.
127+
* front-load upcoming meetings; `"full"` walks the full event history
128+
* afterwards to establish a real delta cursor (see `mode`). Both phases
129+
* share one sync lock; the quick→full transition swaps state without
130+
* releasing. Absent for incremental syncs.
131131
*/
132132
phase?: "quick" | "full";
133+
/**
134+
* Which Graph resource `state` continues. Microsoft's `/events/delta`
135+
* resource ("SyncEvents") rejects `$filter`/`$orderby`/`$select`/`$expand`/
136+
* `$search` entirely — so any request bounded by `min`/`max` must use a
137+
* plain, non-delta `/events` listing (`"listing"`) instead of the real
138+
* delta chain (`"delta"`), which only an unfiltered request may use.
139+
*/
140+
mode?: "listing" | "delta";
133141
};
134142

135143
/**
@@ -139,7 +147,12 @@ export class GraphApi {
139147
constructor(public accessToken: string) {}
140148

141149
/**
142-
* Make a request to Microsoft Graph API
150+
* Make a request to Microsoft Graph API. Retries once on 429/503, honoring
151+
* a `Retry-After` header (seconds), capped at 15s — matches the retry
152+
* behavior already in GraphMailApi.call (mail/graph-mail-api.ts). Without
153+
* this, a transient rate limit during composeChannels (mail + calendar
154+
* fetching channels concurrently at connect time) surfaces as a hard
155+
* connect-time failure instead of a brief, self-healing delay.
143156
*/
144157
public async call(
145158
method: string,
@@ -155,39 +168,52 @@ export class GraphApi {
155168
...(body ? { "Content-Type": "application/json" } : {}),
156169
};
157170

158-
const response = await fetch(url + query, {
159-
method,
160-
headers,
161-
...(body ? { body: JSON.stringify(body) } : {}),
162-
});
163-
164-
switch (response.status) {
165-
case 400:
166-
const responseBody = await response.json();
167-
throw new Error("Invalid request", { cause: responseBody });
168-
case 401:
169-
throw new Error("Authentication failed - token may be expired");
170-
case 403:
171-
throw new Error("Access denied - insufficient permissions");
172-
case 404:
173-
return null;
174-
case 410:
175-
// Gone - delta token expired, need full sync
176-
return null;
177-
case 429:
178-
throw new Error("Rate limit exceeded - too many requests");
179-
case 200:
180-
case 201:
181-
case 204:
182-
if (response.status === 204) return {}; // No content
183-
return await response.json();
184-
case 500:
185-
case 502:
186-
case 503:
187-
case 504:
188-
throw new Error(`Server error: ${response.status}`);
189-
default:
190-
throw new Error(await response.text());
171+
for (let attempt = 0; ; attempt++) {
172+
const response = await fetch(url + query, {
173+
method,
174+
headers,
175+
...(body ? { body: JSON.stringify(body) } : {}),
176+
});
177+
178+
if (
179+
(response.status === 429 || response.status === 503) &&
180+
attempt === 0
181+
) {
182+
const retryAfter = Number(response.headers.get("Retry-After") ?? "2");
183+
await new Promise((r) =>
184+
setTimeout(r, Math.min(isNaN(retryAfter) ? 2 : retryAfter, 15) * 1000)
185+
);
186+
continue;
187+
}
188+
189+
switch (response.status) {
190+
case 400:
191+
const responseBody = await response.json();
192+
throw new Error("Invalid request", { cause: responseBody });
193+
case 401:
194+
throw new Error("Authentication failed - token may be expired");
195+
case 403:
196+
throw new Error("Access denied - insufficient permissions");
197+
case 404:
198+
return null;
199+
case 410:
200+
// Gone - delta token expired, need full sync
201+
return null;
202+
case 429:
203+
throw new Error("Rate limit exceeded - too many requests");
204+
case 200:
205+
case 201:
206+
case 204:
207+
if (response.status === 204) return {}; // No content
208+
return await response.json();
209+
case 500:
210+
case 502:
211+
case 503:
212+
case 504:
213+
throw new Error(`Server error: ${response.status}`);
214+
default:
215+
throw new Error(await response.text());
216+
}
191217
}
192218
}
193219

@@ -599,7 +625,15 @@ export function transformOutlookEvent(
599625
}
600626

601627
/**
602-
* Sync calendar events using Microsoft Graph delta query
628+
* Sync calendar events using Microsoft Graph.
629+
*
630+
* Microsoft's `/events/delta` resource ("SyncEvents") rejects
631+
* `$filter`/`$orderby`/`$select`/`$expand`/`$search` outright — there is no
632+
* way to bound a delta query by date. So any request with `min`/`max` set
633+
* (the quick pass, or a manual start with an explicit window) uses a plain,
634+
* non-delta `/events` listing instead; only a fully unfiltered request may
635+
* use the real delta chain that yields a `deltaLink` for future incremental
636+
* syncs.
603637
*/
604638
export async function syncOutlookCalendar(
605639
api: GraphApi,
@@ -609,43 +643,37 @@ export async function syncOutlookCalendar(
609643
events: OutlookEvent[];
610644
state: SyncState;
611645
}> {
612-
let url: string;
646+
const resource =
647+
calendarId === "primary"
648+
? "/me/events"
649+
: `/me/calendars/${calendarId}/events`;
613650

614-
// If we have a delta link (full URL with token), use it directly
651+
const bounded = !!(state.min || state.max);
652+
const mode: "listing" | "delta" = bounded ? "listing" : "delta";
653+
654+
let url: string;
615655
if (state.state && state.state.startsWith("http")) {
656+
// Continuing pagination via a full nextLink/deltaLink URL.
616657
url = state.state;
617658
} else if (state.state) {
618-
// We have a delta token, append it to the URL
619-
const resource =
620-
calendarId === "primary"
621-
? "/me/events"
622-
: `/me/calendars/${calendarId}/events`;
659+
// Continuing a delta chain via a bare deltaToken.
623660
url = `https://graph.microsoft.com/v1.0${resource}/delta?$deltatoken=${state.state}`;
624-
} else {
625-
// Initial sync - use delta query without token
626-
const resource =
627-
calendarId === "primary"
628-
? "/me/events"
629-
: `/me/calendars/${calendarId}/events`;
630-
661+
} else if (bounded) {
662+
// Bounded request: plain (non-delta) filtered listing.
631663
const params: string[] = [];
632-
633-
// Add time filter if specified
634664
if (state.min && state.max) {
635-
// Both min and max - use combined filter
636665
params.push(
637666
`$filter=start/dateTime ge '${state.min.toISOString()}' and start/dateTime lt '${state.max.toISOString()}'`
638667
);
639668
} else if (state.min) {
640-
// Only min
641669
params.push(`$filter=start/dateTime ge '${state.min.toISOString()}'`);
642670
} else if (state.max) {
643-
// Only max
644671
params.push(`$filter=start/dateTime lt '${state.max.toISOString()}'`);
645672
}
646-
647-
const queryString = params.length > 0 ? `?${params.join("&")}` : "";
648-
url = `https://graph.microsoft.com/v1.0${resource}/delta${queryString}`;
673+
url = `https://graph.microsoft.com/v1.0${resource}?${params.join("&")}`;
674+
} else {
675+
// Unbounded: real delta query, no query params allowed.
676+
url = `https://graph.microsoft.com/v1.0${resource}/delta`;
649677
}
650678

651679
const data = (await api.call("GET", url)) as {
@@ -655,7 +683,7 @@ export async function syncOutlookCalendar(
655683
} | null;
656684

657685
if (!data) {
658-
// Delta token expired or sync failed, need full sync
686+
// Token expired (410) or resource missing (404) — restart from scratch.
659687
const newState: SyncState = {
660688
calendarId,
661689
min: state.min,
@@ -665,12 +693,14 @@ export async function syncOutlookCalendar(
665693
return syncOutlookCalendar(api, calendarId, newState);
666694
}
667695

668-
// Extract next link or delta link
696+
// Extract next link (listing or mid-delta pagination) or delta link
697+
// (delta chain complete for this page).
669698
const nextLink = data["@odata.nextLink"];
670699
const deltaLink = data["@odata.deltaLink"];
671700

672701
const nextState: SyncState = {
673702
calendarId,
703+
mode,
674704
state: nextLink || deltaLink,
675705
more: !!nextLink,
676706
min: state.min,

‎connectors/outlook/src/calendar/sync.ts‎

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1120,10 +1120,13 @@ export async function initOutlookCalendarFn(
11201120
}
11211121

11221122
// Two-pass initial sync:
1123-
// - Quick pass (`phase: "quick"`) walks `timeMin = now` so upcoming
1123+
// - Quick pass (`phase: "quick"`) walks `timeMin = now` (a plain, non-delta
1124+
// filtered /events listing — see syncOutlookCalendar) so upcoming
11241125
// meetings surface in the activity feed immediately.
1125-
// - Full pass (`phase: "full"`, queued at the terminal batch of the
1126-
// quick pass) walks the 2-year historical backfill.
1126+
// - Full pass (`phase: "full"`, queued at the terminal batch of the quick
1127+
// pass) walks the entire event history via an unfiltered /events/delta,
1128+
// which is the only way to establish the deltaLink incremental syncs
1129+
// reuse afterward.
11271130
// Both passes share the sync lock acquired above. The quick→full
11281131
// transition happens inside syncOutlookBatch without releasing.
11291132
const initialState: SyncState = {
@@ -1314,20 +1317,20 @@ export async function syncOutlookBatchFn(
13141317
}
13151318

13161319
// Quick pass done: transition to full pass without releasing
1317-
// the lock. The full pass walks the historical range
1318-
// (timeMin = 2y ago) and picks up long-running recurring
1319-
// masters that timeMin = now excluded. Any exception
1320-
// instances the quick pass buffered into pending_occ: are
1321-
// carried across; they're only cleared when the full pass
1320+
// the lock. The full pass walks the entire event history and
1321+
// picks up long-running recurring masters that timeMin = now
1322+
// excluded. It's deliberately unbounded (no min/max): Microsoft
1323+
// Graph's /events/delta resource rejects $filter entirely, so a
1324+
// bounded historical window isn't possible on delta — this pass
1325+
// has to walk everything once to establish the deltaLink that
1326+
// startIncrementalSyncFn reuses for webhook-driven syncs going
1327+
// forward (see syncOutlookCalendar in graph-api.ts). Any
1328+
// exception instances the quick pass buffered into pending_occ:
1329+
// are carried across; they're only cleared when the full pass
13221330
// completes below.
13231331
if (syncState.phase === "quick") {
1324-
const historyMin = new Date();
1325-
historyMin.setFullYear(historyMin.getFullYear() - 2);
1326-
historyMin.setMonth(0, 1);
1327-
historyMin.setHours(0, 0, 0, 0);
13281332
const fullState: SyncState = {
13291333
calendarId,
1330-
min: historyMin,
13311334
sequence: 1,
13321335
phase: "full",
13331336
};

‎connectors/outlook/src/compose.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,25 @@ export async function composeChannels(
3434
): Promise<Channel[]> {
3535
const grantedScopes = new Set(token.scopes ?? []);
3636

37-
const result: Channel[] = [];
38-
39-
for (const product of products) {
40-
const hasAllScopes = product.requiredScopes.every((s) => grantedScopes.has(s));
41-
if (!hasAllScopes) continue;
37+
// Enumerate every eligible product concurrently (each getRawChannels is an
38+
// independent network call to Microsoft Graph). Order is preserved by
39+
// enumerating results in `eligible` order, which is products' declaration
40+
// order. See connectors/google/src/compose.ts for the reference pattern —
41+
// this function used to await each product serially, which paid the sum of
42+
// every product's round-trips (plus Mail's own internal serial calls)
43+
// during the synchronous connect-time getChannels() call.
44+
const eligible = products.filter((product) =>
45+
product.requiredScopes.every((s) => grantedScopes.has(s))
46+
);
47+
const perProduct = await Promise.all(
48+
eligible.map((product) => product.getRawChannels(token))
49+
);
4250

43-
const rawChannels = await product.getRawChannels(token);
44-
for (const raw of rawChannels) {
51+
const result: Channel[] = [];
52+
eligible.forEach((product, i) => {
53+
for (const raw of perProduct[i]) {
4554
result.push(prefixChannel(product.key, raw, product.linkTypes));
4655
}
47-
}
48-
56+
});
4957
return result;
5058
}

‎connectors/outlook/src/mail/bundle.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ describe("processConversationsFn — calendar-thread bundling", () => {
8181
messages: [
8282
baseMessage({
8383
meetingMessageType: "meetingRequest",
84-
meetingRequestType: "fullUpdate",
8584
event: { iCalUId: "uid-1" },
8685
}),
8786
],

‎connectors/outlook/src/mail/channels.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { EXCLUDED_WELL_KNOWN, GraphMailApi } from "./graph-mail-api";
88
// ---------------------------------------------------------------------------
99

1010
export const OUTLOOK_MAIL_SCOPES: string[] = [
11-
"https://graph.microsoft.com/mail.readwrite",
12-
"https://graph.microsoft.com/mail.send",
11+
"https://graph.microsoft.com/Mail.ReadWrite",
12+
"https://graph.microsoft.com/Mail.Send",
1313
];
1414

1515
// ---------------------------------------------------------------------------
@@ -63,8 +63,12 @@ export async function getOutlookMailChannels(
6363
token: AuthToken
6464
): Promise<Channel[]> {
6565
const api = new GraphMailApi(token.token);
66-
const folders = await api.getMailFolders();
67-
const wellKnown = await api.getWellKnownFolderIds();
66+
// Independent Graph calls — run concurrently rather than paying the sum of
67+
// both round-trips during the synchronous connect-time getChannels() call.
68+
const [folders, wellKnown] = await Promise.all([
69+
api.getMailFolders(),
70+
api.getWellKnownFolderIds(),
71+
]);
6872

6973
const excluded = new Set(
7074
EXCLUDED_WELL_KNOWN.map((n) => wellKnown[n]).filter(Boolean) as string[]

‎connectors/outlook/src/mail/enrich.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe("enrichLinkContactsFromOutlook", () => {
123123
enrichLinkContactsFromOutlook(
124124
[link],
125125
"tok",
126-
["https://graph.microsoft.com/mail.readwrite"]
126+
["https://graph.microsoft.com/Mail.ReadWrite"]
127127
)
128128
).resolves.toBeUndefined();
129129

‎connectors/outlook/src/mail/enrich.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import type {
1616
} from "@plotday/twister/plot";
1717

1818
const GRAPH = "https://graph.microsoft.com/v1.0";
19-
const SCOPE_PEOPLE = "https://graph.microsoft.com/people.read";
20-
const SCOPE_CONTACTS = "https://graph.microsoft.com/contacts.read";
19+
const SCOPE_PEOPLE = "https://graph.microsoft.com/People.Read";
20+
const SCOPE_CONTACTS = "https://graph.microsoft.com/Contacts.Read";
2121

2222
/** Scopes the enrichment helpers depend on. Merge into the connector's
2323
* `scopes` (via `Integrations.MergeScopes`) to enable enrichment. */

0 commit comments

Comments
 (0)