m365connector 0.3.6 → 0.3.8

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.
@@ -0,0 +1,2746 @@
1
+ let jsZipRef = globalThis.JSZip || null;
2
+ let jsZipLoadPromise = null;
3
+ let jsZipLoadFailed = false;
4
+
5
+ const EMAIL_SELECTORS = [
6
+ "td.x_message",
7
+ 'div[aria-label="Message body"]',
8
+ 'div[aria-label^="Message body"]',
9
+ "div.ReadingPaneContents",
10
+ 'div[role="document"]'
11
+ ];
12
+
13
+ const GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0";
14
+ const TEN_MB = 10 * 1024 * 1024;
15
+ const CIRCUIT_FAILURE_WINDOW_MS = 5 * 60 * 1000;
16
+ const CIRCUIT_OPEN_MS = 10 * 60 * 1000;
17
+ const NEGATIVE_CACHE_MS = 30 * 60 * 1000;
18
+ const TRANSLATION_CACHE_MS = 6 * 60 * 60 * 1000;
19
+ const MAX_CACHE_ENTRIES = 500;
20
+ const HTML_ENTITY_MAP = new Map([
21
+ ["nbsp", " "],
22
+ ["amp", "&"],
23
+ ["lt", "<"],
24
+ ["gt", ">"],
25
+ ["quot", '"'],
26
+ ["apos", "'"],
27
+ ["rsquo", "'"],
28
+ ["lsquo", "'"],
29
+ ["rdquo", '"'],
30
+ ["ldquo", '"'],
31
+ ["ndash", "-"],
32
+ ["mdash", "--"],
33
+ ["hellip", "..."],
34
+ ["copy", "(c)"],
35
+ ["reg", "(r)"],
36
+ ["trade", "TM"]
37
+ ]);
38
+
39
+ function sleep(ms) {
40
+ return new Promise((resolve) => setTimeout(resolve, ms));
41
+ }
42
+
43
+ function pickContext(params) {
44
+ if (!params) {
45
+ return null;
46
+ }
47
+ return params.context || params.read_context || null;
48
+ }
49
+
50
+ function nowMs() {
51
+ return Date.now();
52
+ }
53
+
54
+ async function ensureJsZipAvailable() {
55
+ if (jsZipRef) {
56
+ return jsZipRef;
57
+ }
58
+ if (jsZipLoadFailed) {
59
+ return null;
60
+ }
61
+ if (jsZipLoadPromise) {
62
+ return jsZipLoadPromise;
63
+ }
64
+
65
+ jsZipLoadPromise = import("./vendor/jszip.min.js")
66
+ .then(() => {
67
+ jsZipRef = globalThis.JSZip || null;
68
+ if (!jsZipRef) {
69
+ jsZipLoadFailed = true;
70
+ }
71
+ return jsZipRef;
72
+ })
73
+ .catch(() => {
74
+ jsZipLoadFailed = true;
75
+ return null;
76
+ })
77
+ .finally(() => {
78
+ jsZipLoadPromise = null;
79
+ });
80
+
81
+ return jsZipLoadPromise;
82
+ }
83
+
84
+ function evictOldestEntries(map, maxEntries) {
85
+ while (map.size > maxEntries) {
86
+ const firstKey = map.keys().next().value;
87
+ if (firstKey === undefined) {
88
+ break;
89
+ }
90
+ map.delete(firstKey);
91
+ }
92
+ }
93
+
94
+ function decodeCodePointSafe(value) {
95
+ if (!Number.isFinite(value) || value < 0 || value > 0x10ffff) {
96
+ return "";
97
+ }
98
+ try {
99
+ return String.fromCodePoint(value);
100
+ } catch (_err) {
101
+ return "";
102
+ }
103
+ }
104
+
105
+ function htmlToText(value) {
106
+ if (!value) {
107
+ return "";
108
+ }
109
+
110
+ return decodeXmlEntities(
111
+ String(value)
112
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
113
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
114
+ .replace(/<br\s*\/?\s*>/gi, "\n")
115
+ .replace(/<\/p>/gi, "\n")
116
+ .replace(/<[^>]+>/g, " ")
117
+ .replace(/\r/g, "")
118
+ .replace(/\n\s+\n/g, "\n\n")
119
+ .replace(/[ \t]+/g, " ")
120
+ .trim()
121
+ );
122
+ }
123
+
124
+ function decodeXmlEntities(text) {
125
+ return String(text || "")
126
+ .replace(/&#x([0-9A-Fa-f]+);/g, (_m, hex) => decodeCodePointSafe(parseInt(hex, 16)))
127
+ .replace(/&#(\d+);/g, (_m, dec) => decodeCodePointSafe(parseInt(dec, 10)))
128
+ .replace(/&([A-Za-z][A-Za-z0-9]+);/g, (match, entity) => {
129
+ const value = HTML_ENTITY_MAP.get(entity.toLowerCase());
130
+ return value !== undefined ? value : "";
131
+ });
132
+ }
133
+
134
+ function extractTagValues(xml, tagName) {
135
+ const pattern = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g");
136
+ const values = [];
137
+ let match;
138
+ while ((match = pattern.exec(xml)) !== null) {
139
+ values.push(decodeXmlEntities(match[1]));
140
+ }
141
+ return values;
142
+ }
143
+
144
+ function getFileExtension(context = {}, driveItem = {}) {
145
+ const rawType = String(context?.metadata?.fileType || "").toLowerCase().replace(/^\./, "");
146
+ if (rawType) {
147
+ return rawType;
148
+ }
149
+
150
+ const candidates = [
151
+ String(driveItem?.name || ""),
152
+ String(context?.title || ""),
153
+ String(context?.url || ""),
154
+ String(context?.webUrl || "")
155
+ ];
156
+
157
+ for (const candidate of candidates) {
158
+ const match = candidate.toLowerCase().match(/\.([a-z0-9]{1,10})(?:\?|#|$)/);
159
+ if (match?.[1]) {
160
+ return match[1];
161
+ }
162
+ }
163
+ return "";
164
+ }
165
+
166
+ function isAllowedSharePointUrl(url) {
167
+ try {
168
+ const parsed = new URL(url);
169
+ if (parsed.protocol !== "https:") {
170
+ return false;
171
+ }
172
+
173
+ const host = parsed.hostname.toLowerCase();
174
+ return host.endsWith(".sharepoint.com")
175
+ || host.endsWith(".sharepoint-df.com")
176
+ || host === "sharepoint.com"
177
+ || host === "sharepoint-df.com";
178
+ } catch (_err) {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ function extractOutlookDeepLinkItemId(url) {
184
+ if (!url) {
185
+ return "";
186
+ }
187
+
188
+ try {
189
+ const parsed = new URL(url);
190
+ const host = parsed.hostname.toLowerCase();
191
+ if (parsed.protocol !== "https:" || host !== "outlook.office.com") {
192
+ return "";
193
+ }
194
+ if (!parsed.pathname.toLowerCase().startsWith("/mail/deeplink")) {
195
+ return "";
196
+ }
197
+ return String(parsed.searchParams.get("ItemID") || parsed.searchParams.get("itemid") || "").trim();
198
+ } catch (_err) {
199
+ return "";
200
+ }
201
+ }
202
+
203
+ function base64UrlEncode(value) {
204
+ const bytes = new TextEncoder().encode(String(value || ""));
205
+ let binary = "";
206
+ for (const byte of bytes) {
207
+ binary += String.fromCharCode(byte);
208
+ }
209
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
210
+ }
211
+
212
+ function retryAfterSeconds(headerValue) {
213
+ if (!headerValue) {
214
+ return null;
215
+ }
216
+ const asInt = parseInt(headerValue, 10);
217
+ if (Number.isInteger(asInt) && asInt >= 0) {
218
+ return asInt;
219
+ }
220
+ const asDate = Date.parse(headerValue);
221
+ if (!Number.isNaN(asDate)) {
222
+ const deltaMs = asDate - Date.now();
223
+ if (deltaMs > 0) {
224
+ return Math.ceil(deltaMs / 1000);
225
+ }
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function normalizeString(value) {
231
+ if (value === null || value === undefined) {
232
+ return "";
233
+ }
234
+ return String(value).trim();
235
+ }
236
+
237
+ function normalizePhoneList(value) {
238
+ if (!Array.isArray(value)) {
239
+ return [];
240
+ }
241
+
242
+ const phones = value
243
+ .map((entry) => {
244
+ if (typeof entry === "string") {
245
+ return normalizeString(entry);
246
+ }
247
+ if (entry && typeof entry === "object") {
248
+ return normalizeString(entry.number || entry.phoneNumber || entry.value);
249
+ }
250
+ return "";
251
+ })
252
+ .filter(Boolean);
253
+
254
+ return Array.from(new Set(phones));
255
+ }
256
+
257
+ function inferAlias(mailNickname, userPrincipalName, mail) {
258
+ const alias = normalizeString(mailNickname);
259
+ if (alias) {
260
+ return alias;
261
+ }
262
+
263
+ const upn = normalizeString(userPrincipalName);
264
+ if (upn.includes("@")) {
265
+ return upn.split("@")[0];
266
+ }
267
+
268
+ const email = normalizeString(mail);
269
+ if (email.includes("@")) {
270
+ return email.split("@")[0];
271
+ }
272
+
273
+ return "";
274
+ }
275
+
276
+ function normalizeDirectoryPerson(record, source = "users") {
277
+ const row = record && typeof record === "object" ? record : {};
278
+ const scoredEmails = Array.isArray(row.scoredEmailAddresses)
279
+ ? row.scoredEmailAddresses
280
+ .map((entry) => normalizeString(entry?.address || entry?.addressString || entry?.value))
281
+ .filter(Boolean)
282
+ : [];
283
+
284
+ const businessPhones = normalizePhoneList(row.businessPhones);
285
+ const fallbackPhones = normalizePhoneList(row.phones);
286
+ const mobilePhone = normalizeString(row.mobilePhone);
287
+ const allPhones = Array.from(
288
+ new Set(
289
+ [
290
+ mobilePhone,
291
+ ...businessPhones,
292
+ ...fallbackPhones
293
+ ].filter(Boolean)
294
+ )
295
+ );
296
+
297
+ const userPrincipalName = normalizeString(row.userPrincipalName);
298
+ const mail = normalizeString(row.mail || scoredEmails[0]);
299
+ const alias = inferAlias(row.mailNickname, userPrincipalName, mail);
300
+
301
+ return {
302
+ id: normalizeString(row.id || row.userId || row.personId),
303
+ alias,
304
+ displayName: normalizeString(row.displayName),
305
+ givenName: normalizeString(row.givenName),
306
+ surname: normalizeString(row.surname),
307
+ mail,
308
+ userPrincipalName,
309
+ jobTitle: normalizeString(row.jobTitle),
310
+ department: normalizeString(row.department),
311
+ officeLocation: normalizeString(row.officeLocation),
312
+ mobilePhone,
313
+ businessPhones,
314
+ phones: allPhones,
315
+ source
316
+ };
317
+ }
318
+
319
+ function escapeODataStringLiteral(value) {
320
+ return normalizeString(value).replace(/'/g, "''");
321
+ }
322
+
323
+ function buildUsersFieldFilter(field, query) {
324
+ const safeField = normalizeString(field);
325
+ const safeQuery = escapeODataStringLiteral(query);
326
+ return `startswith(${safeField},'${safeQuery}')`;
327
+ }
328
+
329
+ function trimForError(value, maxLen = 220) {
330
+ const compact = normalizeString(value).replace(/\s+/g, " ");
331
+ if (compact.length <= maxLen) {
332
+ return compact;
333
+ }
334
+ return `${compact.slice(0, maxLen)}...`;
335
+ }
336
+
337
+ function escapeGraphSearchValue(value) {
338
+ return normalizeString(value)
339
+ .replace(/["\\]/g, " ")
340
+ .replace(/\s+/g, " ")
341
+ .trim();
342
+ }
343
+
344
+ function buildUsersSearchClause(query) {
345
+ const token = escapeGraphSearchValue(query);
346
+ if (!token) {
347
+ return "\"displayName:\"";
348
+ }
349
+ return [
350
+ `"displayName:${token}"`,
351
+ `"mailNickname:${token}"`,
352
+ `"mail:${token}"`,
353
+ `"userPrincipalName:${token}"`
354
+ ].join(" OR ");
355
+ }
356
+
357
+ function containsFuzzy(haystack, needle) {
358
+ const left = normalizeString(haystack).toLowerCase();
359
+ const right = normalizeString(needle).toLowerCase();
360
+ if (!right) {
361
+ return true;
362
+ }
363
+ return left.includes(right);
364
+ }
365
+
366
+ function normalizePeopleFilters(params = {}) {
367
+ return {
368
+ query: normalizeString(params.query),
369
+ alias: normalizeString(params.alias),
370
+ principalName: normalizeString(params.principal_name || params.principalName || params.user_principal_name),
371
+ displayName: normalizeString(params.display_name || params.displayName),
372
+ department: normalizeString(params.department),
373
+ office: normalizeString(params.office || params.office_location),
374
+ title: normalizeString(params.title || params.job_title)
375
+ };
376
+ }
377
+
378
+ function pickSearchSeeds(filters) {
379
+ const ordered = [
380
+ filters.query,
381
+ filters.alias,
382
+ filters.principalName,
383
+ filters.displayName,
384
+ filters.department,
385
+ filters.office,
386
+ filters.title
387
+ ].filter(Boolean);
388
+ return Array.from(new Set(ordered));
389
+ }
390
+
391
+ function matchesPeopleFilters(person, filters) {
392
+ const p = person || {};
393
+ if (filters.alias && !containsFuzzy(p.alias, filters.alias)) {
394
+ return false;
395
+ }
396
+ if (filters.principalName && !containsFuzzy(p.userPrincipalName, filters.principalName)) {
397
+ return false;
398
+ }
399
+ if (filters.displayName && !containsFuzzy(p.displayName, filters.displayName)) {
400
+ return false;
401
+ }
402
+ if (filters.department && !containsFuzzy(p.department, filters.department)) {
403
+ return false;
404
+ }
405
+ if (filters.office && !containsFuzzy(p.officeLocation, filters.office)) {
406
+ return false;
407
+ }
408
+ if (filters.title && !containsFuzzy(p.jobTitle, filters.title)) {
409
+ return false;
410
+ }
411
+
412
+ if (filters.query) {
413
+ const fields = [
414
+ p.alias,
415
+ p.displayName,
416
+ p.mail,
417
+ p.userPrincipalName,
418
+ p.jobTitle,
419
+ p.department,
420
+ p.officeLocation
421
+ ];
422
+ if (!fields.some((value) => containsFuzzy(value, filters.query))) {
423
+ return false;
424
+ }
425
+ }
426
+
427
+ return true;
428
+ }
429
+
430
+ const DIRECTORY_PERSON_SELECT = [
431
+ "id",
432
+ "displayName",
433
+ "givenName",
434
+ "surname",
435
+ "mail",
436
+ "userPrincipalName",
437
+ "mailNickname",
438
+ "jobTitle",
439
+ "department",
440
+ "officeLocation",
441
+ "mobilePhone",
442
+ "businessPhones"
443
+ ].join(",");
444
+
445
+ const COMPACT_DIRECTORY_PERSON_SELECT = [
446
+ "id",
447
+ "displayName",
448
+ "mail",
449
+ "userPrincipalName",
450
+ "mailNickname",
451
+ "jobTitle",
452
+ "department",
453
+ "officeLocation"
454
+ ].join(",");
455
+
456
+ const PEOPLE_DIRECT_REPORT_CONCURRENCY = 5;
457
+ const PEOPLE_DIRECT_REPORT_TIMEOUT_MS = 5000;
458
+
459
+ function withTimeout(promise, timeoutMs, message) {
460
+ let timeoutId = null;
461
+ const timeoutPromise = new Promise((_, reject) => {
462
+ timeoutId = setTimeout(() => {
463
+ reject(new Error(message || "Operation timed out"));
464
+ }, timeoutMs);
465
+ });
466
+
467
+ return Promise.race([promise, timeoutPromise]).finally(() => {
468
+ if (timeoutId) {
469
+ clearTimeout(timeoutId);
470
+ }
471
+ });
472
+ }
473
+
474
+ async function mapWithConcurrencyLimit(items, limit, mapper) {
475
+ const results = [];
476
+ const batchSize = Math.max(1, Number(limit) || 1);
477
+
478
+ for (let start = 0; start < items.length; start += batchSize) {
479
+ const chunk = items.slice(start, start + batchSize);
480
+ const settled = await Promise.allSettled(
481
+ chunk.map((item, index) => mapper(item, start + index))
482
+ );
483
+ results.push(...settled);
484
+ }
485
+
486
+ return results;
487
+ }
488
+
489
+ function isIgnorableDirectoryLookupError(err) {
490
+ const message = String(err?.message || err);
491
+ return message.includes("Graph token unavailable or missing scope")
492
+ || message.includes("Graph permission denied (403)")
493
+ || message.includes("Graph request failed (404)");
494
+ }
495
+
496
+ function getDirectoryPersonKey(person) {
497
+ return normalizeString(person?.id || person?.userPrincipalName || person?.mail);
498
+ }
499
+
500
+ class GraphHttpError extends Error {
501
+ constructor(message, status = 0, body = "", retryAfter = null) {
502
+ super(message);
503
+ this.name = "GraphHttpError";
504
+ this.status = Number(status || 0);
505
+ this.body = body || "";
506
+ this.retryAfter = retryAfter;
507
+ }
508
+ }
509
+
510
+ export class ContentReader {
511
+ #deepLinkActive = false;
512
+
513
+ constructor(graphTokenProvider = null, logger = console) {
514
+ this.graphTokenProvider = graphTokenProvider;
515
+ this.logger = logger;
516
+ this.circuitBreakers = {
517
+ email: { failures: [], openUntil: 0 },
518
+ event: { failures: [], openUntil: 0 },
519
+ file: { failures: [], openUntil: 0 }
520
+ };
521
+ this.deniedCache = new Map();
522
+ this.translationCache = new Map();
523
+ }
524
+
525
+ async read(params = {}) {
526
+ const context = pickContext(params);
527
+ if (!context || !context.type) {
528
+ throw new Error("Invalid read context");
529
+ }
530
+ const options = { fullContent: Boolean(params.fullContent) };
531
+
532
+ switch (context.type) {
533
+ case "email":
534
+ return this.readEmail(context);
535
+ case "file":
536
+ return this.readFile(context, null, options);
537
+ case "external":
538
+ return this.readExternal(context);
539
+ case "chat":
540
+ return this.readChat(context);
541
+ case "event":
542
+ return this.readEvent(context);
543
+ default:
544
+ return {
545
+ content: context.snippet || "",
546
+ contentType: "text",
547
+ completeness: "snippet",
548
+ type: context.type,
549
+ title: context.title || "",
550
+ url: context.url || "",
551
+ metadata: context.metadata || {},
552
+ warning: `Unsupported type for open: ${context.type}`
553
+ };
554
+ }
555
+ }
556
+
557
+ async readEmail(context) {
558
+ const itemId = this.getEmailItemId(context);
559
+ this.logger.info("[m365connector][graph] readEmail: id=%s mailboxOwnerId=%s", itemId, context.mailboxOwnerId);
560
+ if (!itemId) {
561
+ return {
562
+ content: context.snippet || "",
563
+ contentType: "text",
564
+ completeness: "snippet",
565
+ type: "email",
566
+ title: context.title || "",
567
+ url: context.url || "",
568
+ metadata: {},
569
+ warning: "Email item id missing in read context"
570
+ };
571
+ }
572
+
573
+ const graphResult = await this.tryGraphRead(
574
+ "email",
575
+ itemId,
576
+ "Mail.Read",
577
+ (token) => this.readEmailViaGraph(token, context)
578
+ );
579
+
580
+ // Check if Graph returned IRM-protected or empty content that warrants deep-link fallback
581
+ const graphContentEmpty = graphResult && (!graphResult.content || !graphResult.content.trim());
582
+ const graphContentEncrypted = graphResult && this.isEncryptedEmailContent(graphResult.content);
583
+
584
+ if (graphResult && !graphContentEmpty && !graphContentEncrypted) {
585
+ return graphResult;
586
+ }
587
+
588
+ if (graphResult) {
589
+ this.logger.info("[m365connector][graph] readEmail: Graph returned %s content, trying deep-link fallback",
590
+ graphContentEncrypted ? "IRM-protected" : "empty");
591
+ }
592
+
593
+ const fallbackReason = graphContentEncrypted ? "IRM-protected" : (graphResult ? "empty Graph body" : "Graph unavailable");
594
+
595
+ let deepLinkResult = null;
596
+ try {
597
+ deepLinkResult = await this.readEmailViaDeepLink(context);
598
+ } catch (err) {
599
+ this.logger.warn("[m365connector][graph] readEmail: deep-link fallback threw", err?.message || err);
600
+ if (graphResult) {
601
+ return {
602
+ ...graphResult,
603
+ warning: `Email has ${fallbackReason}; deep-link fallback failed: ` + (err?.message || "unknown error")
604
+ };
605
+ }
606
+ }
607
+
608
+ if (deepLinkResult && deepLinkResult.completeness !== "snippet") {
609
+ // Validate that deep-link didn't also return encrypted wrapper content
610
+ if (this.isEncryptedEmailContent(deepLinkResult.content)) {
611
+ this.logger.info("[m365connector][graph] readEmail: deep-link also returned encrypted wrapper");
612
+ if (graphResult) {
613
+ return {
614
+ ...graphResult,
615
+ warning: `Email has ${fallbackReason}; deep-link also returned encrypted wrapper`
616
+ };
617
+ }
618
+ return {
619
+ ...deepLinkResult,
620
+ completeness: "snippet",
621
+ warning: `Email has ${fallbackReason}; deep-link extraction also returned encrypted wrapper`
622
+ };
623
+ }
624
+
625
+ // Merge Graph metadata into deep-link result when available
626
+ if (graphResult && graphResult.metadata) {
627
+ return {
628
+ ...deepLinkResult,
629
+ title: deepLinkResult.title || graphResult.title,
630
+ metadata: { ...graphResult.metadata, ...deepLinkResult.metadata }
631
+ };
632
+ }
633
+ return deepLinkResult;
634
+ }
635
+
636
+ // Deep-link also failed; return Graph result with metadata if available.
637
+ if (graphResult) {
638
+ return {
639
+ ...graphResult,
640
+ warning: `Email has ${fallbackReason}; deep-link extraction also failed`
641
+ };
642
+ }
643
+
644
+ return deepLinkResult || {
645
+ content: context.snippet || "",
646
+ contentType: "text",
647
+ completeness: "snippet",
648
+ type: "email",
649
+ title: context.title || "",
650
+ url: context.url || "",
651
+ metadata: {},
652
+ warning: "Both Graph and deep-link fallback failed"
653
+ };
654
+ }
655
+
656
+ async readEvent(context) {
657
+ const itemId = context.id;
658
+ this.logger.info("[m365connector][graph] readEvent: id=%s calendarOwnerId=%s", itemId, context.calendarOwnerId);
659
+ if (itemId) {
660
+ const graphResult = await this.tryGraphRead(
661
+ "event",
662
+ itemId,
663
+ "Calendars.Read",
664
+ (token) => this.readEventViaGraph(token, context)
665
+ );
666
+
667
+ if (graphResult) {
668
+ return graphResult;
669
+ }
670
+ }
671
+
672
+ return this.readEventFromMetadata(context);
673
+ }
674
+
675
+ async readFile(context, warningOverride = null, options = {}) {
676
+ const itemId = context.id;
677
+ this.logger.info("[m365connector][graph] readFile: id=%s webUrl=%s", itemId, String(context.webUrl || "").substring(0, 60));
678
+
679
+ if (itemId || context.webUrl) {
680
+ const requiredScope = this.isSharePointResource(context) ? "Sites.Read.All" : "Files.Read.All";
681
+ const graphResult = await this.tryGraphRead(
682
+ "file",
683
+ itemId || context.webUrl,
684
+ requiredScope,
685
+ (token) => this.readFileViaGraph(token, context)
686
+ );
687
+
688
+ if (graphResult && graphResult.completeness === "full") {
689
+ return graphResult;
690
+ }
691
+
692
+ if (graphResult && graphResult.completeness === "partial") {
693
+ if (options.fullContent) {
694
+ this.logger.info("[m365connector] readFile: Graph returned partial result, trying deep-link fallback (fullContent=true)");
695
+ const deepLinkResult = await this.readFileViaDeepLink(context, options);
696
+ if (deepLinkResult && !deepLinkResult.skipped) {
697
+ return {
698
+ ...deepLinkResult,
699
+ metadata: { ...(graphResult.metadata || {}), ...(deepLinkResult.metadata || {}) }
700
+ };
701
+ }
702
+ if (deepLinkResult?.skipped === "busy") {
703
+ graphResult.warning = (graphResult.warning ? graphResult.warning + ". " : "")
704
+ + "Another fullContent request is in progress. Retry with fullContent=true after it completes to get the full document. Read fullContent documents sequentially, not in parallel.";
705
+ }
706
+ }
707
+ return graphResult;
708
+ }
709
+ }
710
+
711
+ if (options.fullContent) {
712
+ this.logger.info("[m365connector] readFile: Graph unavailable, trying deep-link fallback (fullContent=true)");
713
+ const deepLinkResult = await this.readFileViaDeepLink(context, options);
714
+ if (deepLinkResult && !deepLinkResult.skipped) {
715
+ return deepLinkResult;
716
+ }
717
+ if (deepLinkResult?.skipped === "busy") {
718
+ const fallback = await this.readFileFallback(context, warningOverride);
719
+ fallback.warning = (fallback.warning ? fallback.warning + ". " : "")
720
+ + "Another fullContent request is in progress. Retry with fullContent=true after it completes to get the full document. Read fullContent documents sequentially, not in parallel.";
721
+ return fallback;
722
+ }
723
+ }
724
+
725
+ return this.readFileFallback(context, warningOverride);
726
+ }
727
+
728
+ readExternal(context) {
729
+ const content = String(context.summary || context.snippet || "");
730
+ return {
731
+ content,
732
+ contentType: "text",
733
+ completeness: content.length > 2000 ? "full" : "partial",
734
+ type: "external",
735
+ title: context.title || "",
736
+ url: context.url || "",
737
+ metadata: context.metadata || {},
738
+ warning: undefined
739
+ };
740
+ }
741
+
742
+ readChat(context) {
743
+ return {
744
+ content: String(context.preview || context.snippet || ""),
745
+ contentType: "text",
746
+ completeness: "snippet",
747
+ type: "chat",
748
+ title: context.title || "",
749
+ url: context.url || "",
750
+ metadata: context.metadata || {},
751
+ warning: "Chat full-thread reading is unavailable without Graph Chat.Read scope"
752
+ };
753
+ }
754
+
755
+ readEventFromMetadata(context) {
756
+ const metadata = context.metadata || {};
757
+ const contentParts = [
758
+ metadata.subject ? `Subject: ${metadata.subject}` : "",
759
+ metadata.startTime ? `Start: ${metadata.startTime}` : "",
760
+ metadata.endTime ? `End: ${metadata.endTime}` : "",
761
+ metadata.location ? `Location: ${metadata.location}` : "",
762
+ metadata.organizer ? `Organizer: ${metadata.organizer}` : "",
763
+ Array.isArray(metadata.attendees) && metadata.attendees.length
764
+ ? `Attendees: ${metadata.attendees.join(", ")}`
765
+ : "",
766
+ metadata.bodyPreview ? `Body Preview: ${metadata.bodyPreview}` : "",
767
+ metadata.meetingUrl ? `Meeting URL: ${metadata.meetingUrl}` : ""
768
+ ].filter(Boolean);
769
+
770
+ return {
771
+ content: contentParts.join("\n"),
772
+ contentType: "text",
773
+ completeness: "full",
774
+ type: "event",
775
+ title: context.title || "",
776
+ url: context.url || "",
777
+ metadata,
778
+ warning: undefined
779
+ };
780
+ }
781
+
782
+ async getGraphTokenForScopes(scopes = []) {
783
+ if (!this.graphTokenProvider) {
784
+ return { token: null, scope: null };
785
+ }
786
+
787
+ const candidates = Array.isArray(scopes) ? scopes.filter(Boolean) : [];
788
+ for (const scope of candidates) {
789
+ const token = await this.graphTokenProvider.getToken(scope);
790
+ if (token) {
791
+ return { token, scope };
792
+ }
793
+ }
794
+
795
+ return { token: null, scope: null };
796
+ }
797
+
798
+ async fetchGraphJsonWithScopes(scopes, pathOrUrl, options = {}) {
799
+ const scopeCandidates = Array.isArray(scopes) ? scopes.filter(Boolean) : [];
800
+ if (!scopeCandidates.length) {
801
+ throw new Error("At least one scope is required");
802
+ }
803
+
804
+ let { token } = await this.getGraphTokenForScopes(scopeCandidates);
805
+ if (!token) {
806
+ throw new Error(`Graph token unavailable or missing scope: ${scopeCandidates.join(" or ")}`);
807
+ }
808
+
809
+ try {
810
+ return await this.fetchGraphJson(token, pathOrUrl, options);
811
+ } catch (err) {
812
+ if (Number(err?.status || 0) === 401 && this.graphTokenProvider) {
813
+ await this.graphTokenProvider.refresh();
814
+ const refreshed = await this.getGraphTokenForScopes(scopeCandidates);
815
+ token = refreshed.token;
816
+ if (!token) {
817
+ throw new Error(`Graph token unavailable or missing scope: ${scopeCandidates.join(" or ")}`);
818
+ }
819
+ return this.fetchGraphJson(token, pathOrUrl, options);
820
+ }
821
+
822
+ if (err instanceof GraphHttpError) {
823
+ if (err.status === 403) {
824
+ throw new Error(`Graph permission denied (403). Required scope: ${scopeCandidates.join(" or ")}`);
825
+ }
826
+ const detail = trimForError(err.body);
827
+ const suffix = detail ? `: ${detail}` : "";
828
+ throw new Error(`Graph request failed (${err.status || "unknown"})${suffix}`);
829
+ }
830
+
831
+ throw err;
832
+ }
833
+ }
834
+
835
+ async queryUsersBySearch(query, size, select) {
836
+ const searchClause = buildUsersSearchClause(query);
837
+ const path = `/users?$top=${size}`
838
+ + `&$count=true`
839
+ + `&$select=${encodeURIComponent(select)}`
840
+ + `&$search=${encodeURIComponent(searchClause)}`;
841
+
842
+ const response = await this.fetchGraphJsonWithScopes(
843
+ ["User.ReadBasic.All", "User.Read.All", "Directory.Read.All"],
844
+ path,
845
+ {
846
+ headers: {
847
+ ConsistencyLevel: "eventual"
848
+ }
849
+ }
850
+ );
851
+
852
+ return Array.isArray(response?.value) ? response.value : [];
853
+ }
854
+
855
+ async queryUsersByField(field, query, size, select) {
856
+ const path = `/users?$top=${size}`
857
+ + `&$select=${encodeURIComponent(select)}`
858
+ + `&$filter=${encodeURIComponent(buildUsersFieldFilter(field, query))}`;
859
+
860
+ const response = await this.fetchGraphJsonWithScopes(
861
+ ["User.ReadBasic.All", "User.Read.All", "Directory.Read.All"],
862
+ path
863
+ );
864
+
865
+ return Array.isArray(response?.value) ? response.value : [];
866
+ }
867
+
868
+ async fetchDirectReports(path, size, source = "directReport") {
869
+ const safeSize = Number.isInteger(size)
870
+ ? Math.min(100, Math.max(1, size))
871
+ : 30;
872
+ const response = await this.fetchGraphJsonWithScopes(
873
+ ["User.Read.All", "Directory.Read.All"],
874
+ `${path}?$count=true&$top=${safeSize}&$select=${encodeURIComponent(COMPACT_DIRECTORY_PERSON_SELECT)}`,
875
+ {
876
+ headers: {
877
+ ConsistencyLevel: "eventual"
878
+ }
879
+ }
880
+ );
881
+
882
+ const rows = Array.isArray(response?.value) ? response.value : [];
883
+ const directReports = rows.map((row) => normalizeDirectoryPerson(row, source));
884
+ const directReportsCount = Number.isInteger(response?.["@odata.count"])
885
+ ? response["@odata.count"]
886
+ : rows.length;
887
+
888
+ return {
889
+ directReports,
890
+ directReportsCount
891
+ };
892
+ }
893
+
894
+ buildPeopleQuery(filters, size, options = {}) {
895
+ const safeSize = Number.isInteger(size)
896
+ ? Math.max(1, size)
897
+ : 20;
898
+ const searchSeeds = pickSearchSeeds(filters);
899
+ const activeSearchSeed = normalizeString(options.searchSeed);
900
+ const searchClause = activeSearchSeed
901
+ ? buildUsersSearchClause(activeSearchSeed)
902
+ : "";
903
+
904
+ const graphFilters = [];
905
+ if (filters.department) {
906
+ graphFilters.push(buildUsersFieldFilter("department", filters.department));
907
+ }
908
+ if (filters.office) {
909
+ graphFilters.push(buildUsersFieldFilter("officeLocation", filters.office));
910
+ }
911
+ if (filters.title) {
912
+ graphFilters.push(buildUsersFieldFilter("jobTitle", filters.title));
913
+ }
914
+ const filterClause = graphFilters.join(" and ");
915
+ const requiresAdvancedQuery = Boolean(searchClause || filters.office || options.forceConsistencyLevel);
916
+
917
+ let path = `/users?$top=${safeSize}&$select=${encodeURIComponent(DIRECTORY_PERSON_SELECT)}`;
918
+ if (options.includeCount || searchClause || filters.office) {
919
+ path += "&$count=true";
920
+ }
921
+ if (searchClause) {
922
+ path += `&$search=${encodeURIComponent(searchClause)}`;
923
+ }
924
+ if (filterClause) {
925
+ path += `&$filter=${encodeURIComponent(filterClause)}`;
926
+ }
927
+
928
+ const requestOptions = requiresAdvancedQuery
929
+ ? {
930
+ headers: {
931
+ ConsistencyLevel: "eventual"
932
+ }
933
+ }
934
+ : {};
935
+
936
+ return {
937
+ path,
938
+ requestOptions,
939
+ searchClause,
940
+ searchSeeds,
941
+ activeSearchSeed
942
+ };
943
+ }
944
+
945
+ async fetchUsersRows(path, requestOptions = {}, options = {}) {
946
+ const followNextLink = Boolean(options.followNextLink);
947
+ const maxRows = Number.isInteger(options.maxRows) ? Math.max(1, options.maxRows) : Number.MAX_SAFE_INTEGER;
948
+ const rows = [];
949
+ let nextRef = path;
950
+
951
+ while (nextRef && rows.length < maxRows) {
952
+ const response = await this.fetchGraphJsonWithScopes(
953
+ ["User.ReadBasic.All", "User.Read.All", "Directory.Read.All"],
954
+ nextRef,
955
+ requestOptions
956
+ );
957
+ const pageRows = Array.isArray(response?.value) ? response.value : [];
958
+ rows.push(...pageRows);
959
+ if (!followNextLink || !response?.["@odata.nextLink"]) {
960
+ break;
961
+ }
962
+ nextRef = response["@odata.nextLink"];
963
+ }
964
+
965
+ return rows.slice(0, maxRows);
966
+ }
967
+
968
+ async queryPeopleFallbackRows(query, size, options = {}) {
969
+ const fields = query.includes("@")
970
+ ? ["mail", "userPrincipalName", "mailNickname", "displayName"]
971
+ : ["displayName", "mailNickname", "userPrincipalName", "mail", "givenName", "surname"];
972
+
973
+ const merged = new Map();
974
+ const followNextLink = Boolean(options.followNextLink);
975
+ const maxRows = Number.isInteger(options.maxRows) ? Math.max(1, options.maxRows) : size;
976
+ for (const field of fields) {
977
+ try {
978
+ const fieldPath = `/users?$top=${size}`
979
+ + `&$select=${encodeURIComponent(DIRECTORY_PERSON_SELECT)}`
980
+ + `&$filter=${encodeURIComponent(buildUsersFieldFilter(field, query))}`;
981
+ const fieldRows = await this.fetchUsersRows(fieldPath, {}, { followNextLink, maxRows });
982
+ for (const row of fieldRows) {
983
+ const person = normalizeDirectoryPerson(row, "users");
984
+ const key = getDirectoryPersonKey(person);
985
+ if (!key || merged.has(key)) {
986
+ continue;
987
+ }
988
+ merged.set(key, person);
989
+ if (!followNextLink && merged.size >= maxRows) {
990
+ break;
991
+ }
992
+ }
993
+ if (!followNextLink && merged.size >= maxRows) {
994
+ break;
995
+ }
996
+ } catch (fieldErr) {
997
+ const fieldMessage = String(fieldErr?.message || fieldErr);
998
+ if (!fieldMessage.includes("Graph request failed (400)")) {
999
+ throw fieldErr;
1000
+ }
1001
+ }
1002
+ }
1003
+
1004
+ return Array.from(merged.values());
1005
+ }
1006
+
1007
+ async queryPeopleResults(filters, size, options = {}) {
1008
+ const textSeeds = pickSearchSeeds({
1009
+ query: filters.query,
1010
+ alias: filters.alias,
1011
+ principalName: filters.principalName,
1012
+ displayName: filters.displayName
1013
+ });
1014
+ const searchSeeds = textSeeds.length ? textSeeds : [""];
1015
+ const merged = new Map();
1016
+ const followNextLink = Boolean(options.followNextLink);
1017
+ const maxRows = Number.isInteger(options.maxRows) ? Math.max(1, options.maxRows) : size;
1018
+
1019
+ for (const searchSeed of searchSeeds) {
1020
+ const { path, requestOptions, searchClause } = this.buildPeopleQuery(filters, size, {
1021
+ ...options,
1022
+ searchSeed
1023
+ });
1024
+
1025
+ let rows = [];
1026
+ try {
1027
+ rows = await this.fetchUsersRows(path, requestOptions, { followNextLink, maxRows });
1028
+ } catch (err) {
1029
+ const message = String(err?.message || err);
1030
+ if (!searchClause || !message.includes("Graph request failed (400)")) {
1031
+ throw err;
1032
+ }
1033
+ rows = await this.queryPeopleFallbackRows(searchSeed, size, { followNextLink, maxRows });
1034
+ }
1035
+
1036
+ for (const row of rows) {
1037
+ const person = row && row.source ? row : normalizeDirectoryPerson(row, "users");
1038
+ const key = getDirectoryPersonKey(person);
1039
+ if (!key || merged.has(key)) {
1040
+ continue;
1041
+ }
1042
+ merged.set(key, person);
1043
+ }
1044
+
1045
+ if (!followNextLink) {
1046
+ const matchedCount = Array.from(merged.values())
1047
+ .filter((row) => row.displayName || row.mail || row.userPrincipalName)
1048
+ .filter((row) => matchesPeopleFilters(row, filters))
1049
+ .length;
1050
+ if (matchedCount >= maxRows) {
1051
+ break;
1052
+ }
1053
+ }
1054
+ }
1055
+
1056
+ return Array.from(merged.values())
1057
+ .filter((row) => row.displayName || row.mail || row.userPrincipalName)
1058
+ .filter((row) => matchesPeopleFilters(row, filters));
1059
+ }
1060
+
1061
+ async whoAmI(options = {}) {
1062
+ const directReportsSize = Number.isInteger(options.directReportsSize)
1063
+ ? Math.min(100, Math.max(1, options.directReportsSize))
1064
+ : 30;
1065
+ const me = await this.fetchGraphJsonWithScopes(
1066
+ ["User.Read", "User.ReadBasic.All", "User.Read.All", "Directory.Read.All"],
1067
+ `/me?$select=${encodeURIComponent(DIRECTORY_PERSON_SELECT)}`
1068
+ );
1069
+
1070
+ let manager = null;
1071
+ try {
1072
+ const managerRaw = await this.fetchGraphJsonWithScopes(
1073
+ ["User.Read.All", "Directory.Read.All"],
1074
+ `/me/manager?$select=${encodeURIComponent(DIRECTORY_PERSON_SELECT)}`
1075
+ );
1076
+
1077
+ if (managerRaw && typeof managerRaw === "object") {
1078
+ manager = normalizeDirectoryPerson(managerRaw, "manager");
1079
+ }
1080
+ } catch (err) {
1081
+ if (!isIgnorableDirectoryLookupError(err)) {
1082
+ this.logger.warn("[m365connector][graph] whoAmI manager lookup failed:", String(err?.message || err));
1083
+ }
1084
+ }
1085
+
1086
+ let directReports = [];
1087
+ let directReportsCount = 0;
1088
+ try {
1089
+ const directReportsResponse = await this.fetchDirectReports(
1090
+ "/me/directReports",
1091
+ directReportsSize,
1092
+ "directReport"
1093
+ );
1094
+ directReports = directReportsResponse.directReports;
1095
+ directReportsCount = directReportsResponse.directReportsCount;
1096
+ } catch (err) {
1097
+ if (!isIgnorableDirectoryLookupError(err)) {
1098
+ this.logger.warn("[m365connector][graph] whoAmI directReports lookup failed:", String(err?.message || err));
1099
+ }
1100
+ }
1101
+
1102
+ return {
1103
+ ...normalizeDirectoryPerson(me, "me"),
1104
+ manager,
1105
+ directReports,
1106
+ directReportsCount
1107
+ };
1108
+ }
1109
+
1110
+ async findPeople(params = {}) {
1111
+ const filters = normalizePeopleFilters(params);
1112
+ if (!Object.values(filters).some(Boolean)) {
1113
+ throw new Error("At least one people filter is required");
1114
+ }
1115
+
1116
+ const size = Number.isInteger(params.size)
1117
+ ? Math.min(50, Math.max(1, params.size))
1118
+ : 20;
1119
+ const includeDirectReports = Boolean(params.include_direct_reports || params.includeDirectReports);
1120
+ const directReportsSize = Number.isInteger(params.direct_reports_size)
1121
+ ? Math.min(100, Math.max(1, params.direct_reports_size))
1122
+ : Number.isInteger(params.directReportsSize)
1123
+ ? Math.min(100, Math.max(1, params.directReportsSize))
1124
+ : 30;
1125
+ let results = (await this.queryPeopleResults(filters, size)).slice(0, size);
1126
+
1127
+ if (includeDirectReports && results.length) {
1128
+ const settled = await mapWithConcurrencyLimit(
1129
+ results,
1130
+ PEOPLE_DIRECT_REPORT_CONCURRENCY,
1131
+ async (person) => {
1132
+ if (!person.id) {
1133
+ throw new Error("Missing user id for directReports lookup");
1134
+ }
1135
+ return withTimeout(
1136
+ this.fetchDirectReports(
1137
+ `/users/${encodeURIComponent(person.id)}/directReports`,
1138
+ directReportsSize,
1139
+ "directReport"
1140
+ ),
1141
+ PEOPLE_DIRECT_REPORT_TIMEOUT_MS,
1142
+ `directReports lookup timed out for ${person.id}`
1143
+ );
1144
+ }
1145
+ );
1146
+
1147
+ results = results.map((person, index) => {
1148
+ const outcome = settled[index];
1149
+ if (!outcome || outcome.status !== "fulfilled") {
1150
+ return {
1151
+ ...person,
1152
+ directReports: null,
1153
+ directReportsCount: null
1154
+ };
1155
+ }
1156
+ return {
1157
+ ...person,
1158
+ directReports: outcome.value.directReports,
1159
+ directReportsCount: outcome.value.directReportsCount
1160
+ };
1161
+ });
1162
+ }
1163
+
1164
+ return {
1165
+ filters,
1166
+ results,
1167
+ total: results.length
1168
+ };
1169
+ }
1170
+
1171
+ async countPeople(params = {}) {
1172
+ const filters = normalizePeopleFilters(params);
1173
+ if (!Object.values(filters).some(Boolean)) {
1174
+ throw new Error("At least one people filter is required");
1175
+ }
1176
+
1177
+ const results = await this.queryPeopleResults(filters, 999, {
1178
+ includeCount: true,
1179
+ forceConsistencyLevel: true,
1180
+ followNextLink: true,
1181
+ maxRows: Number.MAX_SAFE_INTEGER
1182
+ });
1183
+
1184
+ return {
1185
+ filters,
1186
+ count: results.length
1187
+ };
1188
+ }
1189
+
1190
+ readFileFallback(context, warningOverride = null) {
1191
+ const content = String(context.summary || context.snippet || "");
1192
+ return {
1193
+ content,
1194
+ contentType: "text",
1195
+ completeness: "partial",
1196
+ type: "file",
1197
+ title: context.title || "",
1198
+ url: context.url || "",
1199
+ metadata: context.metadata || {},
1200
+ warning: warningOverride || "File content comes from extracted summary, not full document bytes"
1201
+ };
1202
+ }
1203
+
1204
+ buildOfficeEditUrl(webUrl) {
1205
+ try {
1206
+ const url = new URL(webUrl);
1207
+ url.searchParams.set("action", "edit");
1208
+ return url.toString();
1209
+ } catch (_err) {
1210
+ const separator = String(webUrl || "").includes("?") ? "&" : "?";
1211
+ return `${webUrl}${separator}action=edit`;
1212
+ }
1213
+ }
1214
+
1215
+ async waitForEditorInAnyFrame(tabId, deadline) {
1216
+ const pollEnd = Math.min(Date.now() + 60000, deadline);
1217
+
1218
+ while (Date.now() < pollEnd) {
1219
+ try {
1220
+ const results = await chrome.scripting.executeScript({
1221
+ target: { tabId, allFrames: true },
1222
+ func: () => {
1223
+ const host = location.hostname.toLowerCase();
1224
+ if (!host.endsWith(".officeapps.live.com")) {
1225
+ return null;
1226
+ }
1227
+ const hasPanel = Boolean(document.getElementById("WACViewPanel"));
1228
+ const hasSlide = Boolean(
1229
+ document.querySelector('[class*="slide"]')
1230
+ || document.querySelector('[class*="Slide"]')
1231
+ );
1232
+ if (!hasPanel && !hasSlide) {
1233
+ return null;
1234
+ }
1235
+ return "ready";
1236
+ }
1237
+ });
1238
+
1239
+ if (results && results.some((r) => r.result === "ready")) {
1240
+ return true;
1241
+ }
1242
+ } catch (_err) {
1243
+ // Frames may not be ready yet; keep polling.
1244
+ }
1245
+ await sleep(1000);
1246
+ }
1247
+
1248
+ return false;
1249
+ }
1250
+
1251
+ /**
1252
+ * Scroll through the Word Online document inside the iframe so that all
1253
+ * virtually-rendered pages get materialised in the DOM. Returns the number
1254
+ * of scroll steps performed (0 means no scrollable container was found).
1255
+ */
1256
+ async scrollToLoadAllPages(tabId, budgetMs = 60000) {
1257
+ const stepEnd = Date.now() + Math.min(budgetMs, 60000);
1258
+ let totalSteps = 0;
1259
+
1260
+ // Temporarily activate the tab — Word Online's virtual renderer only
1261
+ // works in the focused window's active tab. We restore the user's
1262
+ // previous tab after scrolling completes.
1263
+ let previousTabId = null;
1264
+ try {
1265
+ const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
1266
+ if (activeTab) previousTabId = activeTab.id;
1267
+ await chrome.tabs.update(tabId, { active: true });
1268
+ await sleep(500);
1269
+ } catch (_err) {
1270
+ // If we can't activate, proceed anyway.
1271
+ }
1272
+
1273
+ // Strategy: repeatedly jump to the bottom of the scroll container.
1274
+ // Word Online lazily adds pages as the viewport approaches them, so each
1275
+ // jump-to-bottom may increase scrollHeight as new pages materialise.
1276
+ // We keep jumping until scrollHeight stabilises (no growth for 5 rounds).
1277
+ let prevScrollHeight = 0;
1278
+ let stableRounds = 0;
1279
+
1280
+ while (Date.now() < stepEnd && stableRounds < 5) {
1281
+ try {
1282
+ const results = await chrome.scripting.executeScript({
1283
+ target: { tabId, allFrames: true },
1284
+ func: () => {
1285
+ const panel = document.getElementById("WACViewPanel");
1286
+ if (!panel) return null;
1287
+
1288
+ // Find the scrollable container
1289
+ let scroller = null;
1290
+ let el = panel;
1291
+ while (el) {
1292
+ if (el.scrollHeight > el.clientHeight + 20) {
1293
+ scroller = el;
1294
+ break;
1295
+ }
1296
+ el = el.parentElement;
1297
+ }
1298
+ if (!scroller) return { done: true, scrollHeight: 0, pages: 0 };
1299
+
1300
+ // Jump to the very bottom
1301
+ scroller.scrollTop = scroller.scrollHeight;
1302
+
1303
+ // Count rendered .Page elements
1304
+ const pages = document.querySelectorAll(".Page");
1305
+
1306
+ return {
1307
+ done: false,
1308
+ scrollHeight: scroller.scrollHeight,
1309
+ scrollTop: scroller.scrollTop,
1310
+ pages: pages.length
1311
+ };
1312
+ }
1313
+ });
1314
+
1315
+ const hit = results && results.find((r) => r.result);
1316
+ if (!hit || !hit.result || hit.result.done) {
1317
+ break;
1318
+ }
1319
+
1320
+ totalSteps++;
1321
+ const sh = hit.result.scrollHeight;
1322
+ this.logger.info("[m365connector] scrollToLoadAllPages: step", totalSteps,
1323
+ "scrollHeight=", sh, "pages=", hit.result.pages,
1324
+ "prevScrollHeight=", prevScrollHeight);
1325
+
1326
+ if (sh <= prevScrollHeight) {
1327
+ stableRounds++;
1328
+ } else {
1329
+ stableRounds = 0;
1330
+ }
1331
+ prevScrollHeight = sh;
1332
+
1333
+ // Wait for Word Online to render newly-visible pages
1334
+ await sleep(1000);
1335
+ } catch (_err) {
1336
+ await sleep(500);
1337
+ }
1338
+ }
1339
+
1340
+ this.logger.info("[m365connector] scrollToLoadAllPages: finished after", totalSteps,
1341
+ "steps, stableRounds=", stableRounds);
1342
+
1343
+ return totalSteps;
1344
+ }
1345
+
1346
+ /**
1347
+ * Word Online virtualizes long documents. Reading only once after jumping to
1348
+ * the end can miss pages that have already been unloaded, so fullContent mode
1349
+ * walks through the scroll container and accumulates visible page text.
1350
+ */
1351
+ async scrollAndCollectWordText(tabId, budgetMs = 60000) {
1352
+ const stepEnd = Date.now() + Math.min(budgetMs, 60000);
1353
+ const chunks = [];
1354
+ const seen = new Set();
1355
+ let totalSteps = 0;
1356
+ let stableBottomRounds = 0;
1357
+ let lastScrollHeight = 0;
1358
+
1359
+ const remember = (value) => {
1360
+ const text = String(value || "").replace(/\u00a0/g, " ").replace(/[ \t]+/g, " ").trim();
1361
+ if (text.length < 10) {
1362
+ return;
1363
+ }
1364
+ const key = text.replace(/\s+/g, " ").slice(0, 5000);
1365
+ if (seen.has(key)) {
1366
+ return;
1367
+ }
1368
+ seen.add(key);
1369
+ chunks.push(text);
1370
+ };
1371
+
1372
+ let previousTabId = null;
1373
+ try {
1374
+ const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
1375
+ if (activeTab) {
1376
+ previousTabId = activeTab.id;
1377
+ }
1378
+ await chrome.tabs.update(tabId, { active: true });
1379
+ await sleep(800);
1380
+ } catch (_err) {
1381
+ // If activation fails, still try to collect from the loaded DOM.
1382
+ }
1383
+
1384
+ try {
1385
+ await chrome.scripting.executeScript({
1386
+ target: { tabId, allFrames: true },
1387
+ func: () => {
1388
+ const panel = document.getElementById("WACViewPanel");
1389
+ if (!panel) return false;
1390
+ let scroller = null;
1391
+ let el = panel;
1392
+ while (el) {
1393
+ if (el.scrollHeight > el.clientHeight + 20) {
1394
+ scroller = el;
1395
+ break;
1396
+ }
1397
+ el = el.parentElement;
1398
+ }
1399
+ if (!scroller) return false;
1400
+ scroller.scrollTop = 0;
1401
+ return true;
1402
+ }
1403
+ }).catch(() => null);
1404
+
1405
+ while (Date.now() < stepEnd && stableBottomRounds < 4) {
1406
+ const results = await chrome.scripting.executeScript({
1407
+ target: { tabId, allFrames: true },
1408
+ func: () => {
1409
+ const panel = document.getElementById("WACViewPanel");
1410
+ if (!panel) return null;
1411
+
1412
+ let scroller = null;
1413
+ let el = panel;
1414
+ while (el) {
1415
+ if (el.scrollHeight > el.clientHeight + 20) {
1416
+ scroller = el;
1417
+ break;
1418
+ }
1419
+ el = el.parentElement;
1420
+ }
1421
+ if (!scroller) {
1422
+ return {
1423
+ chunks: [panel.innerText || ""],
1424
+ done: true,
1425
+ scrollTop: 0,
1426
+ scrollHeight: 0,
1427
+ clientHeight: 0,
1428
+ pageCount: 0
1429
+ };
1430
+ }
1431
+
1432
+ const pageNodes = Array.from(document.querySelectorAll(".Page"));
1433
+ const pageTexts = pageNodes
1434
+ .map((node) => node.innerText || node.textContent || "")
1435
+ .map((text) => text.trim())
1436
+ .filter((text) => text.length >= 10);
1437
+ const chunks = pageTexts.length ? pageTexts : [panel.innerText || ""];
1438
+
1439
+ const scrollTop = scroller.scrollTop;
1440
+ const scrollHeight = scroller.scrollHeight;
1441
+ const clientHeight = scroller.clientHeight;
1442
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 24;
1443
+ if (!atBottom) {
1444
+ const step = Math.max(Math.floor(clientHeight * 0.85), 500);
1445
+ scroller.scrollTop = Math.min(scrollHeight, scrollTop + step);
1446
+ }
1447
+
1448
+ return {
1449
+ chunks,
1450
+ done: atBottom,
1451
+ scrollTop,
1452
+ scrollHeight,
1453
+ clientHeight,
1454
+ pageCount: pageNodes.length
1455
+ };
1456
+ }
1457
+ });
1458
+
1459
+ const hit = results && results.find((row) => row.result);
1460
+ const value = hit?.result;
1461
+ if (!value) {
1462
+ await sleep(700);
1463
+ continue;
1464
+ }
1465
+
1466
+ for (const chunk of value.chunks || []) {
1467
+ remember(chunk);
1468
+ }
1469
+
1470
+ totalSteps++;
1471
+ if (value.done) {
1472
+ stableBottomRounds = value.scrollHeight <= lastScrollHeight ? stableBottomRounds + 1 : 0;
1473
+ } else {
1474
+ stableBottomRounds = 0;
1475
+ }
1476
+ lastScrollHeight = value.scrollHeight || lastScrollHeight;
1477
+
1478
+ this.logger.info(
1479
+ "[m365connector] scrollAndCollectWordText: step %d scrollTop=%s scrollHeight=%s pages=%s chunks=%s bottomRounds=%s",
1480
+ totalSteps,
1481
+ value.scrollTop,
1482
+ value.scrollHeight,
1483
+ value.pageCount,
1484
+ chunks.length,
1485
+ stableBottomRounds
1486
+ );
1487
+
1488
+ await sleep(value.done ? 900 : 600);
1489
+ }
1490
+ } finally {
1491
+ if (previousTabId && previousTabId !== tabId) {
1492
+ try {
1493
+ await chrome.tabs.update(previousTabId, { active: true });
1494
+ } catch (_err) {
1495
+ // Best-effort; the original tab may have been closed.
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ const content = chunks.join("\n\n").trim();
1501
+ this.logger.info(
1502
+ "[m365connector] scrollAndCollectWordText: finished after %d steps, chunks=%d, chars=%d",
1503
+ totalSteps,
1504
+ chunks.length,
1505
+ content.length
1506
+ );
1507
+ return { text: content, steps: totalSteps, chunks: chunks.length };
1508
+ }
1509
+
1510
+ async extractWordText(tabId, budgetMs = 15000, options = {}) {
1511
+ const totalBudget = Math.min(budgetMs, 70000);
1512
+ const pollEnd = Date.now() + totalBudget;
1513
+
1514
+ // First, wait for the panel to appear
1515
+ let panelReady = false;
1516
+ while (Date.now() < pollEnd) {
1517
+ try {
1518
+ const results = await chrome.scripting.executeScript({
1519
+ target: { tabId, allFrames: true },
1520
+ func: () => {
1521
+ const panel = document.getElementById("WACViewPanel");
1522
+ return panel && panel.innerText && panel.innerText.trim().length >= 20;
1523
+ }
1524
+ });
1525
+ if (results && results.some((r) => r.result)) {
1526
+ panelReady = true;
1527
+ break;
1528
+ }
1529
+ } catch (_err) { /* keep waiting */ }
1530
+ await sleep(1000);
1531
+ }
1532
+ if (!panelReady) return null;
1533
+
1534
+ // Scroll through the document to force all pages to render (fullContent only)
1535
+ if (options.fullContent) {
1536
+ const scrollBudget = Math.min(60000, pollEnd - Date.now());
1537
+ if (scrollBudget > 2000) {
1538
+ const collected = await this.scrollAndCollectWordText(tabId, scrollBudget);
1539
+ if (collected.text && collected.text.length >= 10) {
1540
+ return collected.text;
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ // Now extract the full text
1546
+ try {
1547
+ const results = await chrome.scripting.executeScript({
1548
+ target: { tabId, allFrames: true },
1549
+ func: () => {
1550
+ const panel = document.getElementById("WACViewPanel");
1551
+ if (!panel || !panel.innerText || panel.innerText.trim().length < 10) {
1552
+ return null;
1553
+ }
1554
+ return panel.innerText.trim();
1555
+ }
1556
+ });
1557
+ const hit = results && results.find((r) => r.result);
1558
+ if (hit) return hit.result;
1559
+ } catch (_err) { /* fall through */ }
1560
+
1561
+ return null;
1562
+ }
1563
+
1564
+ async extractPowerPointText(tabId, budgetMs = 15000) {
1565
+ const pollEnd = Date.now() + Math.min(budgetMs, 15000);
1566
+ while (Date.now() < pollEnd) {
1567
+ try {
1568
+ const results = await chrome.scripting.executeScript({
1569
+ target: { tabId, allFrames: true },
1570
+ func: () => {
1571
+ const runs = document.querySelectorAll("span.NormalTextRun");
1572
+ if (!runs.length) {
1573
+ return null;
1574
+ }
1575
+ const text = Array.from(runs)
1576
+ .map((run) => run.textContent)
1577
+ .filter(Boolean)
1578
+ .join(" ")
1579
+ .replace(/\s+/g, " ")
1580
+ .trim();
1581
+ return text.length > 10 ? text : null;
1582
+ }
1583
+ });
1584
+
1585
+ const hit = results && results.find((r) => r.result);
1586
+ if (hit) {
1587
+ return hit.result;
1588
+ }
1589
+ } catch (_err) {
1590
+ // Keep polling until budget is exhausted.
1591
+ }
1592
+ await sleep(1000);
1593
+ }
1594
+ return null;
1595
+ }
1596
+
1597
+ async readFileViaDeepLink(context, options = {}) {
1598
+ const ext = getFileExtension(context);
1599
+ if (ext !== "docx") {
1600
+ return null;
1601
+ }
1602
+
1603
+ const webUrl = context.webUrl || context.url || "";
1604
+ if (!webUrl) {
1605
+ return null;
1606
+ }
1607
+
1608
+ if (!isAllowedSharePointUrl(webUrl)) {
1609
+ this.logger.warn(
1610
+ "[m365connector] readFileViaDeepLink: rejected non-SharePoint URL:",
1611
+ String(webUrl).substring(0, 60)
1612
+ );
1613
+ return null;
1614
+ }
1615
+
1616
+ if (this.#deepLinkActive) {
1617
+ this.logger.warn("[m365connector] readFileViaDeepLink: another extraction in progress, skipping");
1618
+ return { skipped: "busy" };
1619
+ }
1620
+
1621
+ const DEEP_LINK_BUDGET_MS = 90000;
1622
+ const deadline = Date.now() + DEEP_LINK_BUDGET_MS;
1623
+ const editUrl = this.buildOfficeEditUrl(webUrl);
1624
+ let tabId = null;
1625
+
1626
+ this.#deepLinkActive = true;
1627
+ try {
1628
+ const tab = await chrome.tabs.create({ url: editUrl, active: true });
1629
+ tabId = tab.id;
1630
+
1631
+ await this.waitForTabLoad(tabId, Math.min(15000, deadline - Date.now()));
1632
+ await chrome.tabs.update(tabId, { active: true }).catch(() => null);
1633
+
1634
+ // SharePoint auto-submits the WOPI form into a cross-origin iframe where
1635
+ // Word/PowerPoint Online renders. Wait for the editor to become ready in
1636
+ // any frame (main page or iframe) instead of trying to hijack the form.
1637
+ const editorReady = await this.waitForEditorInAnyFrame(tabId, deadline);
1638
+ if (!editorReady) {
1639
+ this.logger.warn("[m365connector] readFileViaDeepLink: Office editor not ready in any frame");
1640
+ return null;
1641
+ }
1642
+
1643
+ const remaining = deadline - Date.now();
1644
+ if (remaining < 2000) {
1645
+ this.logger.warn("[m365connector] readFileViaDeepLink: budget exhausted before extraction");
1646
+ return null;
1647
+ }
1648
+
1649
+ const text = await this.extractWordText(tabId, remaining, options);
1650
+
1651
+ if (!text || !text.trim()) {
1652
+ return null;
1653
+ }
1654
+
1655
+ return {
1656
+ content: text.trim(),
1657
+ contentType: "text",
1658
+ completeness: "full",
1659
+ type: "file",
1660
+ title: context.title || "",
1661
+ url: context.url || context.webUrl || "",
1662
+ metadata: context.metadata || {},
1663
+ warning: "Content extracted via Office Online deep-link (not raw file bytes)"
1664
+ };
1665
+ } catch (err) {
1666
+ this.logger.warn("[m365connector] readFileViaDeepLink failed:", err?.message || err);
1667
+ return null;
1668
+ } finally {
1669
+ this.#deepLinkActive = false;
1670
+ if (tabId !== null) {
1671
+ try {
1672
+ await chrome.tabs.remove(tabId);
1673
+ } catch (_err) {
1674
+ // Ignore if tab already closed.
1675
+ }
1676
+ }
1677
+ }
1678
+ }
1679
+
1680
+ async readEmailViaDeepLink(context) {
1681
+ const itemId = this.getEmailItemId(context);
1682
+ const deepLink =
1683
+ context.url ||
1684
+ `https://outlook.office.com/mail/deeplink?exvsurl=1&ItemID=${encodeURIComponent(itemId)}`;
1685
+
1686
+ let tabId = null;
1687
+ let previousActiveTabId = null;
1688
+ let activatedForDecryption = false;
1689
+ try {
1690
+ previousActiveTabId = await this.getActiveTabId();
1691
+ const tab = await chrome.tabs.create({ url: deepLink, active: false });
1692
+ tabId = tab.id;
1693
+
1694
+ // Wait for tab to finish loading instead of polling blindly
1695
+ await this.waitForTabLoad(tabId, 15000);
1696
+
1697
+ // Page loaded — now poll for DOM content.
1698
+ // IRM-protected emails decrypt asynchronously: OWA first renders an
1699
+ // encrypted wrapper ("sent you a protected message …"), then replaces it
1700
+ // with the real body once Azure RMS decryption completes. We therefore
1701
+ // keep polling if the content looks like an IRM wrapper, using an
1702
+ // extended window (up to IRM_POLL_MS) to give decryption time to finish.
1703
+ // IRM_POLL_MS is a generous safety cap, not a decryption expectation —
1704
+ // the loop exits as soon as real content appears.
1705
+ const BASE_POLL_MS = 40000;
1706
+ const IRM_POLL_MS = 60000;
1707
+ let sawEncryptedWrapper = false;
1708
+ let stableContent = null;
1709
+ let followedReadMessageUrl = false;
1710
+ const startedAt = Date.now();
1711
+ const pollDeadline = () => sawEncryptedWrapper ? IRM_POLL_MS : BASE_POLL_MS;
1712
+
1713
+ while (Date.now() - startedAt < pollDeadline()) {
1714
+ try {
1715
+ const executions = await chrome.scripting.executeScript({
1716
+ target: { tabId, allFrames: true },
1717
+ func: (selectors) => {
1718
+ const candidates = [];
1719
+ const seen = new Set();
1720
+ const readMessageUrls = [];
1721
+ for (const selector of selectors) {
1722
+ for (const el of document.querySelectorAll(selector)) {
1723
+ if (el && typeof el.innerText === "string") {
1724
+ const text = el.innerText.trim();
1725
+ if (text && !seen.has(text)) {
1726
+ seen.add(text);
1727
+ candidates.push(text);
1728
+ }
1729
+ }
1730
+ }
1731
+ }
1732
+ const bodyText = document.body?.innerText?.trim();
1733
+ if (bodyText && !seen.has(bodyText)) {
1734
+ seen.add(bodyText);
1735
+ candidates.push(bodyText);
1736
+ }
1737
+ for (const link of document.querySelectorAll("a[href]")) {
1738
+ const label = `${link.innerText || ""} ${link.getAttribute("aria-label") || ""}`.toLowerCase();
1739
+ const href = String(link.href || "");
1740
+ if (href && (label.includes("read the message") || href.includes("viewmodel=ReadMessageItem"))) {
1741
+ readMessageUrls.push(href);
1742
+ }
1743
+ }
1744
+ return { candidates, readMessageUrls };
1745
+ },
1746
+ args: [EMAIL_SELECTORS]
1747
+ });
1748
+
1749
+ const extraction = this.selectEmailExtraction(executions);
1750
+ if (extraction.encrypted && extraction.readMessageUrl && !followedReadMessageUrl && tabId !== null) {
1751
+ followedReadMessageUrl = true;
1752
+ sawEncryptedWrapper = true;
1753
+ this.logger.info("[m365connector][deeplink] following protected-message read link");
1754
+ await this.navigateTabAndWait(tabId, extraction.readMessageUrl, 15000).catch(() => {});
1755
+ await sleep(1000);
1756
+ continue;
1757
+ }
1758
+
1759
+ if (extraction.sawEncrypted && !activatedForDecryption && tabId !== null) {
1760
+ // OWA can defer protected-message decryption in an inactive tab.
1761
+ // Briefly activate the deeplink tab once so the visible reading
1762
+ // pane gets a chance to replace the Purview wrapper with the body.
1763
+ activatedForDecryption = true;
1764
+ try {
1765
+ await chrome.tabs.update(tabId, { active: true });
1766
+ } catch (_err) {
1767
+ // Best-effort; continue polling hidden if activation fails.
1768
+ }
1769
+ }
1770
+
1771
+ if (extraction.text && extraction.text.length > 0) {
1772
+ if (extraction.encrypted) {
1773
+ // IRM wrapper detected — OWA is still decrypting; keep polling.
1774
+ if (!sawEncryptedWrapper) {
1775
+ sawEncryptedWrapper = true;
1776
+ this.logger.info("[m365connector][deeplink] IRM wrapper detected, extending poll window for decryption");
1777
+ }
1778
+ } else {
1779
+ const extracted = extraction.text;
1780
+ // Content is not an IRM wrapper. If we previously saw the
1781
+ // encrypted wrapper, wait one extra poll to confirm the
1782
+ // content has stabilised (guards against partial rendering
1783
+ // during the decryption transition).
1784
+ if (sawEncryptedWrapper && !stableContent) {
1785
+ stableContent = extracted;
1786
+ // fall through to sleep and re-check
1787
+ } else if (sawEncryptedWrapper && stableContent && stableContent.length !== extracted.length) {
1788
+ // Content is still changing — update and keep polling
1789
+ stableContent = extracted;
1790
+ } else {
1791
+ return {
1792
+ content: extracted,
1793
+ contentType: "text",
1794
+ completeness: "full",
1795
+ type: "email",
1796
+ title: context.title || "",
1797
+ url: deepLink,
1798
+ metadata: {},
1799
+ warning: undefined
1800
+ };
1801
+ }
1802
+ }
1803
+ }
1804
+ } catch (_err) {
1805
+ // Page may not be ready yet; keep polling.
1806
+ }
1807
+
1808
+ await sleep(500);
1809
+ }
1810
+
1811
+ return {
1812
+ content: context.snippet || "",
1813
+ contentType: "text",
1814
+ completeness: "snippet",
1815
+ type: "email",
1816
+ title: context.title || "",
1817
+ url: deepLink,
1818
+ metadata: {},
1819
+ warning: "Email extraction timed out; returning search snippet"
1820
+ };
1821
+ } finally {
1822
+ if (tabId !== null) {
1823
+ try {
1824
+ await chrome.tabs.remove(tabId);
1825
+ } catch (_err) {
1826
+ // Ignore if tab already closed.
1827
+ }
1828
+ }
1829
+ if (activatedForDecryption && previousActiveTabId && previousActiveTabId !== tabId) {
1830
+ try {
1831
+ await chrome.tabs.update(previousActiveTabId, { active: true });
1832
+ } catch (_err) {
1833
+ // Best-effort; the original tab may have been closed.
1834
+ }
1835
+ }
1836
+ }
1837
+ }
1838
+
1839
+ async getActiveTabId() {
1840
+ try {
1841
+ const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
1842
+ const id = tabs?.[0]?.id;
1843
+ return Number.isInteger(id) ? id : null;
1844
+ } catch (_err) {
1845
+ return null;
1846
+ }
1847
+ }
1848
+
1849
+ selectEmailExtraction(executions) {
1850
+ let encryptedText = null;
1851
+ let plainText = null;
1852
+ let readMessageUrl = null;
1853
+ const rows = Array.isArray(executions) ? executions : [];
1854
+
1855
+ for (const execution of rows) {
1856
+ const result = execution?.result;
1857
+ if (result && typeof result === "object" && !Array.isArray(result)) {
1858
+ if (!readMessageUrl && Array.isArray(result.readMessageUrls)) {
1859
+ readMessageUrl = result.readMessageUrls.find(Boolean) || null;
1860
+ }
1861
+ }
1862
+ const values = Array.isArray(result)
1863
+ ? result
1864
+ : Array.isArray(result?.candidates)
1865
+ ? result.candidates
1866
+ : [result];
1867
+ for (const value of values) {
1868
+ const text = String(value || "").trim();
1869
+ const lowerText = text.toLowerCase();
1870
+ if (!text || lowerText === "loading" || lowerText === "get data") {
1871
+ continue;
1872
+ }
1873
+ if (this.isEncryptedEmailContent(text)) {
1874
+ if (text.length > (encryptedText?.length || 0)) {
1875
+ encryptedText = text;
1876
+ }
1877
+ } else if (text.length > (plainText?.length || 0)) {
1878
+ plainText = text;
1879
+ }
1880
+ }
1881
+ }
1882
+
1883
+ return {
1884
+ text: plainText || encryptedText || null,
1885
+ encrypted: Boolean(!plainText && encryptedText),
1886
+ sawEncrypted: Boolean(encryptedText),
1887
+ readMessageUrl
1888
+ };
1889
+ }
1890
+
1891
+ async navigateTabAndWait(tabId, url, timeoutMs) {
1892
+ await new Promise((resolve) => {
1893
+ let settled = false;
1894
+ let timeoutId = null;
1895
+
1896
+ const cleanup = () => {
1897
+ if (timeoutId !== null) {
1898
+ clearTimeout(timeoutId);
1899
+ }
1900
+ chrome.tabs.onUpdated.removeListener(onUpdated);
1901
+ };
1902
+
1903
+ const finish = () => {
1904
+ if (settled) {
1905
+ return;
1906
+ }
1907
+ settled = true;
1908
+ cleanup();
1909
+ resolve();
1910
+ };
1911
+
1912
+ const onUpdated = (updatedTabId, changeInfo) => {
1913
+ if (updatedTabId === tabId && changeInfo.status === "complete") {
1914
+ finish();
1915
+ }
1916
+ };
1917
+
1918
+ chrome.tabs.onUpdated.addListener(onUpdated);
1919
+ timeoutId = setTimeout(finish, timeoutMs);
1920
+ Promise.resolve(chrome.tabs.update(tabId, { url, active: true })).catch(finish);
1921
+ });
1922
+ }
1923
+
1924
+ waitForTabLoad(tabId, timeoutMs) {
1925
+ return new Promise((resolve) => {
1926
+ let settled = false;
1927
+ const timer = setTimeout(() => {
1928
+ if (!settled) {
1929
+ settled = true;
1930
+ chrome.tabs.onUpdated.removeListener(listener);
1931
+ resolve();
1932
+ }
1933
+ }, timeoutMs);
1934
+
1935
+ function listener(updatedTabId, changeInfo) {
1936
+ if (updatedTabId === tabId && changeInfo.status === "complete") {
1937
+ if (!settled) {
1938
+ settled = true;
1939
+ clearTimeout(timer);
1940
+ chrome.tabs.onUpdated.removeListener(listener);
1941
+ resolve();
1942
+ }
1943
+ }
1944
+ }
1945
+
1946
+ chrome.tabs.onUpdated.addListener(listener);
1947
+
1948
+ // Check if already complete (race condition: tab may have loaded before listener attached)
1949
+ chrome.tabs.get(tabId).then((tab) => {
1950
+ if (tab?.status === "complete" && !settled) {
1951
+ settled = true;
1952
+ clearTimeout(timer);
1953
+ chrome.tabs.onUpdated.removeListener(listener);
1954
+ resolve();
1955
+ }
1956
+ }).catch(() => {
1957
+ // Tab may not exist anymore; resolve to let caller handle it
1958
+ if (!settled) {
1959
+ settled = true;
1960
+ clearTimeout(timer);
1961
+ chrome.tabs.onUpdated.removeListener(listener);
1962
+ resolve();
1963
+ }
1964
+ });
1965
+ });
1966
+ }
1967
+
1968
+ async readEmailViaGraph(token, context) {
1969
+ const ownerId = this.getOwnerIdentity(context, "mailbox");
1970
+ const basePath = ownerId ? `/users/${encodeURIComponent(ownerId)}` : "/me";
1971
+ const itemId = this.getEmailItemId(context);
1972
+
1973
+ const message = await this.fetchWithTranslatedId({
1974
+ token,
1975
+ basePath,
1976
+ rawId: itemId,
1977
+ entityPath: "messages",
1978
+ cacheNamespace: `email:${ownerId || "me"}`,
1979
+ select:
1980
+ "subject,body,bodyPreview,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments",
1981
+ prefer: 'outlook.body-content-type="text"'
1982
+ });
1983
+
1984
+ const bodyContent = String(message?.body?.content || "");
1985
+ const content = message?.body?.contentType === "html"
1986
+ ? htmlToText(bodyContent)
1987
+ : String(bodyContent || message?.bodyPreview || context.snippet || "").trim();
1988
+
1989
+ return {
1990
+ content,
1991
+ contentType: "text",
1992
+ completeness: content ? "full" : "snippet",
1993
+ type: "email",
1994
+ title: context.title || String(message?.subject || ""),
1995
+ url: context.url || "",
1996
+ metadata: {
1997
+ subject: message?.subject || "",
1998
+ from: message?.from?.emailAddress?.address || "",
1999
+ toRecipients: Array.isArray(message?.toRecipients)
2000
+ ? message.toRecipients.map((r) => r?.emailAddress?.address || r?.emailAddress?.name || "").filter(Boolean)
2001
+ : [],
2002
+ ccRecipients: Array.isArray(message?.ccRecipients)
2003
+ ? message.ccRecipients.map((r) => r?.emailAddress?.address || r?.emailAddress?.name || "").filter(Boolean)
2004
+ : [],
2005
+ receivedDateTime: message?.receivedDateTime || "",
2006
+ hasAttachments: Boolean(message?.hasAttachments)
2007
+ },
2008
+ warning: undefined
2009
+ };
2010
+ }
2011
+
2012
+ async readEventViaGraph(token, context) {
2013
+ const ownerId = this.getOwnerIdentity(context, "calendar");
2014
+ const basePath = ownerId ? `/users/${encodeURIComponent(ownerId)}` : "/me";
2015
+
2016
+ const event = await this.fetchWithTranslatedId({
2017
+ token,
2018
+ basePath,
2019
+ rawId: context.id,
2020
+ entityPath: "events",
2021
+ cacheNamespace: `event:${ownerId || "me"}`,
2022
+ select: "subject,start,end,organizer,attendees,location,body,bodyPreview,onlineMeeting",
2023
+ prefer: 'outlook.timezone="UTC", outlook.body-content-type="text"'
2024
+ });
2025
+
2026
+ const attendees = Array.isArray(event?.attendees)
2027
+ ? event.attendees
2028
+ .map((attendee) => attendee?.emailAddress?.address || attendee?.emailAddress?.name || "")
2029
+ .filter(Boolean)
2030
+ : [];
2031
+
2032
+ const bodyText = event?.body?.contentType === "html"
2033
+ ? htmlToText(String(event?.body?.content || ""))
2034
+ : String(event?.body?.content || "").trim();
2035
+
2036
+ const contentParts = [
2037
+ event?.subject ? `Subject: ${event.subject}` : "",
2038
+ event?.start?.dateTime ? `Start: ${event.start.dateTime}` : "",
2039
+ event?.end?.dateTime ? `End: ${event.end.dateTime}` : "",
2040
+ event?.location?.displayName ? `Location: ${event.location.displayName}` : "",
2041
+ event?.organizer?.emailAddress?.address
2042
+ ? `Organizer: ${event.organizer.emailAddress.address}`
2043
+ : "",
2044
+ attendees.length ? `Attendees: ${attendees.join(", ")}` : "",
2045
+ bodyText ? `Body: ${bodyText}` : "",
2046
+ event?.onlineMeeting?.joinUrl ? `Meeting URL: ${event.onlineMeeting.joinUrl}` : ""
2047
+ ].filter(Boolean);
2048
+
2049
+ return {
2050
+ content: contentParts.join("\n"),
2051
+ contentType: "text",
2052
+ completeness: contentParts.length ? "full" : "snippet",
2053
+ type: "event",
2054
+ title: context.title || String(event?.subject || ""),
2055
+ url: context.url || "",
2056
+ metadata: {
2057
+ subject: event?.subject || "",
2058
+ startTime: event?.start?.dateTime || "",
2059
+ endTime: event?.end?.dateTime || "",
2060
+ location: event?.location?.displayName || "",
2061
+ organizer: event?.organizer?.emailAddress?.address || "",
2062
+ attendees,
2063
+ bodyPreview: event?.bodyPreview || "",
2064
+ meetingUrl: event?.onlineMeeting?.joinUrl || ""
2065
+ },
2066
+ warning: undefined
2067
+ };
2068
+ }
2069
+
2070
+ async readFileViaGraph(token, context) {
2071
+ const driveItem = await this.resolveDriveItem(token, context);
2072
+ if (!driveItem) {
2073
+ throw new GraphHttpError("Drive item unavailable", 404);
2074
+ }
2075
+
2076
+ const size = Number(driveItem.size || context.size || 0);
2077
+ if (Number.isFinite(size) && size > TEN_MB) {
2078
+ return this.readFileFallback(
2079
+ context,
2080
+ `File is ${size} bytes (> 10MB limit); returning summary instead of downloading bytes`
2081
+ );
2082
+ }
2083
+
2084
+ const downloadUrl = driveItem["@microsoft.graph.downloadUrl"];
2085
+ if (!downloadUrl) {
2086
+ // Graph returned the item metadata but no download URL. This is a
2087
+ // per-file limitation (cross-tenant, restricted permissions, etc.),
2088
+ // NOT a Graph API failure — so return null instead of throwing,
2089
+ // which would otherwise count toward the circuit breaker.
2090
+ this.logger.info("[m365connector][graph] readFileViaGraph: no downloadUrl in driveItem (id=%s), falling back",
2091
+ String(driveItem.id || context.id || "").substring(0, 40));
2092
+ return null;
2093
+ }
2094
+
2095
+ const downloadResponse = await fetch(downloadUrl);
2096
+ if (!downloadResponse.ok) {
2097
+ const text = await downloadResponse.text().catch(() => "");
2098
+ throw new GraphHttpError(
2099
+ `File download failed (${downloadResponse.status})`,
2100
+ downloadResponse.status,
2101
+ text,
2102
+ retryAfterSeconds(downloadResponse.headers.get("retry-after"))
2103
+ );
2104
+ }
2105
+
2106
+ const arrayBuffer = await downloadResponse.arrayBuffer();
2107
+ const bytes = new Uint8Array(arrayBuffer);
2108
+ if (this.isEncryptedCdf(bytes)) {
2109
+ return this.readFileFallback(
2110
+ context,
2111
+ "File appears sensitivity-label encrypted; returning summary fallback"
2112
+ );
2113
+ }
2114
+
2115
+ const ext = getFileExtension(context, driveItem);
2116
+ const parsed = await this.extractFileContent(ext, arrayBuffer);
2117
+ if (!parsed || !parsed.trim()) {
2118
+ return this.readFileFallback(
2119
+ context,
2120
+ `Unable to parse .${ext || "unknown"} file; returning summary fallback`
2121
+ );
2122
+ }
2123
+
2124
+ return {
2125
+ content: parsed,
2126
+ contentType: "text",
2127
+ completeness: "full",
2128
+ type: "file",
2129
+ title: context.title || driveItem.name || "",
2130
+ url: context.url || context.webUrl || "",
2131
+ metadata: {
2132
+ ...(context.metadata || {}),
2133
+ graphDriveItemId: driveItem.id || "",
2134
+ graphName: driveItem.name || "",
2135
+ size: Number.isFinite(size) ? size : undefined
2136
+ },
2137
+ warning: undefined
2138
+ };
2139
+ }
2140
+
2141
+ async resolveDriveItem(token, context) {
2142
+ const webUrl = String(context.webUrl || "").trim();
2143
+ if (webUrl) {
2144
+ const encodedUrl = `u!${base64UrlEncode(webUrl)}`;
2145
+ try {
2146
+ return await this.fetchGraphJson(
2147
+ token,
2148
+ `/shares/${encodedUrl}/driveItem?$select=id,name,size,@microsoft.graph.downloadUrl,file`
2149
+ );
2150
+ } catch (err) {
2151
+ if (err?.status !== 404 && err?.status !== 400) {
2152
+ throw err;
2153
+ }
2154
+ }
2155
+ }
2156
+
2157
+ if (context.id) {
2158
+ return this.fetchGraphJson(
2159
+ token,
2160
+ `/me/drive/items/${encodeURIComponent(context.id)}?$select=id,name,size,@microsoft.graph.downloadUrl,file`
2161
+ );
2162
+ }
2163
+
2164
+ return null;
2165
+ }
2166
+
2167
+ async extractFileContent(ext, arrayBuffer) {
2168
+ const lowerExt = String(ext || "").toLowerCase();
2169
+
2170
+ if (["txt", "md", "json", "csv"].includes(lowerExt)) {
2171
+ return new TextDecoder("utf-8", { fatal: false }).decode(arrayBuffer).trim();
2172
+ }
2173
+
2174
+ if (!["docx", "xlsx", "pptx"].includes(lowerExt)) {
2175
+ return "";
2176
+ }
2177
+
2178
+ const JSZip = await ensureJsZipAvailable();
2179
+ if (!JSZip) {
2180
+ return "";
2181
+ }
2182
+
2183
+ let zip = null;
2184
+ try {
2185
+ zip = await JSZip.loadAsync(arrayBuffer);
2186
+ } catch (_err) {
2187
+ // Corrupt or non-zip payload with Office extension; caller will fallback.
2188
+ return "";
2189
+ }
2190
+
2191
+ if (lowerExt === "docx") {
2192
+ return this.parseDocx(zip);
2193
+ }
2194
+ if (lowerExt === "xlsx") {
2195
+ return this.parseXlsx(zip);
2196
+ }
2197
+ if (lowerExt === "pptx") {
2198
+ return this.parsePptx(zip);
2199
+ }
2200
+
2201
+ return "";
2202
+ }
2203
+
2204
+ async parseDocx(zip) {
2205
+ const documentXml = await zip.file("word/document.xml")?.async("text");
2206
+ if (!documentXml) {
2207
+ return "";
2208
+ }
2209
+
2210
+ const paragraphs = documentXml.match(/<w:p[\s\S]*?<\/w:p>/g) || [];
2211
+ const lines = [];
2212
+ for (const paragraph of paragraphs) {
2213
+ const textRuns = extractTagValues(paragraph, "w:t");
2214
+ const line = textRuns.join("").trim();
2215
+ if (line) {
2216
+ lines.push(line);
2217
+ }
2218
+ }
2219
+
2220
+ return lines.join("\n").trim();
2221
+ }
2222
+
2223
+ async parsePptx(zip) {
2224
+ const slideFiles = Object.keys(zip.files)
2225
+ .filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name))
2226
+ .sort((a, b) => {
2227
+ const aNum = parseInt((a.match(/slide(\d+)\.xml/i) || [])[1] || "0", 10);
2228
+ const bNum = parseInt((b.match(/slide(\d+)\.xml/i) || [])[1] || "0", 10);
2229
+ return aNum - bNum;
2230
+ });
2231
+
2232
+ const sections = [];
2233
+ for (const file of slideFiles) {
2234
+ const xml = await zip.file(file)?.async("text");
2235
+ if (!xml) {
2236
+ continue;
2237
+ }
2238
+ const slideNum = parseInt((file.match(/slide(\d+)\.xml/i) || [])[1] || "0", 10);
2239
+ const runs = extractTagValues(xml, "a:t").map((value) => value.trim()).filter(Boolean);
2240
+ if (!runs.length) {
2241
+ continue;
2242
+ }
2243
+ sections.push(`Slide ${slideNum}:\n${runs.join("\n")}`);
2244
+ }
2245
+
2246
+ return sections.join("\n\n").trim();
2247
+ }
2248
+
2249
+ async parseXlsx(zip) {
2250
+ const sharedStringsXml = await zip.file("xl/sharedStrings.xml")?.async("text");
2251
+ const sharedStrings = this.parseSharedStrings(sharedStringsXml || "");
2252
+
2253
+ const sheetFiles = Object.keys(zip.files)
2254
+ .filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(name))
2255
+ .sort((a, b) => {
2256
+ const aNum = parseInt((a.match(/sheet(\d+)\.xml/i) || [])[1] || "0", 10);
2257
+ const bNum = parseInt((b.match(/sheet(\d+)\.xml/i) || [])[1] || "0", 10);
2258
+ return aNum - bNum;
2259
+ });
2260
+
2261
+ const allSheets = [];
2262
+ for (const file of sheetFiles) {
2263
+ const xml = await zip.file(file)?.async("text");
2264
+ if (!xml) {
2265
+ continue;
2266
+ }
2267
+ const rows = this.parseSheetRows(xml, sharedStrings);
2268
+ if (!rows.length) {
2269
+ continue;
2270
+ }
2271
+ const sheetNum = parseInt((file.match(/sheet(\d+)\.xml/i) || [])[1] || "0", 10);
2272
+ allSheets.push(`Sheet ${sheetNum}:\n${rows.join("\n")}`);
2273
+ }
2274
+
2275
+ return allSheets.join("\n\n").trim();
2276
+ }
2277
+
2278
+ parseSharedStrings(xml) {
2279
+ const values = [];
2280
+ const items = xml.match(/<si>[\s\S]*?<\/si>/g) || [];
2281
+ for (const item of items) {
2282
+ const runs = extractTagValues(item, "t");
2283
+ values.push(runs.join(""));
2284
+ }
2285
+ return values;
2286
+ }
2287
+
2288
+ parseSheetRows(xml, sharedStrings) {
2289
+ const rows = [];
2290
+ const rowMatches = xml.match(/<row[^>]*>[\s\S]*?<\/row>/g) || [];
2291
+
2292
+ for (const rowXml of rowMatches) {
2293
+ const cells = [];
2294
+ const cellPattern = /<c([^>]*)>([\s\S]*?)<\/c>/g;
2295
+ let cellMatch;
2296
+ while ((cellMatch = cellPattern.exec(rowXml)) !== null) {
2297
+ const attrs = cellMatch[1] || "";
2298
+ const body = cellMatch[2] || "";
2299
+ // OOXML may omit the cell reference; we then append sequentially as best-effort.
2300
+ const ref = (attrs.match(/\sr="([A-Z]+\d+)"/i) || [])[1] || "";
2301
+ const colRef = (ref.match(/[A-Z]+/i) || [])[0] || "";
2302
+ const colIdx = this.columnRefToIndex(colRef);
2303
+
2304
+ const type = (attrs.match(/\st="([^"]+)"/i) || [])[1] || "";
2305
+ let value = "";
2306
+
2307
+ if (type === "s") {
2308
+ const rawIndex = (body.match(/<v>([\s\S]*?)<\/v>/i) || [])[1];
2309
+ const index = parseInt(String(rawIndex || ""), 10);
2310
+ if (Number.isInteger(index) && sharedStrings[index] != null) {
2311
+ value = sharedStrings[index];
2312
+ }
2313
+ } else if (type === "inlineStr") {
2314
+ value = extractTagValues(body, "t").join("");
2315
+ } else {
2316
+ value = decodeXmlEntities((body.match(/<v>([\s\S]*?)<\/v>/i) || [])[1] || "");
2317
+ }
2318
+
2319
+ if (colIdx >= 0) {
2320
+ cells[colIdx] = value;
2321
+ } else {
2322
+ cells.push(value);
2323
+ }
2324
+ }
2325
+
2326
+ if (cells.length) {
2327
+ rows.push(cells.map((cell) => String(cell || "")).join("\t").trimEnd());
2328
+ }
2329
+ }
2330
+
2331
+ return rows.filter(Boolean);
2332
+ }
2333
+
2334
+ columnRefToIndex(columnRef) {
2335
+ if (!columnRef) {
2336
+ return -1;
2337
+ }
2338
+ let value = 0;
2339
+ const upper = String(columnRef).toUpperCase();
2340
+ for (let i = 0; i < upper.length; i += 1) {
2341
+ const code = upper.charCodeAt(i);
2342
+ if (code < 65 || code > 90) {
2343
+ return -1;
2344
+ }
2345
+ value = value * 26 + (code - 64);
2346
+ }
2347
+ return value - 1;
2348
+ }
2349
+
2350
+ isEncryptedEmailContent(content) {
2351
+ if (!content || !content.trim()) {
2352
+ return false;
2353
+ }
2354
+ const lower = content.toLowerCase();
2355
+ // Multiple resilient markers for IRM/Purview wrapper detection.
2356
+ // Any single strong indicator is sufficient; the AND of two weaker
2357
+ // phrases guards against false positives from normal email text.
2358
+ if (lower.includes("sent you a protected message") && lower.includes("microsoft purview message encryption")) {
2359
+ return true;
2360
+ }
2361
+ if (lower.includes("sent you a protected message") && lower.includes("read the message")) {
2362
+ return true;
2363
+ }
2364
+ if (lower.includes("azure information protection") && lower.includes("protected message")) {
2365
+ return true;
2366
+ }
2367
+ if (lower.includes("microsoft information protection") && lower.includes("protected")) {
2368
+ return true;
2369
+ }
2370
+ if (lower.includes("encrypt.compliance.microsoft.com") || lower.includes("rights-protected message")) {
2371
+ return true;
2372
+ }
2373
+ if (lower.includes("aka.ms/protectedmessage")) {
2374
+ return true;
2375
+ }
2376
+ return false;
2377
+ }
2378
+
2379
+ isEncryptedCdf(bytes) {
2380
+ if (!bytes || bytes.length < 4) {
2381
+ return false;
2382
+ }
2383
+ return bytes[0] === 0xd0 && bytes[1] === 0xcf && bytes[2] === 0x11 && bytes[3] === 0xe0;
2384
+ }
2385
+
2386
+ isSharePointResource(context) {
2387
+ const url = String(context?.webUrl || context?.url || "").toLowerCase();
2388
+ return url.includes(".sharepoint.com/") || url.includes(".sharepoint-df.com/");
2389
+ }
2390
+
2391
+ getEmailItemId(context) {
2392
+ return String(context?.id || "").trim()
2393
+ || extractOutlookDeepLinkItemId(context?.url)
2394
+ || extractOutlookDeepLinkItemId(context?.webUrl);
2395
+ }
2396
+
2397
+ getOwnerIdentity(context, kind) {
2398
+ const metadata = context?.metadata || {};
2399
+ if (kind === "mailbox") {
2400
+ return (
2401
+ context.mailboxOwnerId ||
2402
+ context.ownerId ||
2403
+ metadata.mailboxOwnerId ||
2404
+ metadata.ownerId ||
2405
+ metadata.mailboxOwnerUpn ||
2406
+ ""
2407
+ );
2408
+ }
2409
+
2410
+ return (
2411
+ context.calendarOwnerId ||
2412
+ context.ownerId ||
2413
+ metadata.calendarOwnerId ||
2414
+ metadata.ownerId ||
2415
+ metadata.calendarOwnerUpn ||
2416
+ ""
2417
+ );
2418
+ }
2419
+
2420
+ async fetchWithTranslatedId({ token, basePath, rawId, entityPath, cacheNamespace, select, prefer }) {
2421
+ const cacheKey = `${cacheNamespace}:${rawId}`;
2422
+ const cached = this.getTranslatedId(cacheKey);
2423
+ const hadCachedTranslation = Boolean(cached && cached !== rawId);
2424
+
2425
+ if (hadCachedTranslation) {
2426
+ try {
2427
+ return await this.fetchGraphJson(
2428
+ token,
2429
+ `${basePath}/${entityPath}/${encodeURIComponent(cached)}?$select=${encodeURIComponent(select)}`,
2430
+ {
2431
+ headers: {
2432
+ Prefer: prefer
2433
+ }
2434
+ }
2435
+ );
2436
+ } catch (err) {
2437
+ if (err?.status !== 404) {
2438
+ throw err;
2439
+ }
2440
+ this.translationCache.delete(cacheKey);
2441
+ }
2442
+ }
2443
+
2444
+ try {
2445
+ return await this.fetchGraphJson(
2446
+ token,
2447
+ `${basePath}/${entityPath}/${encodeURIComponent(rawId)}?$select=${encodeURIComponent(select)}`,
2448
+ {
2449
+ headers: {
2450
+ Prefer: prefer
2451
+ }
2452
+ }
2453
+ );
2454
+ } catch (err) {
2455
+ if (err?.status !== 404) {
2456
+ throw err;
2457
+ }
2458
+ if (hadCachedTranslation) {
2459
+ throw new GraphHttpError("Resource not found using cached and raw IDs", 404);
2460
+ }
2461
+ }
2462
+
2463
+ const translated = await this.translateExchangeId(token, basePath, rawId);
2464
+ if (translated && translated !== rawId) {
2465
+ this.setTranslatedId(cacheKey, translated);
2466
+ return this.fetchGraphJson(
2467
+ token,
2468
+ `${basePath}/${entityPath}/${encodeURIComponent(translated)}?$select=${encodeURIComponent(select)}`,
2469
+ {
2470
+ headers: {
2471
+ Prefer: prefer
2472
+ }
2473
+ }
2474
+ );
2475
+ }
2476
+
2477
+ throw new GraphHttpError("Resource not found after ID translation", 404);
2478
+ }
2479
+
2480
+ getTranslatedId(key) {
2481
+ const entry = this.translationCache.get(key);
2482
+ if (!entry) {
2483
+ return null;
2484
+ }
2485
+ if (entry.expiresAt <= nowMs()) {
2486
+ this.translationCache.delete(key);
2487
+ return null;
2488
+ }
2489
+ return entry.value;
2490
+ }
2491
+
2492
+ setTranslatedId(key, value) {
2493
+ if (this.translationCache.has(key)) {
2494
+ this.translationCache.delete(key);
2495
+ }
2496
+ this.translationCache.set(key, {
2497
+ value,
2498
+ expiresAt: nowMs() + TRANSLATION_CACHE_MS
2499
+ });
2500
+ evictOldestEntries(this.translationCache, MAX_CACHE_ENTRIES);
2501
+ }
2502
+
2503
+ async translateExchangeId(token, basePath, id) {
2504
+ const response = await this.fetchGraphJson(token, `${basePath}/translateExchangeIds`, {
2505
+ method: "POST",
2506
+ headers: {
2507
+ "Content-Type": "application/json"
2508
+ },
2509
+ body: JSON.stringify({
2510
+ inputIds: [id],
2511
+ sourceIdType: "ewsId",
2512
+ targetIdType: "restId"
2513
+ })
2514
+ });
2515
+
2516
+ const values = Array.isArray(response?.value) ? response.value : [];
2517
+ const first = values[0];
2518
+ return first?.targetId || null;
2519
+ }
2520
+
2521
+ async tryGraphRead(contentType, itemId, requiredScope, operation) {
2522
+ if (!this.graphTokenProvider) {
2523
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): no graphTokenProvider", contentType);
2524
+ return null;
2525
+ }
2526
+ if (!itemId) {
2527
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): no itemId", contentType);
2528
+ return null;
2529
+ }
2530
+ this.pruneCaches();
2531
+ if (this.isCircuitOpen(contentType)) {
2532
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): circuit open", contentType);
2533
+ return null;
2534
+ }
2535
+ if (this.isDeniedCached(contentType, itemId)) {
2536
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): denied cached for %s", contentType, itemId);
2537
+ return null;
2538
+ }
2539
+
2540
+ let token = await this.graphTokenProvider.getToken(requiredScope || undefined);
2541
+ if (!token) {
2542
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): getToken returned null (requiredScope=%s)", contentType, requiredScope);
2543
+ return null;
2544
+ }
2545
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): got token, proceeding with Graph API call for itemId=%s", contentType, String(itemId).substring(0, 30));
2546
+
2547
+ let retried401 = false;
2548
+ let retried429 = false;
2549
+ let retried5xx = false;
2550
+
2551
+ for (let attempt = 0; attempt < 4; attempt += 1) {
2552
+ try {
2553
+ const result = await operation(token);
2554
+ this.recordGraphSuccess(contentType);
2555
+ return result;
2556
+ } catch (err) {
2557
+ const status = Number(err?.status || 0);
2558
+ this.logger.info("[m365connector][graph] tryGraphRead(%s): attempt %d failed, status=%d err=%s", contentType, attempt, status, err?.message || err);
2559
+
2560
+ if (status === 403) {
2561
+ this.cacheDenied(contentType, itemId);
2562
+ this.recordGraphFailure(contentType);
2563
+ return null;
2564
+ }
2565
+
2566
+ if (status === 404) {
2567
+ this.recordGraphFailure(contentType);
2568
+ return null;
2569
+ }
2570
+
2571
+ if (status === 401 && !retried401) {
2572
+ retried401 = true;
2573
+ await this.graphTokenProvider.refresh();
2574
+ token = await this.graphTokenProvider.getToken(requiredScope || undefined);
2575
+ if (!token) {
2576
+ break;
2577
+ }
2578
+ continue;
2579
+ }
2580
+
2581
+ if (status === 429 && !retried429) {
2582
+ retried429 = true;
2583
+ const retrySeconds = Number.isFinite(err?.retryAfter) ? err.retryAfter : 2;
2584
+ const backoffMs = Math.max(500, retrySeconds * 1000);
2585
+ await sleep(backoffMs);
2586
+ continue;
2587
+ }
2588
+
2589
+ if (status >= 500 && status <= 599 && !retried5xx) {
2590
+ retried5xx = true;
2591
+ await sleep(1800 + Math.floor(Math.random() * 600));
2592
+ continue;
2593
+ }
2594
+
2595
+ break;
2596
+ }
2597
+ }
2598
+
2599
+ this.recordGraphFailure(contentType);
2600
+ return null;
2601
+ }
2602
+
2603
+ recordGraphSuccess(contentType) {
2604
+ const state = this.circuitBreakers[contentType];
2605
+ if (!state) {
2606
+ return;
2607
+ }
2608
+ state.failures = [];
2609
+ state.openUntil = 0;
2610
+ }
2611
+
2612
+ recordGraphFailure(contentType) {
2613
+ const state = this.circuitBreakers[contentType];
2614
+ if (!state) {
2615
+ return;
2616
+ }
2617
+
2618
+ const now = nowMs();
2619
+ state.failures = state.failures.filter((ts) => now - ts <= CIRCUIT_FAILURE_WINDOW_MS);
2620
+ state.failures.push(now);
2621
+
2622
+ if (state.failures.length >= 3) {
2623
+ state.openUntil = now + CIRCUIT_OPEN_MS;
2624
+ state.failures = [];
2625
+ }
2626
+ }
2627
+
2628
+ isCircuitOpen(contentType) {
2629
+ const state = this.circuitBreakers[contentType];
2630
+ if (!state) {
2631
+ return false;
2632
+ }
2633
+
2634
+ if (!state.openUntil) {
2635
+ return false;
2636
+ }
2637
+
2638
+ if (state.openUntil <= nowMs()) {
2639
+ state.openUntil = 0;
2640
+ return false;
2641
+ }
2642
+
2643
+ return true;
2644
+ }
2645
+
2646
+ deniedKey(contentType, itemId) {
2647
+ return `${contentType}:${itemId}`;
2648
+ }
2649
+
2650
+ cacheDenied(contentType, itemId) {
2651
+ if (!itemId) {
2652
+ return;
2653
+ }
2654
+ const key = this.deniedKey(contentType, itemId);
2655
+ if (this.deniedCache.has(key)) {
2656
+ this.deniedCache.delete(key);
2657
+ }
2658
+ this.deniedCache.set(key, nowMs() + NEGATIVE_CACHE_MS);
2659
+ evictOldestEntries(this.deniedCache, MAX_CACHE_ENTRIES);
2660
+ }
2661
+
2662
+ isDeniedCached(contentType, itemId) {
2663
+ if (!itemId) {
2664
+ return false;
2665
+ }
2666
+ const key = this.deniedKey(contentType, itemId);
2667
+ const expiresAt = this.deniedCache.get(key);
2668
+ if (!expiresAt) {
2669
+ return false;
2670
+ }
2671
+ if (expiresAt <= nowMs()) {
2672
+ this.deniedCache.delete(key);
2673
+ return false;
2674
+ }
2675
+ return true;
2676
+ }
2677
+
2678
+ pruneCaches() {
2679
+ const now = nowMs();
2680
+
2681
+ for (const [key, expiresAt] of this.deniedCache.entries()) {
2682
+ if (!expiresAt || expiresAt <= now) {
2683
+ this.deniedCache.delete(key);
2684
+ }
2685
+ }
2686
+ evictOldestEntries(this.deniedCache, MAX_CACHE_ENTRIES);
2687
+
2688
+ for (const [key, entry] of this.translationCache.entries()) {
2689
+ if (!entry?.expiresAt || entry.expiresAt <= now) {
2690
+ this.translationCache.delete(key);
2691
+ }
2692
+ }
2693
+ evictOldestEntries(this.translationCache, MAX_CACHE_ENTRIES);
2694
+ }
2695
+
2696
+ /** @internal Exposed for testing only. */
2697
+ _getCircuitBreakers() {
2698
+ return this.circuitBreakers;
2699
+ }
2700
+
2701
+ async fetchGraphJson(token, pathOrUrl, options = {}) {
2702
+ const url = pathOrUrl.startsWith("http") ? pathOrUrl : `${GRAPH_BASE_URL}${pathOrUrl}`;
2703
+ const headers = {
2704
+ Authorization: `Bearer ${token}`,
2705
+ Accept: "application/json",
2706
+ ...(options.headers || {})
2707
+ };
2708
+
2709
+ const response = await fetch(url, {
2710
+ method: options.method || "GET",
2711
+ headers,
2712
+ body: options.body
2713
+ });
2714
+
2715
+ if (!response.ok) {
2716
+ const body = await response.text().catch(() => "");
2717
+ throw new GraphHttpError(
2718
+ `Graph request failed (${response.status})`,
2719
+ response.status,
2720
+ body,
2721
+ retryAfterSeconds(response.headers.get("retry-after"))
2722
+ );
2723
+ }
2724
+
2725
+ if (response.status === 204) {
2726
+ return null;
2727
+ }
2728
+
2729
+ return response.json();
2730
+ }
2731
+ }
2732
+
2733
+ export const _testable = {
2734
+ htmlToText,
2735
+ decodeXmlEntities,
2736
+ extractTagValues,
2737
+ getFileExtension,
2738
+ isAllowedSharePointUrl,
2739
+ base64UrlEncode,
2740
+ retryAfterSeconds,
2741
+ extractOutlookDeepLinkItemId,
2742
+ evictOldestEntries,
2743
+ decodeCodePointSafe,
2744
+ pickContext,
2745
+ GraphHttpError
2746
+ };