@littlebearapps/outlook-assistant 3.11.2 → 3.12.1

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.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
@@ -37,14 +37,304 @@ function assertGraphUrl(url) {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * Fully percent-decode a value (bounded), so `%2e`, `%252e` etc. are seen as
42
+ * the characters they eventually stand for. Malformed escapes stop decoding.
43
+ * @param {string} value
44
+ * @returns {string}
45
+ */
46
+ function decodeFully(value) {
47
+ let current = value;
48
+ for (let i = 0; i < 5; i++) {
49
+ let next;
50
+ try {
51
+ next = decodeURIComponent(current);
52
+ } catch {
53
+ return current;
54
+ }
55
+ if (next === current) return current;
56
+ current = next;
57
+ }
58
+ return current;
59
+ }
60
+
61
+ /**
62
+ * Is this path segment a dot segment (`.` or `..`) in any encoding?
63
+ * @param {string} segment
64
+ * @returns {boolean}
65
+ */
66
+ function isDotSegment(segment) {
67
+ const decoded = decodeFully(String(segment)).trim();
68
+ return decoded === '.' || decoded === '..';
69
+ }
70
+
71
+ /**
72
+ * Reject relative Graph resource paths containing dot segments. Caller-supplied
73
+ * IDs (message, folder, attachment, delta tokens) are interpolated into these
74
+ * paths, and URL normalisation would otherwise let `..` walk the request to a
75
+ * different Graph resource (another mailbox, another API version) than the
76
+ * tool intended. Legitimate Graph IDs never contain a bare `.`/`..` segment.
77
+ * @param {string} resourcePath - Relative path (query string, if any, ignored)
78
+ * @throws {Error} If any segment is `.` or `..` (literal or percent-encoded)
79
+ */
80
+ function assertSafeResourcePath(resourcePath) {
81
+ const pathOnly = String(resourcePath).split('?')[0];
82
+ if (pathOnly.split('/').some(isDotSegment)) {
83
+ throw new Error(
84
+ 'Invalid resource path: IDs must not contain "." or ".." path segments'
85
+ );
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Build the request URL for a Graph resource path (or validate a full
91
+ * continuation URL). Pure: never mutates `queryParams`, so retries and
92
+ * callers that reuse the object always see the original `$filter`.
93
+ * Host and dot-segment guards run here, once, before any attempt.
94
+ * @param {string} path - Relative resource path, or a full nextLink/deltaLink URL
95
+ * @param {object} [queryParams] - Query parameters (`$filter` gets OData-safe encoding)
96
+ * @returns {string} Absolute URL
97
+ * @throws {Error} If the full URL is not Graph, or the path has dot segments
98
+ */
99
+ function buildGraphUrl(path, queryParams = {}) {
100
+ if (path.startsWith('http://') || path.startsWith('https://')) {
101
+ // Path is already a full URL (from pagination nextLink / deltaLink)
102
+ assertGraphUrl(path);
103
+ return path;
104
+ }
105
+
106
+ // Refuse dot segments before encoding: encodeURIComponent leaves `..`
107
+ // intact, and the URL parser would then resolve it to a different resource.
108
+ assertSafeResourcePath(path);
109
+ const encodedPath = path
110
+ .split('/')
111
+ .map((segment) => encodeURIComponent(segment))
112
+ .join('/');
113
+
114
+ // $filter is encoded separately to ensure proper OData URI encoding
115
+ const { $filter: filter, ...rest } = queryParams || {};
116
+ const params = new URLSearchParams();
117
+ for (const [key, value] of Object.entries(rest)) {
118
+ params.append(key, value);
119
+ }
120
+ let queryString = params.toString();
121
+ if (filter) {
122
+ const encodedFilter = `$filter=${encodeURIComponent(filter)}`;
123
+ queryString = queryString
124
+ ? `${queryString}&${encodedFilter}`
125
+ : encodedFilter;
126
+ }
127
+
128
+ return `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString ? `?${queryString}` : ''}`;
129
+ }
130
+
131
+ // --- Concurrency gate -------------------------------------------------------
132
+ // Graph throttles per app+mailbox; bulk tools fan out many calls at once.
133
+ // At most MAX_CONCURRENT_REQUESTS sends are in flight; the rest queue FIFO.
134
+ const MAX_CONCURRENT_REQUESTS = 4;
135
+ let inFlightRequests = 0;
136
+ const slotWaiters = [];
137
+
138
+ function acquireSlot() {
139
+ if (inFlightRequests < MAX_CONCURRENT_REQUESTS) {
140
+ inFlightRequests += 1;
141
+ return Promise.resolve();
142
+ }
143
+ return new Promise((resolve) => {
144
+ slotWaiters.push(resolve);
145
+ });
146
+ }
147
+
148
+ function releaseSlot() {
149
+ const next = slotWaiters.shift();
150
+ if (next) {
151
+ next(); // hand the slot straight to the next waiter
152
+ } else {
153
+ inFlightRequests -= 1;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Perform a single HTTPS request (no retries). The body is read as UTF-8.
159
+ * @param {object} options
160
+ * @param {string} options.url
161
+ * @param {string} options.method
162
+ * @param {object} options.headers
163
+ * @param {string|null} [options.body] - Serialised request body
164
+ * @param {number} options.timeoutMs - Socket idle timeout
165
+ * @returns {Promise<{status: number, headers: object, text: string}>}
166
+ * @throws {Error} Network errors (with `.code`); timeouts have code `ETIMEDOUT`
167
+ */
168
+ function sendOnce({ url, method, headers, body = null, timeoutMs }) {
169
+ return new Promise((resolve, reject) => {
170
+ let settled = false;
171
+ const fail = (error) => {
172
+ if (!settled) {
173
+ settled = true;
174
+ reject(error);
175
+ }
176
+ };
177
+
178
+ const req = https.request(
179
+ url,
180
+ { method, headers, timeout: timeoutMs },
181
+ (res) => {
182
+ const chunks = [];
183
+ res.on('data', (chunk) => {
184
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
185
+ });
186
+ res.on('error', fail);
187
+ res.on('end', () => {
188
+ if (settled) return;
189
+ settled = true;
190
+ resolve({
191
+ status: res.statusCode,
192
+ headers: res.headers || {},
193
+ text: Buffer.concat(chunks).toString('utf8'),
194
+ });
195
+ });
196
+ }
197
+ );
198
+
199
+ req.on('timeout', () => {
200
+ const error = new Error(`Request timed out after ${timeoutMs} ms`);
201
+ error.code = 'ETIMEDOUT';
202
+ fail(error);
203
+ if (typeof req.destroy === 'function') {
204
+ req.destroy(error);
205
+ }
206
+ });
207
+ req.on('error', fail);
208
+
209
+ if (body !== null && body !== undefined) {
210
+ req.write(body);
211
+ }
212
+ req.end();
213
+ });
214
+ }
215
+
216
+ const RETRYABLE_STATUSES = new Set([429, 503, 504]);
217
+ const RETRY_STATUS_METHODS = new Set(['GET', 'PUT', 'DELETE', 'PATCH']);
218
+ const RETRYABLE_NETWORK_CODES = new Set(['ETIMEDOUT', 'ECONNRESET']);
219
+ const MAX_RETRIES = 3;
220
+ const BACKOFF_BASE_MS = 1000;
221
+ const BACKOFF_CAP_MS = 30000;
222
+ const MAX_RETRY_AFTER_SECONDS = 60;
223
+ // POST (sendMail, /send, …) must finish well inside an MCP client's ~60 s
224
+ // request timeout: if the client gives up while we sleep and the send later
225
+ // succeeds, the model may send again. So a POST only waits out a short 429.
226
+ const POST_MAX_RETRY_DELAY_MS = 10000;
227
+ const POST_MAX_TOTAL_SLEEP_MS = 20000;
228
+
229
+ /**
230
+ * Can this response be retried for this method? POST (sendMail, /send,
231
+ * createReply, move, $batch) is retried only on 429 — the request was
232
+ * refused, so it cannot have taken effect. 503/504 may have been applied.
233
+ */
234
+ function isRetryableStatus(method, status) {
235
+ if (!RETRYABLE_STATUSES.has(status)) return false;
236
+ return status === 429 || RETRY_STATUS_METHODS.has(method);
237
+ }
238
+
239
+ /** Retry-After in seconds, or null when absent/unparseable. */
240
+ function parseRetryAfterSeconds(headers) {
241
+ const raw = headers['retry-after'];
242
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
243
+ return null;
244
+ }
245
+ const seconds = Number(raw);
246
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
247
+ }
248
+
249
+ /** Exponential backoff with full jitter for retry number `retry` (0-based). */
250
+ function backoffDelayMs(retry) {
251
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** retry);
252
+ return Math.floor(Math.random() * ceiling);
253
+ }
254
+
255
+ function sleep(ms) {
256
+ return new Promise((resolve) => {
257
+ setTimeout(resolve, ms);
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Send a request through the concurrency gate, retrying throttled and
263
+ * transient failures: 429 (all methods) and 503/504 (GET/PUT/DELETE/PATCH)
264
+ * up to MAX_RETRIES times, honouring Retry-After; GET also retries once on
265
+ * ETIMEDOUT/ECONNRESET. A POST waits out a 429 only if that delay is
266
+ * ≤ 10 s and its total sleep stays ≤ 20 s. The slot is released before any
267
+ * backoff sleep.
268
+ * @param {object} request - See sendOnce (timeoutMs defaults to config)
269
+ * @returns {Promise<{status: number, headers: object, text: string}>} The
270
+ * final response (2xx, or the last non-retried error status)
271
+ * @throws {Error} The network error of the final attempt
272
+ */
273
+ async function requestWithRetry(request) {
274
+ const method = String(request.method).toUpperCase();
275
+ const timeoutMs = request.timeoutMs || config.REQUEST_TIMEOUT_MS;
276
+ let networkRetryUsed = false;
277
+ let totalSleepMs = 0;
278
+
279
+ for (let retry = 0; ; retry++) {
280
+ await acquireSlot();
281
+ let response;
282
+ let networkError;
283
+ try {
284
+ response = await sendOnce({ ...request, timeoutMs });
285
+ } catch (error) {
286
+ networkError = error;
287
+ } finally {
288
+ releaseSlot();
289
+ }
290
+
291
+ let delayMs;
292
+ if (networkError) {
293
+ const retryable =
294
+ method === 'GET' &&
295
+ !networkRetryUsed &&
296
+ retry < MAX_RETRIES &&
297
+ RETRYABLE_NETWORK_CODES.has(networkError.code);
298
+ if (!retryable) throw networkError;
299
+ networkRetryUsed = true;
300
+ delayMs = backoffDelayMs(retry);
301
+ } else {
302
+ if (retry >= MAX_RETRIES || !isRetryableStatus(method, response.status)) {
303
+ return response;
304
+ }
305
+ const retryAfter = parseRetryAfterSeconds(response.headers);
306
+ if (retryAfter !== null && retryAfter > MAX_RETRY_AFTER_SECONDS) {
307
+ return response; // fail fast rather than block for minutes
308
+ }
309
+ delayMs = retryAfter !== null ? retryAfter * 1000 : backoffDelayMs(retry);
310
+ if (
311
+ method === 'POST' &&
312
+ (delayMs > POST_MAX_RETRY_DELAY_MS ||
313
+ totalSleepMs + delayMs > POST_MAX_TOTAL_SLEEP_MS)
314
+ ) {
315
+ return response; // surface the 429 rather than outlast the client
316
+ }
317
+ }
318
+
319
+ console.error(
320
+ `[GRAPH-API] ${method} ${networkError ? networkError.code : response.status}; ` +
321
+ `retry ${retry + 1}/${MAX_RETRIES} in ${delayMs} ms`
322
+ );
323
+ totalSleepMs += delayMs;
324
+ await sleep(delayMs);
325
+ }
326
+ }
327
+
40
328
  /**
41
329
  * Makes a request to the Microsoft Graph API
42
330
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
331
+ * Throttled/transient responses are retried (see requestWithRetry); each
332
+ * attempt times out after OUTLOOK_REQUEST_TIMEOUT_MS.
43
333
  * @param {string} accessToken - The access token for authentication
44
334
  * @param {string} method - HTTP method (GET, POST, etc.)
45
335
  * @param {string} path - API endpoint path
46
336
  * @param {object} data - Data to send for POST/PUT requests
47
- * @param {object} queryParams - Query parameters
337
+ * @param {object} queryParams - Query parameters (never mutated)
48
338
  * @param {object} extraHeaders - Additional headers (e.g. Prefer for immutable IDs)
49
339
  * @returns {Promise<object>} - The API response
50
340
  * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
@@ -63,132 +353,71 @@ async function callGraphAPI(
63
353
  return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
64
354
  }
65
355
 
356
+ let finalUrl;
66
357
  try {
67
- // Check if path already contains the full URL (from nextLink)
68
- let finalUrl;
69
- if (path.startsWith('http://') || path.startsWith('https://')) {
70
- // Path is already a full URL (from pagination nextLink)
71
- assertGraphUrl(path);
72
- finalUrl = path;
73
- } else {
74
- // Build URL from path and queryParams
75
- // Encode path segments properly
76
- const encodedPath = path
77
- .split('/')
78
- .map((segment) => encodeURIComponent(segment))
79
- .join('/');
80
-
81
- // Build query string from parameters with special handling for OData filters
82
- let queryString = '';
83
- if (Object.keys(queryParams).length > 0) {
84
- // Handle $filter parameter specially to ensure proper URI encoding
85
- const filter = queryParams.$filter;
86
- if (filter) {
87
- delete queryParams.$filter; // Remove from regular params
88
- }
89
-
90
- // Build query string with proper encoding for regular params
91
- const params = new URLSearchParams();
92
- for (const [key, value] of Object.entries(queryParams)) {
93
- params.append(key, value);
94
- }
95
-
96
- queryString = params.toString();
97
-
98
- // Add filter parameter separately with proper encoding
99
- if (filter) {
100
- if (queryString) {
101
- queryString += `&$filter=${encodeURIComponent(filter)}`;
102
- } else {
103
- queryString = `$filter=${encodeURIComponent(filter)}`;
104
- }
105
- }
106
-
107
- if (queryString) {
108
- queryString = `?${queryString}`;
109
- }
110
- }
111
-
112
- finalUrl = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
113
- }
114
-
115
- return new Promise((resolve, reject) => {
116
- const headers = {
117
- Authorization: `Bearer ${accessToken}`,
118
- 'Content-Type': 'application/json',
119
- };
120
-
121
- // Add immutable IDs header when enabled globally
122
- if (config.USE_IMMUTABLE_IDS) {
123
- headers.Prefer = 'IdType="ImmutableId"';
124
- }
358
+ finalUrl = buildGraphUrl(path, queryParams);
359
+ } catch (error) {
360
+ console.error('Error calling Graph API:', error);
361
+ throw error;
362
+ }
125
363
 
126
- // Merge any extra headers (caller overrides take precedence). `Prefer`
127
- // is multi-valued in HTTP (comma-separated); combine both values rather
128
- // than letting a caller Prefer (e.g. outlook.timezone) clobber the global
129
- // immutable-IDs Prefer, or vice versa.
130
- const combinedPrefer =
131
- headers.Prefer && extraHeaders.Prefer
132
- ? `${headers.Prefer}, ${extraHeaders.Prefer}`
133
- : null;
134
- Object.assign(headers, extraHeaders);
135
- if (combinedPrefer) {
136
- headers.Prefer = combinedPrefer;
137
- }
364
+ const headers = {
365
+ Authorization: `Bearer ${accessToken}`,
366
+ 'Content-Type': 'application/json',
367
+ };
138
368
 
139
- const options = {
140
- method: method,
141
- headers,
142
- };
369
+ // Add immutable IDs header when enabled globally
370
+ if (config.USE_IMMUTABLE_IDS) {
371
+ headers.Prefer = 'IdType="ImmutableId"';
372
+ }
143
373
 
144
- const req = https.request(finalUrl, options, (res) => {
145
- let responseData = '';
374
+ // Merge any extra headers (caller overrides take precedence). `Prefer`
375
+ // is multi-valued in HTTP (comma-separated); combine both values rather
376
+ // than letting a caller Prefer (e.g. outlook.timezone) clobber the global
377
+ // immutable-IDs Prefer, or vice versa.
378
+ const combinedPrefer =
379
+ headers.Prefer && extraHeaders.Prefer
380
+ ? `${headers.Prefer}, ${extraHeaders.Prefer}`
381
+ : null;
382
+ Object.assign(headers, extraHeaders);
383
+ if (combinedPrefer) {
384
+ headers.Prefer = combinedPrefer;
385
+ }
146
386
 
147
- res.on('data', (chunk) => {
148
- responseData += chunk;
149
- });
387
+ const body =
388
+ data && (method === 'POST' || method === 'PATCH' || method === 'PUT')
389
+ ? JSON.stringify(data)
390
+ : null;
150
391
 
151
- res.on('end', () => {
152
- if (res.statusCode >= 200 && res.statusCode < 300) {
153
- try {
154
- responseData = responseData ? responseData : '{}';
155
- const jsonResponse = JSON.parse(responseData);
156
- resolve(jsonResponse);
157
- } catch (error) {
158
- reject(new Error(`Error parsing API response: ${error.message}`));
159
- }
160
- } else if (res.statusCode === 401) {
161
- // Token expired or invalid
162
- reject(new Error('UNAUTHORIZED'));
163
- } else {
164
- // Truncate response to avoid leaking sensitive data in error messages
165
- const safeResponse = responseData.substring(0, 200);
166
- reject(
167
- new Error(
168
- `API call failed with status ${res.statusCode}: ${safeResponse}`
169
- )
170
- );
171
- }
172
- });
173
- });
392
+ let response;
393
+ try {
394
+ response = await requestWithRetry({ url: finalUrl, method, headers, body });
395
+ } catch (error) {
396
+ const wrapped = new Error(
397
+ `Network error during API call: ${error.message}`,
398
+ { cause: error }
399
+ );
400
+ wrapped.code = error.code;
401
+ throw wrapped;
402
+ }
174
403
 
175
- req.on('error', (error) => {
176
- reject(new Error(`Network error during API call: ${error.message}`));
404
+ if (response.status >= 200 && response.status < 300) {
405
+ try {
406
+ return JSON.parse(response.text || '{}');
407
+ } catch (error) {
408
+ throw new Error(`Error parsing API response: ${error.message}`, {
409
+ cause: error,
177
410
  });
178
-
179
- if (
180
- data &&
181
- (method === 'POST' || method === 'PATCH' || method === 'PUT')
182
- ) {
183
- req.write(JSON.stringify(data));
184
- }
185
-
186
- req.end();
187
- });
188
- } catch (error) {
189
- console.error('Error calling Graph API:', error);
190
- throw error;
411
+ }
412
+ }
413
+ if (response.status === 401) {
414
+ // Token expired or invalid
415
+ throw new Error('UNAUTHORIZED');
191
416
  }
417
+ // Truncate response to avoid leaking sensitive data in error messages
418
+ throw new Error(
419
+ `API call failed with status ${response.status}: ${response.text.substring(0, 200)}`
420
+ );
192
421
  }
193
422
 
194
423
  /**
@@ -292,6 +521,12 @@ async function callGraphAPIBatch(accessToken, requests) {
292
521
  }));
293
522
  }
294
523
 
524
+ // Batch sub-request URLs are resolved by Graph itself — apply the same
525
+ // dot-segment guard as single requests.
526
+ for (const req of requests) {
527
+ assertSafeResourcePath(req.url);
528
+ }
529
+
295
530
  const batchPayload = {
296
531
  requests: requests.map((req) => ({
297
532
  id: req.id,
@@ -319,11 +554,12 @@ async function callGraphAPIBatch(accessToken, requests) {
319
554
  * In test mode (USE_TEST_MODE=true), returns mock MIME content instead of calling the real API.
320
555
  * @param {string} accessToken - The access token for authentication
321
556
  * @param {string} emailId - The email ID to export
557
+ * @param {string} [mailboxPrefix] - Resource prefix (`me` or `users/{email}`) for shared mailboxes. Defaults to `me`.
322
558
  * @returns {Promise<string>} - Raw MIME content as string
323
559
  * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
324
560
  * @throws {Error} If the HTTP status is outside 2xx or a network error occurs
325
561
  */
326
- async function callGraphAPIRaw(accessToken, emailId) {
562
+ async function callGraphAPIRaw(accessToken, emailId, mailboxPrefix = 'me') {
327
563
  // Test mode: return mock MIME content
328
564
  if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
329
565
  return mockData.getMockMimeContent
@@ -331,112 +567,55 @@ async function callGraphAPIRaw(accessToken, emailId) {
331
567
  : `MIME-Version: 1.0\nContent-Type: text/plain\n\nTest email content for ${emailId}`;
332
568
  }
333
569
 
334
- return new Promise((resolve, reject) => {
335
- const path = `me/messages/${encodeURIComponent(emailId)}/$value`;
336
- const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`;
570
+ // `emailId` is encoded as a single segment, but a bare `.`/`..` id would
571
+ // still be resolved as a dot segment — refuse it (and any in the prefix).
572
+ assertSafeResourcePath(`${mailboxPrefix}/messages`);
573
+ if (isDotSegment(emailId)) {
574
+ throw new Error(
575
+ 'Invalid resource path: IDs must not contain "." or ".." path segments'
576
+ );
577
+ }
578
+
579
+ const encodedPrefix = mailboxPrefix
580
+ .split('/')
581
+ .map((segment) => encodeURIComponent(segment))
582
+ .join('/');
583
+ const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
337
584
 
338
- const options = {
585
+ let response;
586
+ try {
587
+ response = await requestWithRetry({
588
+ url: `${config.GRAPH_API_ENDPOINT}${path}`,
339
589
  method: 'GET',
340
590
  headers: {
341
591
  Authorization: `Bearer ${accessToken}`,
342
592
  Accept: 'message/rfc822', // Request MIME format
343
593
  },
344
- };
345
-
346
- const req = https.request(finalUrl, options, (res) => {
347
- let responseData = '';
348
-
349
- // Collect data as UTF-8 string
350
- res.setEncoding('utf8');
351
-
352
- res.on('data', (chunk) => {
353
- responseData += chunk;
354
- });
355
-
356
- res.on('end', () => {
357
- if (res.statusCode >= 200 && res.statusCode < 300) {
358
- resolve(responseData);
359
- } else if (res.statusCode === 401) {
360
- reject(new Error('UNAUTHORIZED'));
361
- } else {
362
- reject(
363
- new Error(
364
- `MIME export failed with status ${res.statusCode}: ${responseData.substring(0, 200)}`
365
- )
366
- );
367
- }
368
- });
369
- });
370
-
371
- req.on('error', (error) => {
372
- reject(new Error(`Network error during MIME export: ${error.message}`));
373
594
  });
374
-
375
- req.end();
376
- });
377
- }
378
-
379
- /**
380
- * Calls Graph API with automatic auth and 401 retry.
381
- * Gets token via ensureAuthenticated(), and if a 401 occurs,
382
- * refreshes the token and retries once.
383
- * @param {string} method - HTTP method
384
- * @param {string} path - API endpoint path
385
- * @param {object} data - Request body
386
- * @param {object} queryParams - Query parameters
387
- * @param {object} extraHeaders - Additional headers
388
- * @returns {Promise<object>} - API response
389
- */
390
- async function callGraphAPIWithAuth(
391
- method,
392
- path,
393
- data = null,
394
- queryParams = {},
395
- extraHeaders = {}
396
- ) {
397
- // Lazy require to avoid circular dependency
398
- const { ensureAuthenticated, tokenStorage } = require('../auth');
399
-
400
- const accessToken = await ensureAuthenticated();
401
- try {
402
- return await callGraphAPI(
403
- accessToken,
404
- method,
405
- path,
406
- data,
407
- queryParams,
408
- extraHeaders
409
- );
410
595
  } catch (error) {
411
- if (error.message === 'UNAUTHORIZED' && tokenStorage) {
412
- console.error('[GRAPH-API] 401 received, attempting token refresh...');
413
- try {
414
- const newToken = await tokenStorage.refreshAccessToken();
415
- if (newToken) {
416
- return await callGraphAPI(
417
- newToken,
418
- method,
419
- path,
420
- data,
421
- queryParams,
422
- extraHeaders
423
- );
424
- }
425
- } catch (refreshError) {
426
- console.error(
427
- '[GRAPH-API] Token refresh failed:',
428
- refreshError.message
429
- );
430
- }
431
- }
432
- throw error;
596
+ const wrapped = new Error(
597
+ `Network error during MIME export: ${error.message}`,
598
+ { cause: error }
599
+ );
600
+ wrapped.code = error.code;
601
+ throw wrapped;
602
+ }
603
+
604
+ if (response.status >= 200 && response.status < 300) {
605
+ return response.text;
433
606
  }
607
+ if (response.status === 401) {
608
+ throw new Error('UNAUTHORIZED');
609
+ }
610
+ throw new Error(
611
+ `MIME export failed with status ${response.status}: ${response.text.substring(0, 200)}`
612
+ );
434
613
  }
435
614
 
436
615
  module.exports = {
616
+ assertSafeResourcePath,
437
617
  callGraphAPI,
438
618
  callGraphAPIPaginated,
439
619
  callGraphAPIBatch,
440
620
  callGraphAPIRaw,
441
- callGraphAPIWithAuth,
442
621
  };