@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
package/calendar/list.js CHANGED
@@ -4,6 +4,126 @@
4
4
  const config = require('../config');
5
5
  const { callGraphAPI } = require('../utils/graph-api');
6
6
  const { ensureAuthenticated } = require('../auth');
7
+ const {
8
+ escapeODataString,
9
+ buildODataFilter,
10
+ } = require('../utils/odata-helpers');
11
+ const { parseIsoInstant } = require('../utils/datetime');
12
+
13
+ const MAX_SUBJECT_LENGTH = 255;
14
+
15
+ /**
16
+ * Error raised for invalid list-events arguments, so the handler can report it
17
+ * as a tool error without touching the network.
18
+ */
19
+ class ListEventsArgumentError extends Error {}
20
+
21
+ /**
22
+ * Parse an ISO 8601 instant (with `Z` or a ±hh:mm offset) and return it as a
23
+ * UTC ISO string. Events are requested in UTC, so comparing against a UTC
24
+ * instant keeps the filter correct for offset inputs such as +10:00.
25
+ * The schema declares `format: "date-time"` but the MCP schema-coerce layer
26
+ * does not enforce JSON Schema `format`, so we enforce here at runtime.
27
+ */
28
+ function toUtcIsoDateTime(value, paramName) {
29
+ const parsed = parseIsoInstant(value);
30
+ if (Number.isNaN(parsed)) {
31
+ throw new ListEventsArgumentError(
32
+ `Invalid ${paramName}: expected an ISO 8601 datetime with "Z" or a ±hh:mm offset, e.g. "2026-01-01T00:00:00Z" (got ${JSON.stringify(String(value).slice(0, 40))}).`
33
+ );
34
+ }
35
+ return new Date(parsed).toISOString();
36
+ }
37
+
38
+ /**
39
+ * Validate the subject filter: a non-empty-safe string of bounded length that
40
+ * can be URL-encoded (a lone surrogate would make encodeURIComponent throw).
41
+ */
42
+ function assertValidSubject(subject) {
43
+ if (typeof subject !== 'string') {
44
+ throw new ListEventsArgumentError('Invalid subject: expected a string.');
45
+ }
46
+ if (subject.length > MAX_SUBJECT_LENGTH) {
47
+ throw new ListEventsArgumentError(
48
+ `Invalid subject: must be at most ${MAX_SUBJECT_LENGTH} characters.`
49
+ );
50
+ }
51
+ try {
52
+ encodeURIComponent(subject);
53
+ } catch (_e) {
54
+ throw new ListEventsArgumentError(
55
+ 'Invalid subject: contains malformed Unicode.'
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Build the $filter clause for the list-events Graph query.
62
+ *
63
+ * Backward-compatible behaviour: when no search args are supplied, the filter
64
+ * defaults to `start/dateTime ge '<now>'` so callers without parameters keep
65
+ * seeing only upcoming events. When ANY of startAfter/startBefore/subject are
66
+ * supplied, those replace the default and are AND-ed together.
67
+ *
68
+ * startAfter/startBefore must carry a zone and are normalised to UTC; invalid
69
+ * values raise before any Graph call is made. Single quotes in the subject are
70
+ * escaped via OData rules (`'` -> `''`) to prevent filter injection.
71
+ *
72
+ * Graph requires `$orderby` properties to lead the `$filter`, so a subject-only
73
+ * search gets a `start/dateTime ge '1900-…'` lead clause (which matches every
74
+ * event) to avoid an InefficientFilter error.
75
+ *
76
+ * @param {object} args - { startAfter?, startBefore?, subject? }
77
+ * @returns {string} - The complete $filter expression
78
+ */
79
+ function buildListEventsFilter(args) {
80
+ const { startAfter, startBefore, subject } = args;
81
+ const hasAnyFilter = Boolean(startAfter || startBefore || subject);
82
+
83
+ const conditions = [];
84
+
85
+ if (hasAnyFilter) {
86
+ const after = startAfter
87
+ ? toUtcIsoDateTime(startAfter, 'startAfter')
88
+ : null;
89
+ const before = startBefore
90
+ ? toUtcIsoDateTime(startBefore, 'startBefore')
91
+ : null;
92
+ if (after && before && after >= before) {
93
+ throw new ListEventsArgumentError(
94
+ 'Invalid range: startAfter must be earlier than startBefore.'
95
+ );
96
+ }
97
+ if (subject) assertValidSubject(subject);
98
+
99
+ if (after) conditions.push(`start/dateTime ge '${after}'`);
100
+ if (before) conditions.push(`start/dateTime lt '${before}'`);
101
+ if (!after && !before) {
102
+ conditions.push("start/dateTime ge '1900-01-01T00:00:00.000Z'");
103
+ }
104
+ if (subject) {
105
+ conditions.push(`contains(subject, '${escapeODataString(subject)}')`);
106
+ }
107
+ } else {
108
+ conditions.push(`start/dateTime ge '${new Date().toISOString()}'`);
109
+ }
110
+
111
+ return buildODataFilter(conditions);
112
+ }
113
+
114
+ /**
115
+ * Sort order for list-events: oldest first for upcoming or bounded windows;
116
+ * newest first when the search only looks backwards (an upper bound only, or a
117
+ * subject with no dates), so `$top` returns the most recent matches rather
118
+ * than the oldest events in the calendar.
119
+ * @param {object} args - { startAfter?, startBefore?, subject? }
120
+ * @returns {string} - The $orderby expression
121
+ */
122
+ function listEventsOrderBy(args) {
123
+ const { startAfter, startBefore, subject } = args;
124
+ const newestFirst = !startAfter && Boolean(startBefore || subject);
125
+ return newestFirst ? 'start/dateTime desc' : 'start/dateTime';
126
+ }
7
127
 
8
128
  /**
9
129
  * Normalise a Graph dateTimeTimeZone value to a canonical UTC ISO-8601 string
@@ -89,7 +209,28 @@ function formatLocal(utcIso, tz) {
89
209
  * @returns {object} - MCP response
90
210
  */
91
211
  async function handleListEvents(args) {
92
- const count = Math.min(args.count || 10, config.MAX_RESULT_COUNT);
212
+ // Whole number in 1..MAX_RESULT_COUNT: Graph rejects $top below 1 or
213
+ // fractional, and the schema promises the cap.
214
+ const requested = args.count == null ? 10 : Math.floor(args.count);
215
+ const count = Math.min(
216
+ Math.max(Number.isFinite(requested) ? requested : 10, 1),
217
+ config.MAX_RESULT_COUNT
218
+ );
219
+
220
+ // Validate arguments before authenticating, so a bad argument is reported
221
+ // as such (and never reaches the network).
222
+ let filter;
223
+ try {
224
+ filter = buildListEventsFilter(args);
225
+ } catch (error) {
226
+ if (error instanceof ListEventsArgumentError) {
227
+ return {
228
+ content: [{ type: 'text', text: error.message }],
229
+ isError: true,
230
+ };
231
+ }
232
+ throw error;
233
+ }
93
234
 
94
235
  try {
95
236
  // Get access token
@@ -101,8 +242,8 @@ async function handleListEvents(args) {
101
242
  // Add query parameters
102
243
  const queryParams = {
103
244
  $top: count,
104
- $orderby: 'start/dateTime',
105
- $filter: `start/dateTime ge '${new Date().toISOString()}'`,
245
+ $orderby: listEventsOrderBy(args),
246
+ $filter: filter,
106
247
  $select: config.CALENDAR_SELECT_FIELDS,
107
248
  };
108
249
 
@@ -198,3 +339,5 @@ handleListEvents.toUtcIso = toUtcIso;
198
339
  handleListEvents.formatLocal = formatLocal;
199
340
 
200
341
  module.exports = handleListEvents;
342
+ module.exports.buildListEventsFilter = buildListEventsFilter;
343
+ module.exports.listEventsOrderBy = listEventsOrderBy;
@@ -11,7 +11,7 @@
11
11
  * - subject
12
12
  * - start (ISO string or {dateTime, timeZone} object)
13
13
  * - end (same shape as start)
14
- * - attendees (full replacement list of emails)
14
+ * - attendees (full replacement list; email strings or {email, type})
15
15
  * - body (sent as HTML)
16
16
  * - location (displayName)
17
17
  * - isOnlineMeeting
@@ -21,12 +21,16 @@
21
21
  * - categories (full replacement array of category names)
22
22
  * - reminderMinutesBeforeStart
23
23
  *
24
- * `dryRun: true` returns a preview of the PATCH payload without calling
25
- * Graph — useful for confirming behaviour before mutating real data.
24
+ * `dryRun: true` returns a preview of the PATCH payload without changing
25
+ * anything — useful for confirming behaviour before mutating real data.
26
+ * When an attendee has no explicit type, the event's current attendees are
27
+ * read first (also on dryRun) so existing optional/resource attendees keep
28
+ * their type (#249).
26
29
  */
27
30
  const { callGraphAPI } = require('../utils/graph-api');
28
31
  const { ensureAuthenticated } = require('../auth');
29
32
  const { DEFAULT_TIMEZONE } = require('../config');
33
+ const { normaliseAttendees, buildAttendees } = require('./attendees');
30
34
 
31
35
  const SENSITIVITY_VALUES = new Set([
32
36
  'normal',
@@ -82,6 +86,9 @@ async function handleUpdateEvent(args) {
82
86
  // Graph treats absent properties as "no change", so we never overwrite
83
87
  // something the user didn't intend to touch.
84
88
  const patch = {};
89
+ // Attendee entries without an explicit type, which need the event's
90
+ // current attendees to resolve (#249).
91
+ let untypedAttendees = null;
85
92
 
86
93
  if (subject !== undefined) patch.subject = subject;
87
94
 
@@ -102,10 +109,17 @@ async function handleUpdateEvent(args) {
102
109
  if (attendees !== undefined) {
103
110
  // Replaces the full attendee list — Graph PATCH on this property is
104
111
  // not additive. Caller must pass the desired complete list.
105
- patch.attendees = (attendees || []).map((email) => ({
106
- emailAddress: { address: email },
107
- type: 'required',
108
- }));
112
+ let entries;
113
+ try {
114
+ entries = normaliseAttendees(attendees || []);
115
+ } catch (error) {
116
+ return {
117
+ content: [{ type: 'text', text: error.message }],
118
+ isError: true,
119
+ };
120
+ }
121
+ patch.attendees = buildAttendees(entries);
122
+ if (entries.some((entry) => !entry.type)) untypedAttendees = entries;
109
123
  }
110
124
 
111
125
  if (body !== undefined) {
@@ -193,34 +207,52 @@ async function handleUpdateEvent(args) {
193
207
  };
194
208
  }
195
209
 
196
- // dryRun: don't touch Graph; just show the caller what would be sent.
197
- if (dryRun) {
198
- return {
199
- content: [
200
- {
201
- type: 'text',
202
- text: [
203
- `**Dry run** — would PATCH \`me/events/${eventId}\` with:`,
204
- '',
205
- '```json',
206
- JSON.stringify(patch, null, 2),
207
- '```',
208
- '',
209
- `Fields that would change: ${Object.keys(patch).join(', ')}`,
210
- ].join('\n'),
210
+ try {
211
+ let accessToken;
212
+
213
+ // Keep the type of attendees already on the event: an entry without an
214
+ // explicit type takes its current type, so a room or optional attendee
215
+ // isn't turned into a required one. Runs before the dryRun return so the
216
+ // preview shows the resolved types.
217
+ if (untypedAttendees) {
218
+ accessToken = await ensureAuthenticated();
219
+ const current = await callGraphAPI(
220
+ accessToken,
221
+ 'GET',
222
+ `me/events/${eventId}`,
223
+ null,
224
+ { $select: 'attendees' }
225
+ );
226
+ patch.attendees = buildAttendees(untypedAttendees, current?.attendees);
227
+ }
228
+
229
+ // dryRun: show the caller what would be sent without changing anything.
230
+ if (dryRun) {
231
+ return {
232
+ content: [
233
+ {
234
+ type: 'text',
235
+ text: [
236
+ `**Dry run** — would PATCH \`me/events/${eventId}\` with:`,
237
+ '',
238
+ '```json',
239
+ JSON.stringify(patch, null, 2),
240
+ '```',
241
+ '',
242
+ `Fields that would change: ${Object.keys(patch).join(', ')}`,
243
+ ].join('\n'),
244
+ },
245
+ ],
246
+ _meta: {
247
+ eventId,
248
+ dryRun: true,
249
+ patch,
250
+ fieldsChanged: Object.keys(patch),
211
251
  },
212
- ],
213
- _meta: {
214
- eventId,
215
- dryRun: true,
216
- patch,
217
- fieldsChanged: Object.keys(patch),
218
- },
219
- };
220
- }
252
+ };
253
+ }
221
254
 
222
- try {
223
- const accessToken = await ensureAuthenticated();
255
+ accessToken = accessToken || (await ensureAuthenticated());
224
256
  const endpoint = `me/events/${eventId}`;
225
257
 
226
258
  const response = await callGraphAPI(accessToken, 'PATCH', endpoint, patch);
@@ -5,6 +5,7 @@
5
5
  */
6
6
  const { callGraphAPI } = require('../utils/graph-api');
7
7
  const { ensureAuthenticated } = require('../auth');
8
+ const { buildMailboxPrefix } = require('../utils/mailbox');
8
9
 
9
10
  // Category color presets (Outlook uses these names)
10
11
  const CATEGORY_COLORS = [
@@ -436,6 +437,7 @@ async function handleDeleteCategory(args) {
436
437
  */
437
438
  async function handleApplyCategory(args) {
438
439
  const { messageId, messageIds, categories, action } = args;
440
+ const mailbox = args.sharedMailbox || args.email || null;
439
441
 
440
442
  // Support single ID or array
441
443
  const ids = messageIds || (messageId ? [messageId] : []);
@@ -477,6 +479,9 @@ async function handleApplyCategory(args) {
477
479
  }
478
480
 
479
481
  try {
482
+ // Inside the try so an invalid mailbox returns the handler's normal
483
+ // error object instead of a rejected promise.
484
+ const prefix = buildMailboxPrefix(mailbox);
480
485
  const accessToken = await ensureAuthenticated();
481
486
 
482
487
  const results = [];
@@ -491,7 +496,7 @@ async function handleApplyCategory(args) {
491
496
  const current = await callGraphAPI(
492
497
  accessToken,
493
498
  'GET',
494
- `me/messages/${id}`,
499
+ `${prefix}/messages/${id}`,
495
500
  null,
496
501
  { $select: 'categories' }
497
502
  );
@@ -507,7 +512,7 @@ async function handleApplyCategory(args) {
507
512
  }
508
513
  }
509
514
 
510
- await callGraphAPI(accessToken, 'PATCH', `me/messages/${id}`, {
515
+ await callGraphAPI(accessToken, 'PATCH', `${prefix}/messages/${id}`, {
511
516
  categories: newCategories,
512
517
  });
513
518
 
@@ -901,7 +906,7 @@ const categoriesTools = [
901
906
  {
902
907
  name: 'apply-category',
903
908
  description:
904
- "Tag or untag email messages with master categories (those created via `manage-category`). action=`set` (default) replaces the message's category set with the supplied `categories` array. action=`add` appends categories to whatever's already on the message. action=`remove` removes only the named categories, leaving the rest. Accepts either `messageId` (single) or `messageIds` (batch via Graph `$batch`). `categories` are matched by display name — names must already exist in the master list (create via `manage-category` first). Returns per-message confirmation.",
909
+ "Tag or untag email messages with master categories (those created via `manage-category`). action=`set` (default) replaces the message's category set with the supplied `categories` array. action=`add` appends categories to whatever's already on the message. action=`remove` removes only the named categories, leaving the rest. Accepts either `messageId` (single) or `messageIds` (batch via Graph `$batch`). `categories` are matched by display name — names must already exist in the target mailbox's master list. For your own mailbox, create them via `manage-category` first; for a shared mailbox, the names must already exist there (`manage-category` only manages the signed-in account's master list). Pass `sharedMailbox` (or alias `email`) to categorise messages in a shared/delegated mailbox instead of the signed-in account (requires Mail.ReadWrite.Shared + delegate access). Returns per-message confirmation.",
905
910
  annotations: {
906
911
  title: 'Apply Categories',
907
912
  readOnlyHint: false,
@@ -931,6 +936,15 @@ const categoriesTools = [
931
936
  description:
932
937
  'set (replace all), add (append), remove (remove specific). Default: set',
933
938
  },
939
+ sharedMailbox: {
940
+ type: 'string',
941
+ description:
942
+ 'Email address of a shared/delegated mailbox whose messages to categorise instead of the signed-in account. Requires delegate access + Mail.ReadWrite.Shared. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
943
+ },
944
+ email: {
945
+ type: 'string',
946
+ description: 'Alias for `sharedMailbox`.',
947
+ },
934
948
  },
935
949
  additionalProperties: false,
936
950
  required: ['categories'],
package/config.js CHANGED
@@ -57,7 +57,6 @@ if (
57
57
  !VALID_AUDIENCE_LITERALS.has(AUTH_AUDIENCE) &&
58
58
  !TENANT_GUID_RE.test(AUTH_AUDIENCE)
59
59
  ) {
60
- // eslint-disable-next-line no-console
61
60
  console.warn(
62
61
  `[outlook-assistant] OUTLOOK_AUTH_AUDIENCE="${AUTH_AUDIENCE}" is not a recognised value. ` +
63
62
  `Expected one of: common, consumers, organizations, or a tenant GUID. ` +
@@ -65,6 +64,88 @@ if (
65
64
  );
66
65
  }
67
66
 
67
+ // Shared/delegated mailbox access is OPT-IN (work/school accounts only).
68
+ // With OUTLOOK_SHARED_MAILBOX unset, sign-in requests exactly BASE_SCOPES —
69
+ // nobody's consent prompt or token changes unless they enable it:
70
+ // OUTLOOK_SHARED_MAILBOX=read → Mail.Read.Shared
71
+ // OUTLOOK_SHARED_MAILBOX=true|readwrite|1 → Mail.Read.Shared + Mail.ReadWrite.Shared
72
+ // Sending/drafts from a shared mailbox are out of scope (Mail.Send.Shared is
73
+ // never requested). When enabled, the device-code flow falls back to
74
+ // BASE_SCOPES only on errors proving the account can't use `.Shared` scopes
75
+ // (see auth/device-code.js isScopeConsentError); consent-required errors
76
+ // (AADSTS65001) surface remediation instead of downgrading.
77
+ const ALL_SHARED_SCOPES = ['Mail.Read.Shared', 'Mail.ReadWrite.Shared'];
78
+
79
+ /**
80
+ * Parse OUTLOOK_SHARED_MAILBOX into a mode.
81
+ * @param {string|undefined} raw
82
+ * @returns {'off'|'read'|'readwrite'}
83
+ */
84
+ function parseSharedMailboxMode(raw) {
85
+ const value = String(raw || '')
86
+ .trim()
87
+ .toLowerCase();
88
+ if (value === 'read') return 'read';
89
+ if (['true', 'readwrite', '1'].includes(value)) return 'readwrite';
90
+ if (value && !['false', '0', 'off', 'no'].includes(value)) {
91
+ console.warn(
92
+ `[outlook-assistant] OUTLOOK_SHARED_MAILBOX="${raw}" is not a recognised value. ` +
93
+ 'Expected read, true/readwrite/1, or unset. Shared-mailbox support stays off.'
94
+ );
95
+ }
96
+ return 'off';
97
+ }
98
+
99
+ const SHARED_MAILBOX_MODE = parseSharedMailboxMode(
100
+ process.env.OUTLOOK_SHARED_MAILBOX
101
+ );
102
+ const SHARED_SCOPES_BY_MODE = {
103
+ off: [],
104
+ read: ['Mail.Read.Shared'],
105
+ readwrite: [...ALL_SHARED_SCOPES],
106
+ };
107
+ const SHARED_SCOPES = SHARED_SCOPES_BY_MODE[SHARED_MAILBOX_MODE];
108
+
109
+ // Base scopes consentable by ANY account type (personal + work/school).
110
+ const BASE_SCOPES = [
111
+ 'offline_access',
112
+ 'User.Read',
113
+ 'Mail.Read',
114
+ 'Mail.ReadWrite',
115
+ 'Mail.Send',
116
+ 'Calendars.Read',
117
+ 'Calendars.ReadWrite',
118
+ 'Contacts.Read',
119
+ 'Contacts.ReadWrite',
120
+ 'People.Read',
121
+ 'MailboxSettings.ReadWrite',
122
+ // Org-dependent scopes (work/school accounts only):
123
+ // 'Place.Read.All', // find-meeting-rooms tool
124
+ ];
125
+
126
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60000;
127
+
128
+ /**
129
+ * Parse OUTLOOK_REQUEST_TIMEOUT_MS: per-attempt Graph inactivity timeout
130
+ * (ms with no data received). Unset or invalid → 60000 (invalid values warn).
131
+ * @param {string|undefined} raw
132
+ * @returns {number}
133
+ */
134
+ function parseRequestTimeoutMs(raw) {
135
+ if (raw === undefined || String(raw).trim() === '') {
136
+ return DEFAULT_REQUEST_TIMEOUT_MS;
137
+ }
138
+ const value = Number(raw);
139
+ if (!Number.isInteger(value) || value <= 0) {
140
+ console.warn(
141
+ `[outlook-assistant] OUTLOOK_REQUEST_TIMEOUT_MS="${raw}" is not a positive integer. ` +
142
+ `Using the default of ${DEFAULT_REQUEST_TIMEOUT_MS} ms.`
143
+ );
144
+ return DEFAULT_REQUEST_TIMEOUT_MS;
145
+ }
146
+ return value;
147
+ }
148
+
68
149
  module.exports = {
69
150
  // Server information
70
151
  SERVER_NAME: 'outlook-assistant',
@@ -73,27 +154,25 @@ module.exports = {
73
154
  // Test mode setting
74
155
  USE_TEST_MODE: process.env.USE_TEST_MODE === 'true',
75
156
 
157
+ // OAuth scope sets (exported so tests + the fallback logic can reference them)
158
+ BASE_SCOPES,
159
+ // `.Shared` scopes requested at sign-in for the configured mode ([] = off)
160
+ SHARED_SCOPES,
161
+ ALL_SHARED_SCOPES,
162
+ // 'off' | 'read' | 'readwrite' — from OUTLOOK_SHARED_MAILBOX (opt-in)
163
+ SHARED_MAILBOX_MODE,
164
+ parseSharedMailboxMode,
165
+
76
166
  // Authentication configuration
77
167
  AUTH_CONFIG: {
78
168
  clientId: process.env.OUTLOOK_CLIENT_ID || '',
79
169
  clientSecret: process.env.OUTLOOK_CLIENT_SECRET || '',
80
170
  redirectUri: 'http://localhost:3333/auth/callback',
81
- scopes: [
82
- 'offline_access',
83
- 'User.Read',
84
- 'Mail.Read',
85
- 'Mail.ReadWrite',
86
- 'Mail.Send',
87
- 'Calendars.Read',
88
- 'Calendars.ReadWrite',
89
- 'Contacts.Read',
90
- 'Contacts.ReadWrite',
91
- 'People.Read',
92
- 'MailboxSettings.ReadWrite',
93
- // Org-dependent scopes (work/school accounts only):
94
- // 'Mail.Read.Shared', // access-shared-mailbox tool
95
- // 'Place.Read.All', // find-meeting-rooms tool
96
- ],
171
+ // Base scopes, plus the `.Shared` scopes only when OUTLOOK_SHARED_MAILBOX
172
+ // opts in. With the flag on, device-code auth falls back to
173
+ // fallbackScopes (base only) when the account rejects `.Shared`.
174
+ scopes: [...BASE_SCOPES, ...SHARED_SCOPES],
175
+ fallbackScopes: BASE_SCOPES,
97
176
  tokenStorePath: path.join(homeDir, '.outlook-assistant-tokens.json'),
98
177
  authServerUrl: 'http://localhost:3333',
99
178
  audience: AUTH_AUDIENCE,
@@ -140,6 +219,13 @@ module.exports = {
140
219
  // Immutable IDs (opt-in: IDs persist through folder moves)
141
220
  USE_IMMUTABLE_IDS: process.env.OUTLOOK_IMMUTABLE_IDS === 'true',
142
221
 
222
+ // Per-attempt Graph inactivity timeout: an attempt that receives no data
223
+ // for this many ms is abandoned (not an overall deadline). Throttled and
224
+ // transient responses are retried by utils/graph-api.js.
225
+ REQUEST_TIMEOUT_MS: parseRequestTimeoutMs(
226
+ process.env.OUTLOOK_REQUEST_TIMEOUT_MS
227
+ ),
228
+
143
229
  // Timezone — IANA zone (e.g. "Australia/Melbourne", "Europe/London",
144
230
  // "America/New_York"). Override per-deployment via OUTLOOK_DEFAULT_TIMEZONE.
145
231
  // Default preserves the historic value for backwards compatibility.
package/contacts/index.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  callGraphAPIPaginated: _callGraphAPIPaginated,
9
9
  } = require('../utils/graph-api');
10
10
  const { ensureAuthenticated } = require('../auth');
11
+ const { quoteSearchPhrase } = require('../utils/odata-helpers');
11
12
 
12
13
  /**
13
14
  * Contact field presets for different use cases
@@ -597,7 +598,7 @@ async function handleSearchPeople(args) {
597
598
 
598
599
  const endpoint = 'me/people';
599
600
  const queryParams = {
600
- $search: `"${query}"`,
601
+ $search: quoteSearchPhrase(query),
601
602
  $top: count,
602
603
  $select:
603
604
  'id,displayName,scoredEmailAddresses,phones,companyName,jobTitle,department,userPrincipalName,personType',
@@ -9,6 +9,8 @@ const path = require('path');
9
9
  const _config = require('../config'); // Reserved for future use
10
10
  const { callGraphAPI } = require('../utils/graph-api');
11
11
  const { ensureAuthenticated } = require('../auth');
12
+ const { buildMailboxPrefix } = require('../utils/mailbox');
13
+ const { writeClaimedFile } = require('../utils/safe-write');
12
14
 
13
15
  const MAX_FILENAME_LENGTH = 200;
14
16
 
@@ -37,38 +39,6 @@ function safeAttachmentFilename(name) {
37
39
  return base.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext;
38
40
  }
39
41
 
40
- /**
41
- * Write a buffer into outputDir without ever overwriting an existing entry
42
- * or following a symlink: `wx` fails on any existing path (including a
43
- * dangling symlink), so collisions get a numbered suffix instead.
44
- * @param {string} outputDir - Target directory
45
- * @param {string} filename - Safe basename from safeAttachmentFilename
46
- * @param {Buffer} buffer - File contents
47
- * @returns {string} - Absolute path actually written
48
- */
49
- function writeUniqueFile(outputDir, filename, buffer) {
50
- const root = path.resolve(outputDir);
51
- const ext = path.extname(filename);
52
- const stem = filename.slice(0, filename.length - ext.length);
53
-
54
- for (let i = 0; i < 1000; i++) {
55
- const candidate = path.join(
56
- root,
57
- i === 0 ? filename : `${stem}-${i}${ext}`
58
- );
59
- if (path.dirname(candidate) !== root) {
60
- throw new Error('Refusing to write attachment outside outputDir');
61
- }
62
- try {
63
- fs.writeFileSync(candidate, buffer, { flag: 'wx' });
64
- return candidate;
65
- } catch (error) {
66
- if (error.code !== 'EEXIST') throw error;
67
- }
68
- }
69
- throw new Error(`Too many files named ${filename} in ${root}`);
70
- }
71
-
72
42
  /**
73
43
  * List attachments for a specific email
74
44
  * @param {object} args - Tool arguments
@@ -77,6 +47,9 @@ function writeUniqueFile(outputDir, filename, buffer) {
77
47
  */
78
48
  async function handleListAttachments(args) {
79
49
  const messageId = args.messageId;
50
+ // Attachment IDs live under a mailbox-scoped message ID; route to the owning
51
+ // shared/delegated mailbox when supplied, else the signed-in account.
52
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
80
53
 
81
54
  if (!messageId) {
82
55
  return {
@@ -93,7 +66,7 @@ async function handleListAttachments(args) {
93
66
  const accessToken = await ensureAuthenticated();
94
67
 
95
68
  // Call Graph API to get attachments
96
- const endpoint = `/me/messages/${messageId}/attachments`;
69
+ const endpoint = `${prefix}/messages/${messageId}/attachments`;
97
70
  const params = {
98
71
  $select: 'id,name,contentType,size,isInline',
99
72
  };
@@ -176,6 +149,7 @@ async function handleDownloadAttachment(args) {
176
149
  // tree with downloaded files.
177
150
  const { messageId, attachmentId } = args;
178
151
  const savePath = args.outputDir || args.savePath;
152
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
179
153
 
180
154
  if (!messageId || !attachmentId) {
181
155
  return {
@@ -192,7 +166,7 @@ async function handleDownloadAttachment(args) {
192
166
  const accessToken = await ensureAuthenticated();
193
167
 
194
168
  // First, get attachment metadata to get the filename and content
195
- const metadataEndpoint = `/me/messages/${messageId}/attachments/${attachmentId}`;
169
+ const metadataEndpoint = `${prefix}/messages/${messageId}/attachments/${attachmentId}`;
196
170
  console.error(`Fetching attachment metadata: ${attachmentId}`);
197
171
 
198
172
  const metadata = await callGraphAPI(
@@ -243,9 +217,13 @@ async function handleDownloadAttachment(args) {
243
217
 
244
218
  // Decode base64 and save to file
245
219
  const buffer = Buffer.from(contentBytes, 'base64');
246
- const outputPath = writeUniqueFile(
220
+ const safeName = safeAttachmentFilename(filename);
221
+ const ext = path.extname(safeName);
222
+ const outputPath = writeClaimedFile(
247
223
  outputDir,
248
- safeAttachmentFilename(filename),
224
+ safeName.slice(0, safeName.length - ext.length),
225
+ ext.slice(1),
226
+ null,
249
227
  buffer
250
228
  );
251
229
 
@@ -326,6 +304,7 @@ async function handleDownloadAttachment(args) {
326
304
  */
327
305
  async function handleGetAttachmentContent(args) {
328
306
  const { messageId, attachmentId } = args;
307
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
329
308
 
330
309
  if (!messageId || !attachmentId) {
331
310
  return {
@@ -341,7 +320,7 @@ async function handleGetAttachmentContent(args) {
341
320
  try {
342
321
  const accessToken = await ensureAuthenticated();
343
322
 
344
- const endpoint = `/me/messages/${messageId}/attachments/${attachmentId}`;
323
+ const endpoint = `${prefix}/messages/${messageId}/attachments/${attachmentId}`;
345
324
  console.error(`Fetching attachment content: ${attachmentId}`);
346
325
 
347
326
  const response = await callGraphAPI(accessToken, 'GET', endpoint, null, {});
@@ -436,4 +415,7 @@ module.exports = {
436
415
  handleListAttachments,
437
416
  handleDownloadAttachment,
438
417
  handleGetAttachmentContent,
418
+ // Shared with email/export.js so exported attachments get the same
419
+ // GHSA-755c-c45g-69rv filename hardening.
420
+ safeAttachmentFilename,
439
421
  };