@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/advanced/index.js
CHANGED
|
@@ -11,7 +11,21 @@
|
|
|
11
11
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
12
12
|
const { ensureAuthenticated } = require('../auth');
|
|
13
13
|
const { FIELD_PRESETS } = require('../utils/field-presets');
|
|
14
|
-
const
|
|
14
|
+
const config = require('../config');
|
|
15
|
+
const { DEFAULT_TIMEZONE } = config;
|
|
16
|
+
const {
|
|
17
|
+
buildMailboxPrefix,
|
|
18
|
+
validateMailboxPrefix,
|
|
19
|
+
SHARED_MAILBOX_DISABLED_MESSAGE,
|
|
20
|
+
} = require('../utils/mailbox');
|
|
21
|
+
const { resolveFolder } = require('../folder/resolve');
|
|
22
|
+
const { getAllFoldersHierarchy } = require('../folder/list');
|
|
23
|
+
const {
|
|
24
|
+
InvalidDateTimeError,
|
|
25
|
+
toGraphDateTimeTimeZone,
|
|
26
|
+
zonedParts,
|
|
27
|
+
zonedWallTimeToUtcMs,
|
|
28
|
+
} = require('../utils/datetime');
|
|
15
29
|
|
|
16
30
|
/**
|
|
17
31
|
* Format an email for display (simplified)
|
|
@@ -42,13 +56,25 @@ function formatEmail(email, verbosity = 'standard') {
|
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
59
|
+
* Shared-mailbox hint appended to access errors while OUTLOOK_SHARED_MAILBOX
|
|
60
|
+
* is off (the token then carries no `.Shared` scope).
|
|
61
|
+
*/
|
|
62
|
+
const ENABLE_SHARED_HINT =
|
|
63
|
+
'\n\nShared-mailbox scopes are not enabled. Reading a mailbox other than your own normally needs `Mail.Read.Shared`: set OUTLOOK_SHARED_MAILBOX=read (work/school accounts only), restart the server, and run `auth action=authenticate force=true`.';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Access shared mailbox handler.
|
|
67
|
+
*
|
|
68
|
+
* With OUTLOOK_SHARED_MAILBOX off this behaves exactly as before the opt-in
|
|
69
|
+
* flag existed: `folder` (or `folderId`) is used as given — a well-known name
|
|
70
|
+
* or a folder ID — with whatever scopes the token already has. With the flag
|
|
71
|
+
* on, custom/localized names and nested paths are resolved, and
|
|
72
|
+
* `listFolders` is available.
|
|
47
73
|
*/
|
|
48
74
|
async function handleAccessSharedMailbox(args) {
|
|
49
75
|
// F-46: accept `email` as alias for `sharedMailbox`. The original
|
|
50
76
|
// param name is awkward; most callers reach for `email` first.
|
|
51
|
-
const { folder, count, outputVerbosity } = args;
|
|
77
|
+
const { folder, folderId, count, outputVerbosity, listFolders } = args;
|
|
52
78
|
const sharedMailbox = args.sharedMailbox || args.email;
|
|
53
79
|
|
|
54
80
|
if (!sharedMailbox) {
|
|
@@ -62,6 +88,40 @@ async function handleAccessSharedMailbox(args) {
|
|
|
62
88
|
};
|
|
63
89
|
}
|
|
64
90
|
|
|
91
|
+
// Validate up front, through the same helper every other tool uses. The
|
|
92
|
+
// ungated variant keeps this tool's pre-opt-in behaviour when
|
|
93
|
+
// OUTLOOK_SHARED_MAILBOX is off; with it on, buildMailboxPrefix and
|
|
94
|
+
// validateMailboxPrefix agree.
|
|
95
|
+
let mailboxPrefix;
|
|
96
|
+
try {
|
|
97
|
+
mailboxPrefix = validateMailboxPrefix(sharedMailbox);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { content: [{ type: 'text', text: error.message }] };
|
|
100
|
+
}
|
|
101
|
+
if (mailboxPrefix === 'me') {
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: 'text',
|
|
106
|
+
text: 'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// listFolders mode: enumerate the shared mailbox's folder tree so callers
|
|
113
|
+
// can discover custom subfolder names/IDs to read from.
|
|
114
|
+
const sharedEnabled = config.SHARED_MAILBOX_MODE !== 'off';
|
|
115
|
+
|
|
116
|
+
if (listFolders) {
|
|
117
|
+
if (!sharedEnabled) {
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: 'text', text: SHARED_MAILBOX_DISABLED_MESSAGE }],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return handleListSharedMailboxFolders(sharedMailbox, args);
|
|
123
|
+
}
|
|
124
|
+
|
|
65
125
|
const mailFolder = folder || 'inbox';
|
|
66
126
|
const pageSize = Math.min(count || 25, 50);
|
|
67
127
|
const verbosity = outputVerbosity || 'standard';
|
|
@@ -69,8 +129,45 @@ async function handleAccessSharedMailbox(args) {
|
|
|
69
129
|
try {
|
|
70
130
|
const accessToken = await ensureAuthenticated();
|
|
71
131
|
|
|
132
|
+
// Resolve the requested folder to an ID/well-known segment scoped to the
|
|
133
|
+
// shared mailbox. A raw `folderId` (from `listFolders`) is used as-is;
|
|
134
|
+
// otherwise custom and localized folder names are resolved via the tree.
|
|
135
|
+
let resolvedFolder;
|
|
136
|
+
if (!sharedEnabled) {
|
|
137
|
+
// Pre-opt-in behaviour: no folder resolution.
|
|
138
|
+
resolvedFolder = folderId || mailFolder;
|
|
139
|
+
} else {
|
|
140
|
+
try {
|
|
141
|
+
const resolved = await resolveFolder(accessToken, {
|
|
142
|
+
id: folderId,
|
|
143
|
+
name: mailFolder,
|
|
144
|
+
mailbox: sharedMailbox,
|
|
145
|
+
});
|
|
146
|
+
resolvedFolder = resolved.id;
|
|
147
|
+
} catch (resolveError) {
|
|
148
|
+
// Only resolution failures get the discovery hint — a Graph error
|
|
149
|
+
// (access denied, 5xx) must fall through to the generic handler below
|
|
150
|
+
// rather than masquerading as "folder not found".
|
|
151
|
+
if (!/not found|ambiguous/i.test(resolveError.message)) {
|
|
152
|
+
throw resolveError;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{
|
|
157
|
+
type: 'text',
|
|
158
|
+
text:
|
|
159
|
+
`${resolveError.message}\n\n` +
|
|
160
|
+
`Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
|
|
161
|
+
'- `access-shared-mailbox` with `listFolders: true`, or\n' +
|
|
162
|
+
`- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
72
169
|
// Build endpoint for shared mailbox
|
|
73
|
-
const endpoint =
|
|
170
|
+
const endpoint = `${mailboxPrefix}/mailFolders/${resolvedFolder}/messages`;
|
|
74
171
|
const fieldSet = verbosity === 'full' ? 'read' : 'list';
|
|
75
172
|
const queryParams = {
|
|
76
173
|
$top: pageSize.toString(),
|
|
@@ -165,7 +262,7 @@ async function handleAccessSharedMailbox(args) {
|
|
|
165
262
|
content: [
|
|
166
263
|
{
|
|
167
264
|
type: 'text',
|
|
168
|
-
text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`,
|
|
265
|
+
text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`,
|
|
169
266
|
},
|
|
170
267
|
],
|
|
171
268
|
};
|
|
@@ -193,6 +290,173 @@ async function handleAccessSharedMailbox(args) {
|
|
|
193
290
|
}
|
|
194
291
|
}
|
|
195
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Enumerate a shared mailbox's folder hierarchy.
|
|
295
|
+
*
|
|
296
|
+
* Lists the full folder tree (recursively) of a shared/delegated mailbox so
|
|
297
|
+
* callers can discover custom subfolder names, paths, and IDs — the values
|
|
298
|
+
* needed to read or search those folders.
|
|
299
|
+
* @param {string} sharedMailbox - Shared mailbox email address
|
|
300
|
+
* @param {object} args - Tool arguments (outputVerbosity)
|
|
301
|
+
* @returns {object} - MCP response
|
|
302
|
+
*/
|
|
303
|
+
async function handleListSharedMailboxFolders(sharedMailbox, args) {
|
|
304
|
+
const verbosity = args.outputVerbosity || 'standard';
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const accessToken = await ensureAuthenticated();
|
|
308
|
+
|
|
309
|
+
const { folders, warnings } = await getAllFoldersHierarchy(
|
|
310
|
+
accessToken,
|
|
311
|
+
true,
|
|
312
|
+
sharedMailbox
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
if (!folders || folders.length === 0) {
|
|
316
|
+
return {
|
|
317
|
+
content: [
|
|
318
|
+
{
|
|
319
|
+
type: 'text',
|
|
320
|
+
text: `No folders found in ${sharedMailbox}.\n\nNote: Make sure you have delegate access to this shared mailbox and the Mail.Read.Shared permission is granted.`,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const output = [];
|
|
327
|
+
output.push(`# Shared Mailbox Folders: ${sharedMailbox}`);
|
|
328
|
+
output.push(
|
|
329
|
+
`**Folders**: ${folders.length}${warnings.length > 0 ? ' (partial)' : ''}\n`
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
folders.forEach((f) => {
|
|
333
|
+
const depth = f.path ? f.path.split('/').length - 1 : 0;
|
|
334
|
+
const indent = ' '.repeat(depth);
|
|
335
|
+
let line = `${indent}- ${f.displayName}`;
|
|
336
|
+
const total = f.totalItemCount || 0;
|
|
337
|
+
const unread = f.unreadItemCount || 0;
|
|
338
|
+
line += ` (${total} items${unread > 0 ? `, ${unread} unread` : ''})`;
|
|
339
|
+
output.push(line);
|
|
340
|
+
if (verbosity === 'full') {
|
|
341
|
+
output.push(`${indent} path: \`${f.path}\``);
|
|
342
|
+
output.push(`${indent} id: \`${f.id}\``);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
if (warnings.length > 0) {
|
|
347
|
+
output.push(
|
|
348
|
+
`\n**Partial listing — ${warnings.length} branch(es) incomplete:**`
|
|
349
|
+
);
|
|
350
|
+
warnings.forEach((w) => output.push(`- ${w}`));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
output.push(
|
|
354
|
+
'\nRead a folder with `access-shared-mailbox` using its name, ' +
|
|
355
|
+
'path (e.g. `Inbox/Subfolder`), or `folderId`.'
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: 'text',
|
|
362
|
+
text: output.join('\n'),
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
_meta: {
|
|
366
|
+
sharedMailbox,
|
|
367
|
+
folderCount: folders.length,
|
|
368
|
+
partial: warnings.length > 0,
|
|
369
|
+
warnings,
|
|
370
|
+
folders: folders.map((f) => ({
|
|
371
|
+
id: f.id,
|
|
372
|
+
displayName: f.displayName,
|
|
373
|
+
folderPath: f.path,
|
|
374
|
+
parentFolderId: f.parentFolderId,
|
|
375
|
+
totalItemCount: f.totalItemCount,
|
|
376
|
+
unreadItemCount: f.unreadItemCount,
|
|
377
|
+
})),
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
} catch (error) {
|
|
381
|
+
if (error.message === 'Authentication required') {
|
|
382
|
+
return {
|
|
383
|
+
content: [
|
|
384
|
+
{
|
|
385
|
+
type: 'text',
|
|
386
|
+
text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (
|
|
393
|
+
error.message.includes('Access is denied') ||
|
|
394
|
+
error.message.includes('403')
|
|
395
|
+
) {
|
|
396
|
+
return {
|
|
397
|
+
content: [
|
|
398
|
+
{
|
|
399
|
+
type: 'text',
|
|
400
|
+
text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`,
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
content: [
|
|
408
|
+
{
|
|
409
|
+
type: 'text',
|
|
410
|
+
text: `Error listing shared mailbox folders: ${error.message}`,
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Default follow-up start for a flag with only a due date: 09:00 in
|
|
419
|
+
* DEFAULT_TIMEZONE on the due's local date, or the due itself when the due is
|
|
420
|
+
* earlier than that (a start after the due would be nonsense).
|
|
421
|
+
*/
|
|
422
|
+
function deriveFlagStart(due) {
|
|
423
|
+
let wall;
|
|
424
|
+
if (due.timeZone === DEFAULT_TIMEZONE) {
|
|
425
|
+
wall = due.dateTime;
|
|
426
|
+
} else {
|
|
427
|
+
const parts = zonedParts(Date.parse(`${due.dateTime}Z`), DEFAULT_TIMEZONE);
|
|
428
|
+
if (!parts) return { ...due };
|
|
429
|
+
wall = `${parts.date}T${parts.time}`;
|
|
430
|
+
}
|
|
431
|
+
const nineAm = `${wall.slice(0, 10)}T09:00:00`;
|
|
432
|
+
// Same zone and fixed-width prefix, so string order is time order.
|
|
433
|
+
return wall < nineAm
|
|
434
|
+
? { ...due }
|
|
435
|
+
: { dateTime: nineAm, timeZone: DEFAULT_TIMEZONE };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Describe a flag dateTimeTimeZone envelope as UTC plus DEFAULT_TIMEZONE,
|
|
440
|
+
* e.g. "2026-03-01 09:00 UTC (2026-03-01 20:00 Australia/Melbourne)".
|
|
441
|
+
*/
|
|
442
|
+
function describeFlagTime(envelope) {
|
|
443
|
+
const ms =
|
|
444
|
+
envelope.timeZone === 'UTC'
|
|
445
|
+
? Date.parse(`${envelope.dateTime}Z`)
|
|
446
|
+
: zonedWallTimeToUtcMs(envelope.dateTime, envelope.timeZone);
|
|
447
|
+
const local = (date, time) =>
|
|
448
|
+
`${date} ${time.slice(0, 5)} ${DEFAULT_TIMEZONE}`;
|
|
449
|
+
if (Number.isNaN(ms)) {
|
|
450
|
+
// DEFAULT_TIMEZONE isn't an IANA zone Intl knows; show it as given.
|
|
451
|
+
return local(envelope.dateTime.slice(0, 10), envelope.dateTime.slice(11));
|
|
452
|
+
}
|
|
453
|
+
const iso = new Date(ms).toISOString();
|
|
454
|
+
const utc = `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
455
|
+
const parts =
|
|
456
|
+
DEFAULT_TIMEZONE === 'UTC' ? null : zonedParts(ms, DEFAULT_TIMEZONE);
|
|
457
|
+
return parts ? `${utc} (${local(parts.date, parts.time)})` : utc;
|
|
458
|
+
}
|
|
459
|
+
|
|
196
460
|
/**
|
|
197
461
|
* Set message flag handler
|
|
198
462
|
*/
|
|
@@ -204,6 +468,7 @@ async function handleSetMessageFlag(args) {
|
|
|
204
468
|
startDateTime,
|
|
205
469
|
reminderDateTime: _reminderDateTime,
|
|
206
470
|
} = args;
|
|
471
|
+
const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
|
|
207
472
|
|
|
208
473
|
// Support single ID or array
|
|
209
474
|
const ids = messageIds || (messageId ? [messageId] : []);
|
|
@@ -219,43 +484,38 @@ async function handleSetMessageFlag(args) {
|
|
|
219
484
|
};
|
|
220
485
|
}
|
|
221
486
|
|
|
487
|
+
// Build flag object. Zoned values (Z/offset) are sent as the same instant in
|
|
488
|
+
// UTC; zone-less values are read in DEFAULT_TIMEZONE. Bad dates are refused
|
|
489
|
+
// here, before authenticating or calling Graph.
|
|
490
|
+
const flag = {
|
|
491
|
+
flagStatus: 'flagged',
|
|
492
|
+
};
|
|
222
493
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
494
|
+
if (startDateTime) {
|
|
495
|
+
flag.startDateTime = toGraphDateTimeTimeZone(
|
|
496
|
+
startDateTime,
|
|
497
|
+
'startDateTime'
|
|
498
|
+
);
|
|
499
|
+
}
|
|
230
500
|
if (dueDateTime) {
|
|
231
|
-
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
dateTime: dueDt,
|
|
236
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
// Graph API requires startDateTime when dueDateTime is set
|
|
240
|
-
// Default to start of the same day if not explicitly provided
|
|
241
|
-
if (startDateTime) {
|
|
242
|
-
flag.startDateTime = {
|
|
243
|
-
dateTime: startDateTime.replace(/Z$/i, ''),
|
|
244
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
245
|
-
};
|
|
246
|
-
} else {
|
|
247
|
-
const startOfDay = `${dueDt.split('T')[0]}T09:00:00`;
|
|
248
|
-
flag.startDateTime = {
|
|
249
|
-
dateTime: startOfDay,
|
|
250
|
-
timeZone: DEFAULT_TIMEZONE,
|
|
251
|
-
};
|
|
501
|
+
flag.dueDateTime = toGraphDateTimeTimeZone(dueDateTime, 'dueDateTime');
|
|
502
|
+
// Graph requires startDateTime when dueDateTime is set.
|
|
503
|
+
if (!flag.startDateTime) {
|
|
504
|
+
flag.startDateTime = deriveFlagStart(flag.dueDateTime);
|
|
252
505
|
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
506
|
+
}
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error instanceof InvalidDateTimeError) {
|
|
509
|
+
return {
|
|
510
|
+
content: [{ type: 'text', text: error.message }],
|
|
511
|
+
isError: true,
|
|
257
512
|
};
|
|
258
513
|
}
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const accessToken = await ensureAuthenticated();
|
|
259
519
|
|
|
260
520
|
// Process all messages
|
|
261
521
|
const results = [];
|
|
@@ -263,7 +523,7 @@ async function handleSetMessageFlag(args) {
|
|
|
263
523
|
|
|
264
524
|
for (const id of ids) {
|
|
265
525
|
try {
|
|
266
|
-
await callGraphAPI(accessToken, 'PATCH',
|
|
526
|
+
await callGraphAPI(accessToken, 'PATCH', `${prefix}/messages/${id}`, {
|
|
267
527
|
flag,
|
|
268
528
|
});
|
|
269
529
|
results.push({ id, success: true });
|
|
@@ -277,11 +537,11 @@ async function handleSetMessageFlag(args) {
|
|
|
277
537
|
if (results.length > 0) {
|
|
278
538
|
output.push(`Flagged ${results.length} message(s) for follow-up`);
|
|
279
539
|
|
|
280
|
-
if (dueDateTime) {
|
|
281
|
-
output.push(`**Due**: ${
|
|
540
|
+
if (flag.dueDateTime) {
|
|
541
|
+
output.push(`**Due**: ${describeFlagTime(flag.dueDateTime)}`);
|
|
282
542
|
}
|
|
283
|
-
if (startDateTime) {
|
|
284
|
-
output.push(`**Start**: ${
|
|
543
|
+
if (flag.startDateTime) {
|
|
544
|
+
output.push(`**Start**: ${describeFlagTime(flag.startDateTime)}`);
|
|
285
545
|
}
|
|
286
546
|
}
|
|
287
547
|
|
|
@@ -333,6 +593,7 @@ async function handleSetMessageFlag(args) {
|
|
|
333
593
|
*/
|
|
334
594
|
async function handleClearMessageFlag(args) {
|
|
335
595
|
const { messageId, messageIds, markComplete } = args;
|
|
596
|
+
const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
|
|
336
597
|
|
|
337
598
|
// Support single ID or array
|
|
338
599
|
const ids = messageIds || (messageId ? [messageId] : []);
|
|
@@ -370,7 +631,7 @@ async function handleClearMessageFlag(args) {
|
|
|
370
631
|
|
|
371
632
|
for (const id of ids) {
|
|
372
633
|
try {
|
|
373
|
-
await callGraphAPI(accessToken, 'PATCH',
|
|
634
|
+
await callGraphAPI(accessToken, 'PATCH', `${prefix}/messages/${id}`, {
|
|
374
635
|
flag,
|
|
375
636
|
});
|
|
376
637
|
results.push({ id, success: true });
|
|
@@ -607,7 +868,7 @@ const advancedTools = [
|
|
|
607
868
|
{
|
|
608
869
|
name: 'access-shared-mailbox',
|
|
609
870
|
description:
|
|
610
|
-
|
|
871
|
+
"List emails — or enumerate folders — from a shared mailbox the signed-in user has been granted access to (read-only). Returns paged messages from the named `sharedMailbox` (or alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview — same shape as `search-emails` list mode. `folder` accepts a well-known name (inbox, sent, archive…), a custom/localized folder display name (e.g. `Archiv`), a nested folder path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. Set `listFolders: true` to enumerate the shared mailbox's full folder tree (names, paths, IDs, counts) — use this to discover custom subfolders before reading them. Requires that the shared mailbox has been delegated to the signed-in user in Exchange (admin-configured). Use `outputVerbosity` to control field count and `count` (default 25, max 50) for page size. For full search/filter capability over a shared mailbox, prefer `search-emails` with `sharedMailbox` set. Custom/localized names, nested paths and `listFolders` need the server opt-in setting OUTLOOK_SHARED_MAILBOX (work/school only); without it `folder` must be a well-known name or a folder ID, as before.",
|
|
611
872
|
annotations: {
|
|
612
873
|
title: 'Shared Mailbox',
|
|
613
874
|
readOnlyHint: true,
|
|
@@ -629,7 +890,18 @@ const advancedTools = [
|
|
|
629
890
|
},
|
|
630
891
|
folder: {
|
|
631
892
|
type: 'string',
|
|
632
|
-
description:
|
|
893
|
+
description:
|
|
894
|
+
'Folder to read from (default: inbox). Accepts a well-known name, a custom/localized display name, or a nested path like `Inbox/Subfolder`.',
|
|
895
|
+
},
|
|
896
|
+
folderId: {
|
|
897
|
+
type: 'string',
|
|
898
|
+
description:
|
|
899
|
+
'Exact Graph folder ID to read from (e.g. from `listFolders`). Skips name resolution; takes precedence over `folder`.',
|
|
900
|
+
},
|
|
901
|
+
listFolders: {
|
|
902
|
+
type: 'boolean',
|
|
903
|
+
description:
|
|
904
|
+
"Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) instead of reading messages.",
|
|
633
905
|
},
|
|
634
906
|
count: {
|
|
635
907
|
type: 'number',
|
|
@@ -690,6 +962,7 @@ const advancedTools = [
|
|
|
690
962
|
module.exports = {
|
|
691
963
|
advancedTools,
|
|
692
964
|
handleAccessSharedMailbox,
|
|
965
|
+
handleListSharedMailboxFolders,
|
|
693
966
|
handleSetMessageFlag,
|
|
694
967
|
handleClearMessageFlag,
|
|
695
968
|
handleFindMeetingRooms,
|
package/auth/device-code.js
CHANGED
|
@@ -73,10 +73,19 @@ async function initiateDeviceCodeFlow(clientId, scopes) {
|
|
|
73
73
|
const { statusCode, body } = await postRequest(endpoint, postData);
|
|
74
74
|
|
|
75
75
|
if (statusCode < 200 || statusCode >= 300) {
|
|
76
|
-
|
|
76
|
+
const error = new Error(
|
|
77
77
|
body.error_description ||
|
|
78
78
|
`Device code request failed with status ${statusCode}`
|
|
79
79
|
);
|
|
80
|
+
// Same classification payload as pollForToken, so a scope rejection at
|
|
81
|
+
// initiation can be recognised by isScopeConsentError.
|
|
82
|
+
error.oauth = {
|
|
83
|
+
error: body.error,
|
|
84
|
+
error_codes: body.error_codes,
|
|
85
|
+
suberror: body.suberror,
|
|
86
|
+
error_description: body.error_description,
|
|
87
|
+
};
|
|
88
|
+
throw error;
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
return {
|
|
@@ -133,11 +142,22 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
|
|
|
133
142
|
throw new Error(
|
|
134
143
|
'Device code expired. Please restart the authentication process.'
|
|
135
144
|
);
|
|
136
|
-
default:
|
|
137
|
-
|
|
145
|
+
default: {
|
|
146
|
+
// Attach the raw OAuth payload so callers (e.g. handleDeviceCodeComplete)
|
|
147
|
+
// can classify the failure — notably scope-consent rejections that should
|
|
148
|
+
// trigger a base-scopes fallback. Keep the existing message text.
|
|
149
|
+
const e = new Error(
|
|
138
150
|
body.error_description ||
|
|
139
151
|
`Token polling failed: ${body.error || `status ${statusCode}`}`
|
|
140
152
|
);
|
|
153
|
+
e.oauth = {
|
|
154
|
+
error: body.error,
|
|
155
|
+
error_codes: body.error_codes,
|
|
156
|
+
suberror: body.suberror,
|
|
157
|
+
error_description: body.error_description,
|
|
158
|
+
};
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
141
161
|
}
|
|
142
162
|
}
|
|
143
163
|
|
|
@@ -146,7 +166,84 @@ async function pollForToken(clientId, deviceCode, interval, expiresIn) {
|
|
|
146
166
|
);
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
// AADSTS codes meaning "this scope value isn't supported for this account" —
|
|
170
|
+
// the only signals that justify a SILENT, DURABLE downgrade to base scopes:
|
|
171
|
+
// 650053 — "The application asked for scope '<x>' that doesn't exist on the
|
|
172
|
+
// resource" (the personal-account `.Shared` rejection)
|
|
173
|
+
// 70011 — invalid scope value
|
|
174
|
+
// Deliberately NOT here:
|
|
175
|
+
// 65001 — consent required (remediable: user/admin consent) → see
|
|
176
|
+
// isConsentRequiredError; must not silently strip capability
|
|
177
|
+
// 28000 — generic invalid request, not scope-specific
|
|
178
|
+
// invalid_grant (bare) — MFA/conditional access, revoked grant, tenant policy
|
|
179
|
+
const SCOPE_UNSUPPORTED_AADSTS_CODES = ['650053', '70011'];
|
|
180
|
+
const CONSENT_REQUIRED_AADSTS_CODES = ['65001'];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Does `err` carry one of `codes` in `oauth.error_codes` (array) or as an
|
|
184
|
+
* `AADSTS<code>` substring in `oauth.error_description` / `err.message`?
|
|
185
|
+
* @param {Error & {oauth?: object}} err
|
|
186
|
+
* @param {string[]} codes
|
|
187
|
+
* @returns {boolean}
|
|
188
|
+
*/
|
|
189
|
+
// The OAuth error payload is attacker-influencable HTTP data — fields may
|
|
190
|
+
// arrive as arrays or objects instead of strings. Coerce before substring
|
|
191
|
+
// checks so `includes` is always String.prototype.includes.
|
|
192
|
+
function asString(value) {
|
|
193
|
+
return typeof value === 'string' ? value : '';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function hasAadstsCode(err, codes) {
|
|
197
|
+
const oauth = err.oauth || {};
|
|
198
|
+
if (Array.isArray(oauth.error_codes)) {
|
|
199
|
+
const found = oauth.error_codes.map(String);
|
|
200
|
+
if (found.some((c) => codes.includes(c))) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const haystack = `${asString(oauth.error_description)} ${asString(err.message)}`;
|
|
205
|
+
// Whole-code match: `AADSTS70011` must not match `AADSTS700110`.
|
|
206
|
+
return codes.some((code) =>
|
|
207
|
+
new RegExp(`AADSTS${code}(?!\\d)`).test(haystack)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Predicate: is this a "requested scope isn't supported for this account"
|
|
213
|
+
* rejection that warrants falling back to base scopes? Deliberately narrow —
|
|
214
|
+
* a false positive silently and permanently strips shared-mailbox access.
|
|
215
|
+
* @param {Error & {oauth?: object}} err
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
function isScopeConsentError(err) {
|
|
219
|
+
if (!err) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
// Consent-required takes precedence: it is remediable, so it must surface
|
|
223
|
+
// rather than silently downgrade the scope set.
|
|
224
|
+
if (isConsentRequiredError(err)) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const oauth = err.oauth || {};
|
|
228
|
+
return (
|
|
229
|
+
oauth.error === 'invalid_scope' ||
|
|
230
|
+
hasAadstsCode(err, SCOPE_UNSUPPORTED_AADSTS_CODES)
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Predicate: consent required (AADSTS65001). Remediable via user/admin consent
|
|
236
|
+
* — surface it, never downgrade the scope set.
|
|
237
|
+
* @param {Error & {oauth?: object}} err
|
|
238
|
+
* @returns {boolean}
|
|
239
|
+
*/
|
|
240
|
+
function isConsentRequiredError(err) {
|
|
241
|
+
return Boolean(err) && hasAadstsCode(err, CONSENT_REQUIRED_AADSTS_CODES);
|
|
242
|
+
}
|
|
243
|
+
|
|
149
244
|
module.exports = {
|
|
150
245
|
initiateDeviceCodeFlow,
|
|
151
246
|
pollForToken,
|
|
247
|
+
isScopeConsentError,
|
|
248
|
+
isConsentRequiredError,
|
|
152
249
|
};
|
package/auth/token-storage.js
CHANGED
|
@@ -5,6 +5,36 @@ const https = require('https');
|
|
|
5
5
|
const querystring = require('querystring');
|
|
6
6
|
const { describeAuthError } = require('./auth-errors');
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Decide which scopes a refresh request should use. Prefer the scopes that were
|
|
10
|
+
* actually GRANTED (so a base-only fallback never re-requests `.Shared` on
|
|
11
|
+
* refresh and gets logged out ~1h later). Falls back to the parsed `scope`
|
|
12
|
+
* string, then to the configured scopes for back-compat with token files
|
|
13
|
+
* written before granted_scopes existed.
|
|
14
|
+
*
|
|
15
|
+
* `offline_access` is always included. Microsoft's token responses list only
|
|
16
|
+
* the scopes the access token is valid for — `offline_access` is not among
|
|
17
|
+
* them — and the token endpoint issues a new refresh_token only when
|
|
18
|
+
* `offline_access` is requested. Refreshing with the bare granted list would
|
|
19
|
+
* stop refresh-token rotation and eventually log the user out.
|
|
20
|
+
* @param {object|null} tokens - Stored token object
|
|
21
|
+
* @param {string[]} configScopes - Configured scope set (back-compat fallback)
|
|
22
|
+
* @returns {string[]} - Scopes to send in the refresh request
|
|
23
|
+
*/
|
|
24
|
+
function resolveRefreshScopes(tokens, configScopes) {
|
|
25
|
+
let scopes = configScopes;
|
|
26
|
+
if (tokens) {
|
|
27
|
+
if (Array.isArray(tokens.granted_scopes) && tokens.granted_scopes.length) {
|
|
28
|
+
scopes = tokens.granted_scopes;
|
|
29
|
+
} else if (typeof tokens.scope === 'string' && tokens.scope.trim()) {
|
|
30
|
+
scopes = tokens.scope.split(' ').filter(Boolean);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return scopes.includes('offline_access')
|
|
34
|
+
? scopes
|
|
35
|
+
: [...scopes, 'offline_access'];
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
class TokenStorage {
|
|
9
39
|
constructor(config) {
|
|
10
40
|
this.config = {
|
|
@@ -179,7 +209,9 @@ class TokenStorage {
|
|
|
179
209
|
client_id: this.config.clientId,
|
|
180
210
|
grant_type: 'refresh_token',
|
|
181
211
|
refresh_token: this.tokens.refresh_token,
|
|
182
|
-
|
|
212
|
+
// Use the GRANTED scopes, not the full configured set. After a base-only
|
|
213
|
+
// fallback, re-requesting `.Shared` here would fail and log the user out.
|
|
214
|
+
scope: resolveRefreshScopes(this.tokens, this.config.scopes).join(' '),
|
|
183
215
|
};
|
|
184
216
|
if (!isDeviceCode) {
|
|
185
217
|
refreshParams.client_secret = this.config.clientSecret;
|
|
@@ -272,13 +304,14 @@ class TokenStorage {
|
|
|
272
304
|
);
|
|
273
305
|
}
|
|
274
306
|
console.log('Exchanging authorization code for tokens...');
|
|
307
|
+
const requestedScopes = this.config.scopes;
|
|
275
308
|
const postData = querystring.stringify({
|
|
276
309
|
client_id: this.config.clientId,
|
|
277
310
|
client_secret: this.config.clientSecret,
|
|
278
311
|
grant_type: 'authorization_code',
|
|
279
312
|
code: authCode,
|
|
280
313
|
redirect_uri: this.config.redirectUri,
|
|
281
|
-
scope:
|
|
314
|
+
scope: requestedScopes.join(' '),
|
|
282
315
|
});
|
|
283
316
|
|
|
284
317
|
const requestOptions = {
|
|
@@ -306,6 +339,14 @@ class TokenStorage {
|
|
|
306
339
|
expires_in: responseBody.expires_in,
|
|
307
340
|
expires_at: Date.now() + responseBody.expires_in * 1000,
|
|
308
341
|
scope: responseBody.scope,
|
|
342
|
+
// Persist granted scopes so refresh re-requests exactly what
|
|
343
|
+
// was granted (mirrors the device-code path). If the token
|
|
344
|
+
// response omits `scope`, fall back to what we requested.
|
|
345
|
+
granted_scopes:
|
|
346
|
+
typeof responseBody.scope === 'string' &&
|
|
347
|
+
responseBody.scope.trim()
|
|
348
|
+
? responseBody.scope.split(' ').filter(Boolean)
|
|
349
|
+
: requestedScopes,
|
|
309
350
|
token_type: responseBody.token_type,
|
|
310
351
|
};
|
|
311
352
|
try {
|
|
@@ -379,4 +420,5 @@ class TokenStorage {
|
|
|
379
420
|
}
|
|
380
421
|
|
|
381
422
|
module.exports = TokenStorage;
|
|
423
|
+
module.exports.resolveRefreshScopes = resolveRefreshScopes;
|
|
382
424
|
// Adding a newline at the end of the file as requested by Gemini Code Assist
|