@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.
- package/.env.example +20 -0
- package/README.md +51 -28
- package/advanced/index.js +319 -46
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +51 -10
- package/calendar/list.js +146 -3
- package/calendar/update.js +65 -33
- package/categories/index.js +17 -3
- package/config.js +103 -17
- package/contacts/index.js +2 -1
- package/email/attachments.js +19 -37
- package/email/conversations.js +180 -91
- package/email/delta.js +123 -13
- package/email/draft.js +66 -9
- package/email/export.js +113 -77
- package/email/folder-utils.js +29 -129
- package/email/headers.js +5 -1
- package/email/index.js +76 -19
- package/email/list.js +8 -1
- package/email/mark-as-read.js +3 -1
- package/email/mime.js +4 -1
- package/email/read.js +5 -1
- package/email/search.js +23 -9
- package/folder/create.js +11 -4
- package/folder/delete.js +9 -1
- package/folder/index.js +11 -1
- package/folder/list.js +61 -27
- package/folder/move.js +32 -7
- package/folder/resolve.js +65 -25
- package/folder/stats.js +11 -5
- package/index.js +9 -1
- package/llms-install.md +28 -9
- package/llms.txt +13 -9
- package/package.json +3 -3
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +390 -211
- package/utils/mailbox.js +77 -0
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- package/calendar/accept.js +0 -72
package/utils/graph-api.js
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
369
|
+
// Add immutable IDs header when enabled globally
|
|
370
|
+
if (config.USE_IMMUTABLE_IDS) {
|
|
371
|
+
headers.Prefer = 'IdType="ImmutableId"';
|
|
372
|
+
}
|
|
143
373
|
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
387
|
+
const body =
|
|
388
|
+
data && (method === 'POST' || method === 'PATCH' || method === 'PUT')
|
|
389
|
+
? JSON.stringify(data)
|
|
390
|
+
: null;
|
|
150
391
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
};
|