@littlebearapps/outlook-assistant 3.13.0 → 3.14.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 (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * dryRun previews for calendar actions that email other people (#274):
3
+ * create-event and manage-event cancel/decline/delete.
4
+ *
5
+ * A preview may read (the event, the signed-in address) but never writes,
6
+ * and says exactly who would be emailed. "External" means an address whose
7
+ * domain differs from the signed-in user's. That address comes from GET /me;
8
+ * if that read fails, the organiser's address stands in on an event you
9
+ * organised. Otherwise the external count is reported as unknown, never
10
+ * guessed.
11
+ */
12
+ const config = require('../config');
13
+ const { callGraphAPI } = require('../utils/graph-api');
14
+ const { ensureAuthenticated } = require('../auth');
15
+ const { dryRunResult } = require('../utils/safety');
16
+ const handleListEvents = require('./list');
17
+
18
+ const { toUtcIso, formatLocal } = handleListEvents;
19
+
20
+ /** Most attendees listed by address; the rest are summarised. */
21
+ const MAX_LISTED = 25;
22
+
23
+ const RESTORE_NOTE =
24
+ "Graph doesn't document a guaranteed way to restore a deleted event, so don't count on getting it back.";
25
+
26
+ /** Lower-cased domain of an email address, or null. */
27
+ function emailDomain(address) {
28
+ if (typeof address !== 'string') return null;
29
+ const at = address.lastIndexOf('@');
30
+ return at > 0
31
+ ? address
32
+ .slice(at + 1)
33
+ .trim()
34
+ .toLowerCase() || null
35
+ : null;
36
+ }
37
+
38
+ /** The signed-in user's address from GET /me, or null if it can't be read. */
39
+ async function getOwnAddress(accessToken) {
40
+ try {
41
+ const me = await callGraphAPI(accessToken, 'GET', 'me', null, {
42
+ $select: 'mail,userPrincipalName',
43
+ });
44
+ return me?.mail || me?.userPrincipalName || null;
45
+ } catch (_error) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Own address, falling back to the organiser on an event you organised. */
51
+ async function ownAddressFor(accessToken, event) {
52
+ const own = await getOwnAddress(accessToken);
53
+ if (own) return own;
54
+ return event.isOrganizer ? event.organizer?.emailAddress?.address : null;
55
+ }
56
+
57
+ /**
58
+ * Split Graph attendees into people and rooms/resources, leaving out the
59
+ * signed-in user, and mark each person external or not.
60
+ * @returns {{people: Array<{address: string, external: (boolean|null)}>, resources: string[], external: (number|null)}}
61
+ */
62
+ function summariseAttendees(attendees, ownAddress) {
63
+ const own = ownAddress ? ownAddress.toLowerCase() : null;
64
+ const ownDomain = emailDomain(own);
65
+ const people = [];
66
+ const resources = [];
67
+ for (const attendee of attendees || []) {
68
+ const address = attendee?.emailAddress?.address;
69
+ if (!address || address.toLowerCase() === own) continue;
70
+ if (attendee.type === 'resource') {
71
+ resources.push(address);
72
+ } else {
73
+ people.push({
74
+ address,
75
+ external: ownDomain ? emailDomain(address) !== ownDomain : null,
76
+ });
77
+ }
78
+ }
79
+ const external = ownDomain ? people.filter((p) => p.external).length : null;
80
+ return { people, resources, external };
81
+ }
82
+
83
+ function plural(count, word) {
84
+ return `${count} ${word}${count === 1 ? '' : 's'}`;
85
+ }
86
+
87
+ /** "6 attendees (2 external)" */
88
+ function countPhrase({ people, external }) {
89
+ const ext =
90
+ external === null ? 'external count unknown' : `${external} external`;
91
+ return `${plural(people.length, 'attendee')} (${ext})`;
92
+ }
93
+
94
+ /** Who would be emailed, by address (capped). */
95
+ function recipientLines({ people, resources }) {
96
+ const lines = [];
97
+ if (people.length > 0) {
98
+ lines.push('', 'Attendees:');
99
+ for (const person of people.slice(0, MAX_LISTED)) {
100
+ lines.push(`- ${person.address}${person.external ? ' (external)' : ''}`);
101
+ }
102
+ if (people.length > MAX_LISTED) {
103
+ lines.push(`- …and ${people.length - MAX_LISTED} more`);
104
+ }
105
+ }
106
+ if (resources.length > 0) {
107
+ lines.push(
108
+ '',
109
+ `Rooms and resources also notified: ${resources.join(', ')}`
110
+ );
111
+ }
112
+ return lines;
113
+ }
114
+
115
+ function messageLine(comment) {
116
+ return typeof comment === 'string' && comment.trim() !== ''
117
+ ? `Message: "${comment}"`
118
+ : 'No message (no `comment` given).';
119
+ }
120
+
121
+ /** "on 3 Apr 2026, 9:00 am GMT+11:00" for an event read in UTC. */
122
+ function eventWhen(event) {
123
+ try {
124
+ const local = formatLocal(toUtcIso(event.start), config.DEFAULT_TIMEZONE);
125
+ if (local) return `on ${local}`;
126
+ } catch (_error) {
127
+ // Fall through to the raw value.
128
+ }
129
+ const start = event.start || {};
130
+ return start.dateTime
131
+ ? `on ${start.dateTime} (${start.timeZone || 'UTC'})`
132
+ : 'at an unknown time';
133
+ }
134
+
135
+ /** The organiser's name and address, or null if Graph gave neither. */
136
+ function organiserLabel(event) {
137
+ const organiser = event.organizer?.emailAddress || {};
138
+ if (organiser.name && organiser.address) {
139
+ return `${organiser.name} <${organiser.address}>`;
140
+ }
141
+ return organiser.address || organiser.name || null;
142
+ }
143
+
144
+ /** "the organiser, Name <address>", or just "the organiser" if unknown. */
145
+ function theOrganiser(event) {
146
+ const label = organiserLabel(event);
147
+ return label ? `the organiser, ${label}` : 'the organiser';
148
+ }
149
+
150
+ /** "'Subject' on …", with a fallback for an event that has no subject. */
151
+ function eventTitle(event) {
152
+ const subject = event.subject ? `'${event.subject}'` : '(no subject)';
153
+ return `${subject} ${eventWhen(event)}`;
154
+ }
155
+
156
+ /** Read the fields a preview needs, with times in UTC. */
157
+ function fetchEvent(accessToken, eventId) {
158
+ return callGraphAPI(
159
+ accessToken,
160
+ 'GET',
161
+ `me/events/${eventId}`,
162
+ null,
163
+ {
164
+ $select: 'subject,start,end,isOrganizer,isCancelled,organizer,attendees',
165
+ },
166
+ { Prefer: 'outlook.timezone="UTC"' }
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Preview create-event: who would be invited.
172
+ * @param {object} event - The POST body create-event would send
173
+ */
174
+ async function previewCreateEvent(event) {
175
+ const { subject, start, end } = event;
176
+ const attendees = event.attendees || [];
177
+ const own =
178
+ attendees.length > 0
179
+ ? await getOwnAddress(await ensureAuthenticated())
180
+ : null;
181
+ const summary = summariseAttendees(attendees, own);
182
+
183
+ const when =
184
+ start.timeZone === end.timeZone
185
+ ? `on ${start.dateTime} to ${end.dateTime} (${start.timeZone})`
186
+ : `on ${start.dateTime} (${start.timeZone}) to ${end.dateTime} (${end.timeZone})`;
187
+ const head = `Creates '${subject}' ${when} in your calendar`;
188
+ let lines;
189
+ if (summary.people.length > 0) {
190
+ lines = [`${head} and emails invitations to ${countPhrase(summary)}.`];
191
+ } else if (summary.resources.length > 0) {
192
+ lines = [`${head}. No people are invited.`];
193
+ } else {
194
+ lines = [`${head}. No attendees, so no invitations are sent.`];
195
+ }
196
+
197
+ return dryRunResult([...lines, ...recipientLines(summary)], {
198
+ action: 'create',
199
+ subject,
200
+ notified: summary.people.length,
201
+ external: summary.external,
202
+ event,
203
+ });
204
+ }
205
+
206
+ /** Preview manage-event cancel: who gets the cancellation. */
207
+ async function previewCancelEvent(accessToken, { eventId, comment }) {
208
+ const event = await fetchEvent(accessToken, eventId);
209
+ const meta = { action: 'cancel', eventId };
210
+ const title = eventTitle(event);
211
+
212
+ if (!event.isOrganizer) {
213
+ return dryRunResult(
214
+ [
215
+ `You aren't the organiser of ${title}, so Graph will refuse to cancel it and nobody is emailed.`,
216
+ 'Use action=decline to tell the organiser, or action=delete to remove it from your calendar.',
217
+ ],
218
+ { ...meta, notified: 0 }
219
+ );
220
+ }
221
+
222
+ const summary = summariseAttendees(
223
+ event.attendees,
224
+ await ownAddressFor(accessToken, event)
225
+ );
226
+ const lines =
227
+ summary.people.length > 0
228
+ ? [
229
+ `Cancels ${title} and emails a cancellation to ${countPhrase(summary)}.`,
230
+ messageLine(comment),
231
+ ]
232
+ : [`Cancels ${title}. It has no attendees, so nobody is emailed.`];
233
+
234
+ return dryRunResult([...lines, ...recipientLines(summary)], {
235
+ ...meta,
236
+ notified: summary.people.length,
237
+ external: summary.external,
238
+ });
239
+ }
240
+
241
+ /** Preview manage-event decline: whether the organiser is emailed. */
242
+ async function previewDeclineEvent(
243
+ accessToken,
244
+ { eventId, comment, sendResponse }
245
+ ) {
246
+ const event = await fetchEvent(accessToken, eventId);
247
+ const meta = { action: 'decline', eventId };
248
+ const title = eventTitle(event);
249
+ const organiser = theOrganiser(event);
250
+
251
+ if (event.isOrganizer) {
252
+ return dryRunResult(
253
+ [
254
+ `You organised this event (${title}), so Graph will refuse to decline it and nobody is emailed.`,
255
+ 'Use action=cancel to cancel it for everyone, or action=delete to remove it.',
256
+ ],
257
+ { ...meta, notified: 0 }
258
+ );
259
+ }
260
+
261
+ if (sendResponse === false) {
262
+ return dryRunResult(
263
+ `Declines ${title} without notifying ${organiser} (sendResponse=false).`,
264
+ { ...meta, notified: 0 }
265
+ );
266
+ }
267
+
268
+ const ownDomain = emailDomain(await ownAddressFor(accessToken, event));
269
+ const organiserDomain = emailDomain(event.organizer?.emailAddress?.address);
270
+ let status = ' (external status unknown)';
271
+ if (ownDomain && organiserDomain) {
272
+ status = organiserDomain === ownDomain ? ' (internal)' : ' (external)';
273
+ }
274
+
275
+ return dryRunResult(
276
+ [
277
+ `Declines ${title} and emails your response to ${organiser}${status}.`,
278
+ messageLine(comment),
279
+ ],
280
+ { ...meta, notified: 1 }
281
+ );
282
+ }
283
+
284
+ /** Preview manage-event delete: who (if anyone) gets a cancellation. */
285
+ async function previewDeleteEvent(accessToken, { eventId }) {
286
+ const event = await fetchEvent(accessToken, eventId);
287
+ const meta = { action: 'delete', eventId };
288
+ const head = `Deletes ${eventTitle(event)} from your calendar`;
289
+
290
+ if (!event.isOrganizer) {
291
+ const organiser = organiserLabel(event)
292
+ ? `${theOrganiser(event)},`
293
+ : theOrganiser(event);
294
+ return dryRunResult(
295
+ [
296
+ `${head}. Nobody is emailed: ${organiser} isn't told you won't attend (use action=decline for that).`,
297
+ RESTORE_NOTE,
298
+ ],
299
+ { ...meta, notified: 0 }
300
+ );
301
+ }
302
+
303
+ const summary = summariseAttendees(
304
+ event.attendees,
305
+ await ownAddressFor(accessToken, event)
306
+ );
307
+ if (event.isCancelled || summary.people.length === 0) {
308
+ const why = event.isCancelled
309
+ ? "it's already cancelled"
310
+ : 'it has no attendees';
311
+ return dryRunResult([`${head}. Nobody is emailed: ${why}.`, RESTORE_NOTE], {
312
+ ...meta,
313
+ notified: 0,
314
+ });
315
+ }
316
+
317
+ return dryRunResult(
318
+ [
319
+ `${head} and emails a cancellation to ${countPhrase(summary)}.`,
320
+ 'To word that cancellation yourself, use action=cancel with a `comment` instead.',
321
+ RESTORE_NOTE,
322
+ ...recipientLines(summary),
323
+ ],
324
+ { ...meta, notified: summary.people.length, external: summary.external }
325
+ );
326
+ }
327
+
328
+ /**
329
+ * Fields that are the signed-in user's own view of the event. Changing
330
+ * only these doesn't send attendees a meeting update.
331
+ */
332
+ const PERSONAL_FIELDS = new Set([
333
+ 'categories',
334
+ 'reminderMinutesBeforeStart',
335
+ 'showAs',
336
+ ]);
337
+
338
+ /**
339
+ * Who a manage-event update would email (#303): the attendees of an event
340
+ * you organise, plus who is added or removed when `attendees` is replaced.
341
+ * @param {string} accessToken
342
+ * @param {{eventId: string, patch: object}} options - the PATCH body
343
+ * @returns {Promise<{lines: string[], notified: number, external: (number|null)}>}
344
+ */
345
+ async function describeUpdateRecipients(accessToken, { eventId, patch }) {
346
+ let event = null;
347
+ try {
348
+ event = await fetchEvent(accessToken, eventId);
349
+ } catch (_error) {
350
+ // Fall through to the cautious wording below.
351
+ }
352
+ if (!event || typeof event !== 'object') {
353
+ return {
354
+ lines: [
355
+ "Couldn't read the event to check who would be emailed. If you organise it and it has attendees, saving this emails them an update.",
356
+ ],
357
+ notified: null,
358
+ external: null,
359
+ };
360
+ }
361
+ const title = eventTitle(event);
362
+ const fields = Object.keys(patch);
363
+
364
+ if (!event.isOrganizer) {
365
+ return {
366
+ lines: [
367
+ `Updates ${title}. You aren't the organiser, so this changes only your copy and nobody is emailed.`,
368
+ ],
369
+ notified: 0,
370
+ external: 0,
371
+ };
372
+ }
373
+
374
+ const own = await ownAddressFor(accessToken, event);
375
+ const before = summariseAttendees(event.attendees, own);
376
+ const after = patch.attendees
377
+ ? summariseAttendees(patch.attendees, own)
378
+ : before;
379
+
380
+ const lines = [];
381
+ if (fields.every((field) => PERSONAL_FIELDS.has(field))) {
382
+ lines.push(
383
+ `Updates ${title}. Only your own settings change (${fields.join(', ')}), so attendees aren't sent an update.`
384
+ );
385
+ return { lines, notified: 0, external: 0 };
386
+ }
387
+
388
+ const empty = (summary) =>
389
+ summary.people.length === 0 && summary.resources.length === 0;
390
+ if (empty(before) && empty(after)) {
391
+ lines.push(`Updates ${title}. It has no attendees, so nobody is emailed.`);
392
+ return { lines, notified: 0, external: 0 };
393
+ }
394
+
395
+ const key = (address) => address.toLowerCase();
396
+ const beforeSet = new Set(before.people.map((p) => key(p.address)));
397
+ const afterSet = new Set(after.people.map((p) => key(p.address)));
398
+ const added = after.people.filter((p) => !beforeSet.has(key(p.address)));
399
+ const removed = before.people.filter((p) => !afterSet.has(key(p.address)));
400
+ const changeLines = [];
401
+ if (added.length > 0) {
402
+ changeLines.push(
403
+ `Added (sent an invitation): ${added.map((p) => p.address).join(', ')}`
404
+ );
405
+ }
406
+ if (removed.length > 0) {
407
+ changeLines.push(
408
+ `Removed (sent a cancellation): ${removed.map((p) => p.address).join(', ')}`
409
+ );
410
+ }
411
+
412
+ // Graph emails only the attendees whose status changed when the PATCH
413
+ // carries nothing but `attendees` (except removing a distribution-list
414
+ // member, which updates everyone): event-update docs.
415
+ if (fields.length === 1 && fields[0] === 'attendees') {
416
+ const changed = [...added, ...removed];
417
+ if (changed.length === 0) {
418
+ lines.push(
419
+ `Updates ${title}. No attendee is added or removed, so nobody is emailed.`
420
+ );
421
+ return { lines, notified: 0, external: 0 };
422
+ }
423
+ lines.push(
424
+ `Updates the attendees of ${title}. Only the people added or removed are emailed; the others aren't (unless a removed address is a distribution list, when Graph emails every attendee).`,
425
+ ...changeLines
426
+ );
427
+ const external =
428
+ after.external === null && before.external === null
429
+ ? null
430
+ : changed.filter((p) => p.external).length;
431
+ return { lines, notified: changed.length, external };
432
+ }
433
+
434
+ if (after.people.length > 0) {
435
+ lines.push(
436
+ `Updates ${title} and emails an update to ${countPhrase(after)}.`
437
+ );
438
+ } else if (after.resources.length > 0) {
439
+ lines.push(
440
+ `Updates ${title}. It has no people attendees; its rooms or resources are sent the update.`
441
+ );
442
+ } else {
443
+ lines.push(`Updates ${title} and removes every attendee.`);
444
+ }
445
+ lines.push(...changeLines, ...recipientLines(after));
446
+ return {
447
+ lines,
448
+ notified: after.people.length + after.resources.length,
449
+ external: after.external,
450
+ };
451
+ }
452
+
453
+ module.exports = {
454
+ emailDomain,
455
+ summariseAttendees,
456
+ describeUpdateRecipients,
457
+ previewCreateEvent,
458
+ previewCancelEvent,
459
+ previewDeclineEvent,
460
+ previewDeleteEvent,
461
+ };
@@ -30,7 +30,14 @@
30
30
  const { callGraphAPI } = require('../utils/graph-api');
31
31
  const { ensureAuthenticated } = require('../auth');
32
32
  const { DEFAULT_TIMEZONE } = require('../config');
33
- const { normaliseAttendees, buildAttendees } = require('./attendees');
33
+ const {
34
+ normaliseAttendees,
35
+ buildAttendees,
36
+ checkAttendeeAllowlist,
37
+ } = require('./attendees');
38
+ const { toolError, authRequiredError } = require('../utils/tool-error');
39
+ const { dryRunResult } = require('../utils/safety');
40
+ const { describeUpdateRecipients } = require('./preview');
34
41
 
35
42
  const SENSITIVITY_VALUES = new Set([
36
43
  'normal',
@@ -72,14 +79,7 @@ async function handleUpdateEvent(args) {
72
79
  } = args;
73
80
 
74
81
  if (!eventId) {
75
- return {
76
- content: [
77
- {
78
- type: 'text',
79
- text: 'Event ID is required to update an event.',
80
- },
81
- ],
82
- };
82
+ return toolError('Event ID is required to update an event.');
83
83
  }
84
84
 
85
85
  // Build the patch body from only the fields the caller actually provided.
@@ -119,6 +119,12 @@ async function handleUpdateEvent(args) {
119
119
  };
120
120
  }
121
121
  patch.attendees = buildAttendees(entries);
122
+ // Graph emails every attendee on the new list, so check them all.
123
+ const allowlistError = checkAttendeeAllowlist(patch.attendees, {
124
+ operation: 'update',
125
+ dryRun,
126
+ });
127
+ if (allowlistError) return allowlistError;
122
128
  if (entries.some((entry) => !entry.type)) untypedAttendees = entries;
123
129
  }
124
130
 
@@ -136,42 +142,27 @@ async function handleUpdateEvent(args) {
136
142
 
137
143
  if (sensitivity !== undefined) {
138
144
  if (!SENSITIVITY_VALUES.has(sensitivity)) {
139
- return {
140
- content: [
141
- {
142
- type: 'text',
143
- text: `Invalid sensitivity: '${sensitivity}'. Must be one of: ${[...SENSITIVITY_VALUES].join(', ')}.`,
144
- },
145
- ],
146
- };
145
+ return toolError(
146
+ `Invalid sensitivity: '${sensitivity}'. Must be one of: ${[...SENSITIVITY_VALUES].join(', ')}.`
147
+ );
147
148
  }
148
149
  patch.sensitivity = sensitivity;
149
150
  }
150
151
 
151
152
  if (showAs !== undefined) {
152
153
  if (!SHOW_AS_VALUES.has(showAs)) {
153
- return {
154
- content: [
155
- {
156
- type: 'text',
157
- text: `Invalid showAs: '${showAs}'. Must be one of: ${[...SHOW_AS_VALUES].join(', ')}.`,
158
- },
159
- ],
160
- };
154
+ return toolError(
155
+ `Invalid showAs: '${showAs}'. Must be one of: ${[...SHOW_AS_VALUES].join(', ')}.`
156
+ );
161
157
  }
162
158
  patch.showAs = showAs;
163
159
  }
164
160
 
165
161
  if (importance !== undefined) {
166
162
  if (!IMPORTANCE_VALUES.has(importance)) {
167
- return {
168
- content: [
169
- {
170
- type: 'text',
171
- text: `Invalid importance: '${importance}'. Must be one of: ${[...IMPORTANCE_VALUES].join(', ')}.`,
172
- },
173
- ],
174
- };
163
+ return toolError(
164
+ `Invalid importance: '${importance}'. Must be one of: ${[...IMPORTANCE_VALUES].join(', ')}.`
165
+ );
175
166
  }
176
167
  patch.importance = importance;
177
168
  }
@@ -184,27 +175,17 @@ async function handleUpdateEvent(args) {
184
175
  if (reminderMinutesBeforeStart !== undefined) {
185
176
  const reminder = Number(reminderMinutesBeforeStart);
186
177
  if (!Number.isFinite(reminder) || reminder < 0) {
187
- return {
188
- content: [
189
- {
190
- type: 'text',
191
- text: `Invalid reminderMinutesBeforeStart: '${reminderMinutesBeforeStart}'. Must be a non-negative number.`,
192
- },
193
- ],
194
- };
178
+ return toolError(
179
+ `Invalid reminderMinutesBeforeStart: '${reminderMinutesBeforeStart}'. Must be a non-negative number.`
180
+ );
195
181
  }
196
182
  patch.reminderMinutesBeforeStart = reminder;
197
183
  }
198
184
 
199
185
  if (Object.keys(patch).length === 0) {
200
- return {
201
- content: [
202
- {
203
- type: 'text',
204
- text: 'No fields to update — provide at least one updatable field (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart).',
205
- },
206
- ],
207
- };
186
+ return toolError(
187
+ 'No fields to update — provide at least one updatable field (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart).'
188
+ );
208
189
  }
209
190
 
210
191
  try {
@@ -228,28 +209,33 @@ async function handleUpdateEvent(args) {
228
209
 
229
210
  // dryRun: show the caller what would be sent without changing anything.
230
211
  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
- },
212
+ // Say who would be emailed, as the cancel/decline/delete previews do
213
+ // (#303); the PATCH body follows.
214
+ accessToken = accessToken || (await ensureAuthenticated());
215
+ const recipients = await describeUpdateRecipients(accessToken, {
216
+ eventId,
217
+ patch,
218
+ });
219
+ return dryRunResult(
220
+ [
221
+ ...recipients.lines,
222
+ '',
223
+ `Would PATCH \`me/events/${eventId}\` with:`,
224
+ '',
225
+ '```json',
226
+ JSON.stringify(patch, null, 2),
227
+ '```',
228
+ '',
229
+ `Fields that would change: ${Object.keys(patch).join(', ')}`,
245
230
  ],
246
- _meta: {
231
+ {
247
232
  eventId,
248
- dryRun: true,
249
233
  patch,
250
234
  fieldsChanged: Object.keys(patch),
251
- },
252
- };
235
+ notified: recipients.notified,
236
+ external: recipients.external,
237
+ }
238
+ );
253
239
  }
254
240
 
255
241
  accessToken = accessToken || (await ensureAuthenticated());
@@ -295,24 +281,10 @@ async function handleUpdateEvent(args) {
295
281
  };
296
282
  } catch (error) {
297
283
  if (error.message === 'Authentication required') {
298
- return {
299
- content: [
300
- {
301
- type: 'text',
302
- text: "Authentication required. Please use the 'authenticate' tool first.",
303
- },
304
- ],
305
- };
284
+ return authRequiredError();
306
285
  }
307
286
 
308
- return {
309
- content: [
310
- {
311
- type: 'text',
312
- text: `Error updating event: ${error.message}`,
313
- },
314
- ],
315
- };
287
+ return toolError(`Error updating event: ${error.message}`);
316
288
  }
317
289
  }
318
290