@littlebearapps/outlook-assistant 3.12.0 → 3.13.0
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 +8 -0
- package/README.md +58 -18
- package/advanced/index.js +80 -36
- package/auth/client-config.js +142 -0
- package/auth/index.js +4 -2
- package/auth/oauth-server.js +5 -1
- package/auth/token-storage.js +29 -14
- package/auth/tools.js +179 -18
- 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 +33 -10
- package/calendar/list.js +10 -19
- package/calendar/update.js +65 -33
- package/config.js +37 -1
- package/contacts/index.js +2 -1
- package/email/attachments.js +7 -35
- package/email/conversations.js +155 -88
- package/email/delta.js +29 -9
- package/email/draft.js +66 -9
- package/email/export.js +5 -79
- package/email/folder-utils.js +0 -123
- package/email/index.js +12 -10
- package/email/search.js +9 -4
- package/folder/index.js +1 -1
- package/folder/resolve.js +3 -2
- package/index.js +13 -3
- package/llms-install.md +10 -4
- package/llms.txt +7 -7
- package/package.json +3 -2
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +324 -218
- 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
|
@@ -86,14 +86,255 @@ function assertSafeResourcePath(resourcePath) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
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
|
+
|
|
89
328
|
/**
|
|
90
329
|
* Makes a request to the Microsoft Graph API
|
|
91
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.
|
|
92
333
|
* @param {string} accessToken - The access token for authentication
|
|
93
334
|
* @param {string} method - HTTP method (GET, POST, etc.)
|
|
94
335
|
* @param {string} path - API endpoint path
|
|
95
336
|
* @param {object} data - Data to send for POST/PUT requests
|
|
96
|
-
* @param {object} queryParams - Query parameters
|
|
337
|
+
* @param {object} queryParams - Query parameters (never mutated)
|
|
97
338
|
* @param {object} extraHeaders - Additional headers (e.g. Prefer for immutable IDs)
|
|
98
339
|
* @returns {Promise<object>} - The API response
|
|
99
340
|
* @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
|
|
@@ -112,135 +353,71 @@ async function callGraphAPI(
|
|
|
112
353
|
return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
|
|
113
354
|
}
|
|
114
355
|
|
|
356
|
+
let finalUrl;
|
|
115
357
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
finalUrl = path;
|
|
122
|
-
} else {
|
|
123
|
-
// Build URL from path and queryParams. Refuse dot segments before
|
|
124
|
-
// encoding: encodeURIComponent leaves `..` intact, and the URL parser
|
|
125
|
-
// would then resolve it to a different resource.
|
|
126
|
-
assertSafeResourcePath(path);
|
|
127
|
-
// Encode path segments properly
|
|
128
|
-
const encodedPath = path
|
|
129
|
-
.split('/')
|
|
130
|
-
.map((segment) => encodeURIComponent(segment))
|
|
131
|
-
.join('/');
|
|
132
|
-
|
|
133
|
-
// Build query string from parameters with special handling for OData filters
|
|
134
|
-
let queryString = '';
|
|
135
|
-
if (Object.keys(queryParams).length > 0) {
|
|
136
|
-
// Handle $filter parameter specially to ensure proper URI encoding
|
|
137
|
-
const filter = queryParams.$filter;
|
|
138
|
-
if (filter) {
|
|
139
|
-
delete queryParams.$filter; // Remove from regular params
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Build query string with proper encoding for regular params
|
|
143
|
-
const params = new URLSearchParams();
|
|
144
|
-
for (const [key, value] of Object.entries(queryParams)) {
|
|
145
|
-
params.append(key, value);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
queryString = params.toString();
|
|
149
|
-
|
|
150
|
-
// Add filter parameter separately with proper encoding
|
|
151
|
-
if (filter) {
|
|
152
|
-
if (queryString) {
|
|
153
|
-
queryString += `&$filter=${encodeURIComponent(filter)}`;
|
|
154
|
-
} else {
|
|
155
|
-
queryString = `$filter=${encodeURIComponent(filter)}`;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (queryString) {
|
|
160
|
-
queryString = `?${queryString}`;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
finalUrl = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return new Promise((resolve, reject) => {
|
|
168
|
-
const headers = {
|
|
169
|
-
Authorization: `Bearer ${accessToken}`,
|
|
170
|
-
'Content-Type': 'application/json',
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
// Add immutable IDs header when enabled globally
|
|
174
|
-
if (config.USE_IMMUTABLE_IDS) {
|
|
175
|
-
headers.Prefer = 'IdType="ImmutableId"';
|
|
176
|
-
}
|
|
358
|
+
finalUrl = buildGraphUrl(path, queryParams);
|
|
359
|
+
} catch (error) {
|
|
360
|
+
console.error('Error calling Graph API:', error);
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
177
363
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const combinedPrefer =
|
|
183
|
-
headers.Prefer && extraHeaders.Prefer
|
|
184
|
-
? `${headers.Prefer}, ${extraHeaders.Prefer}`
|
|
185
|
-
: null;
|
|
186
|
-
Object.assign(headers, extraHeaders);
|
|
187
|
-
if (combinedPrefer) {
|
|
188
|
-
headers.Prefer = combinedPrefer;
|
|
189
|
-
}
|
|
364
|
+
const headers = {
|
|
365
|
+
Authorization: `Bearer ${accessToken}`,
|
|
366
|
+
'Content-Type': 'application/json',
|
|
367
|
+
};
|
|
190
368
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
369
|
+
// Add immutable IDs header when enabled globally
|
|
370
|
+
if (config.USE_IMMUTABLE_IDS) {
|
|
371
|
+
headers.Prefer = 'IdType="ImmutableId"';
|
|
372
|
+
}
|
|
195
373
|
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}
|
|
198
386
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
387
|
+
const body =
|
|
388
|
+
data && (method === 'POST' || method === 'PATCH' || method === 'PUT')
|
|
389
|
+
? JSON.stringify(data)
|
|
390
|
+
: null;
|
|
202
391
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
reject(new Error('UNAUTHORIZED'));
|
|
215
|
-
} else {
|
|
216
|
-
// Truncate response to avoid leaking sensitive data in error messages
|
|
217
|
-
const safeResponse = responseData.substring(0, 200);
|
|
218
|
-
reject(
|
|
219
|
-
new Error(
|
|
220
|
-
`API call failed with status ${res.statusCode}: ${safeResponse}`
|
|
221
|
-
)
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
});
|
|
225
|
-
});
|
|
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
|
+
}
|
|
226
403
|
|
|
227
|
-
|
|
228
|
-
|
|
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,
|
|
229
410
|
});
|
|
230
|
-
|
|
231
|
-
if (
|
|
232
|
-
data &&
|
|
233
|
-
(method === 'POST' || method === 'PATCH' || method === 'PUT')
|
|
234
|
-
) {
|
|
235
|
-
req.write(JSON.stringify(data));
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
req.end();
|
|
239
|
-
});
|
|
240
|
-
} catch (error) {
|
|
241
|
-
console.error('Error calling Graph API:', error);
|
|
242
|
-
throw error;
|
|
411
|
+
}
|
|
243
412
|
}
|
|
413
|
+
if (response.status === 401) {
|
|
414
|
+
// Token expired or invalid
|
|
415
|
+
throw new Error('UNAUTHORIZED');
|
|
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
|
+
);
|
|
244
421
|
}
|
|
245
422
|
|
|
246
423
|
/**
|
|
@@ -399,110 +576,40 @@ async function callGraphAPIRaw(accessToken, emailId, mailboxPrefix = 'me') {
|
|
|
399
576
|
);
|
|
400
577
|
}
|
|
401
578
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
579
|
+
const encodedPrefix = mailboxPrefix
|
|
580
|
+
.split('/')
|
|
581
|
+
.map((segment) => encodeURIComponent(segment))
|
|
582
|
+
.join('/');
|
|
583
|
+
const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
|
|
584
|
+
|
|
585
|
+
let response;
|
|
586
|
+
try {
|
|
587
|
+
response = await requestWithRetry({
|
|
588
|
+
url: `${config.GRAPH_API_ENDPOINT}${path}`,
|
|
411
589
|
method: 'GET',
|
|
412
590
|
headers: {
|
|
413
591
|
Authorization: `Bearer ${accessToken}`,
|
|
414
592
|
Accept: 'message/rfc822', // Request MIME format
|
|
415
593
|
},
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
const req = https.request(finalUrl, options, (res) => {
|
|
419
|
-
let responseData = '';
|
|
420
|
-
|
|
421
|
-
// Collect data as UTF-8 string
|
|
422
|
-
res.setEncoding('utf8');
|
|
423
|
-
|
|
424
|
-
res.on('data', (chunk) => {
|
|
425
|
-
responseData += chunk;
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
res.on('end', () => {
|
|
429
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
430
|
-
resolve(responseData);
|
|
431
|
-
} else if (res.statusCode === 401) {
|
|
432
|
-
reject(new Error('UNAUTHORIZED'));
|
|
433
|
-
} else {
|
|
434
|
-
reject(
|
|
435
|
-
new Error(
|
|
436
|
-
`MIME export failed with status ${res.statusCode}: ${responseData.substring(0, 200)}`
|
|
437
|
-
)
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
req.on('error', (error) => {
|
|
444
|
-
reject(new Error(`Network error during MIME export: ${error.message}`));
|
|
445
594
|
});
|
|
446
|
-
|
|
447
|
-
req.end();
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Calls Graph API with automatic auth and 401 retry.
|
|
453
|
-
* Gets token via ensureAuthenticated(), and if a 401 occurs,
|
|
454
|
-
* refreshes the token and retries once.
|
|
455
|
-
* @param {string} method - HTTP method
|
|
456
|
-
* @param {string} path - API endpoint path
|
|
457
|
-
* @param {object} data - Request body
|
|
458
|
-
* @param {object} queryParams - Query parameters
|
|
459
|
-
* @param {object} extraHeaders - Additional headers
|
|
460
|
-
* @returns {Promise<object>} - API response
|
|
461
|
-
*/
|
|
462
|
-
async function callGraphAPIWithAuth(
|
|
463
|
-
method,
|
|
464
|
-
path,
|
|
465
|
-
data = null,
|
|
466
|
-
queryParams = {},
|
|
467
|
-
extraHeaders = {}
|
|
468
|
-
) {
|
|
469
|
-
// Lazy require to avoid circular dependency
|
|
470
|
-
const { ensureAuthenticated, tokenStorage } = require('../auth');
|
|
471
|
-
|
|
472
|
-
const accessToken = await ensureAuthenticated();
|
|
473
|
-
try {
|
|
474
|
-
return await callGraphAPI(
|
|
475
|
-
accessToken,
|
|
476
|
-
method,
|
|
477
|
-
path,
|
|
478
|
-
data,
|
|
479
|
-
queryParams,
|
|
480
|
-
extraHeaders
|
|
481
|
-
);
|
|
482
595
|
} catch (error) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
newToken,
|
|
490
|
-
method,
|
|
491
|
-
path,
|
|
492
|
-
data,
|
|
493
|
-
queryParams,
|
|
494
|
-
extraHeaders
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
} catch (refreshError) {
|
|
498
|
-
console.error(
|
|
499
|
-
'[GRAPH-API] Token refresh failed:',
|
|
500
|
-
refreshError.message
|
|
501
|
-
);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
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;
|
|
505
602
|
}
|
|
603
|
+
|
|
604
|
+
if (response.status >= 200 && response.status < 300) {
|
|
605
|
+
return response.text;
|
|
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
|
+
);
|
|
506
613
|
}
|
|
507
614
|
|
|
508
615
|
module.exports = {
|
|
@@ -511,5 +618,4 @@ module.exports = {
|
|
|
511
618
|
callGraphAPIPaginated,
|
|
512
619
|
callGraphAPIBatch,
|
|
513
620
|
callGraphAPIRaw,
|
|
514
|
-
callGraphAPIWithAuth,
|
|
515
621
|
};
|
package/utils/mock-data.js
CHANGED
|
@@ -47,6 +47,9 @@ function simulateGraphAPIResponse(method, path, _data, _queryParams) {
|
|
|
47
47
|
hasAttachments: false,
|
|
48
48
|
importance: 'normal',
|
|
49
49
|
isRead: false,
|
|
50
|
+
// A draft, so draft update/send/delete pass the draft guard in
|
|
51
|
+
// test mode (they look the ID up before acting).
|
|
52
|
+
isDraft: true,
|
|
50
53
|
internetMessageHeaders: [],
|
|
51
54
|
};
|
|
52
55
|
} else {
|
package/utils/odata-helpers.js
CHANGED
|
@@ -14,6 +14,28 @@ function escapeODataString(str) {
|
|
|
14
14
|
return str.replace(/'/g, "''");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Escapes text for use inside a double-quoted Graph `$search` phrase.
|
|
19
|
+
* Graph requires `"` and `\` inside the phrase to be backslash-escaped;
|
|
20
|
+
* backslashes go first so the escapes added for quotes are not doubled. (#251)
|
|
21
|
+
* @param {string} str - Raw user text
|
|
22
|
+
* @returns {string} - Text safe to place between the phrase's quotes
|
|
23
|
+
*/
|
|
24
|
+
function escapeSearchPhrase(str) {
|
|
25
|
+
if (!str) return str;
|
|
26
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps user text as a double-quoted Graph `$search` phrase, escaping it.
|
|
31
|
+
* Not for caller-written `$search` expressions, which pass through as-is.
|
|
32
|
+
* @param {string} str - Raw user text
|
|
33
|
+
* @returns {string} - e.g. `Sam "the man" Lee` → `"Sam \"the man\" Lee"`
|
|
34
|
+
*/
|
|
35
|
+
function quoteSearchPhrase(str) {
|
|
36
|
+
return `"${escapeSearchPhrase(str)}"`;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/**
|
|
18
40
|
* Builds an OData filter from filter conditions
|
|
19
41
|
* @param {Array<string>} conditions - Array of filter conditions
|
|
@@ -29,5 +51,7 @@ function buildODataFilter(conditions) {
|
|
|
29
51
|
|
|
30
52
|
module.exports = {
|
|
31
53
|
escapeODataString,
|
|
54
|
+
escapeSearchPhrase,
|
|
55
|
+
quoteSearchPhrase,
|
|
32
56
|
buildODataFilter,
|
|
33
57
|
};
|