@littlebearapps/outlook-assistant 3.12.1 → 3.14.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.
Files changed (69) hide show
  1. package/.env.example +27 -3
  2. package/README.md +108 -33
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/client-config.js +142 -0
  6. package/auth/index.js +4 -2
  7. package/auth/oauth-server.js +12 -2
  8. package/auth/token-manager.js +7 -3
  9. package/auth/token-storage.js +46 -33
  10. package/auth/tools.js +223 -93
  11. package/calendar/attendees.js +36 -0
  12. package/calendar/cancel.js +9 -25
  13. package/calendar/create.js +42 -48
  14. package/calendar/decline.js +10 -25
  15. package/calendar/delete.js +10 -25
  16. package/calendar/index.js +20 -37
  17. package/calendar/list.js +4 -16
  18. package/calendar/preview.js +335 -0
  19. package/calendar/update.js +42 -86
  20. package/categories/index.js +59 -264
  21. package/config.js +36 -2
  22. package/contacts/index.js +72 -128
  23. package/email/attachments.js +42 -124
  24. package/email/conversations.js +44 -78
  25. package/email/delta.js +10 -34
  26. package/email/draft.js +140 -96
  27. package/email/export.js +141 -110
  28. package/email/folder-utils.js +3 -2
  29. package/email/headers.js +11 -49
  30. package/email/index.js +85 -109
  31. package/email/list.js +4 -17
  32. package/email/mail-tips.js +86 -57
  33. package/email/mark-as-read.js +13 -49
  34. package/email/mime.js +14 -49
  35. package/email/read.js +16 -50
  36. package/email/search.js +46 -86
  37. package/email/send.js +82 -48
  38. package/folder/create.js +6 -25
  39. package/folder/delete.js +117 -38
  40. package/folder/index.js +17 -16
  41. package/folder/list.js +5 -17
  42. package/folder/move.js +13 -42
  43. package/folder/resolve.js +11 -6
  44. package/folder/stats.js +6 -20
  45. package/index.js +23 -45
  46. package/llms-install.md +31 -7
  47. package/llms.txt +19 -10
  48. package/outlook-auth-server.js +10 -3
  49. package/package.json +6 -2
  50. package/request-handler.js +217 -116
  51. package/rules/create.js +27 -70
  52. package/rules/index.js +30 -92
  53. package/rules/list.js +5 -17
  54. package/rules/rule-builder.js +57 -20
  55. package/rules/update.js +26 -60
  56. package/server.js +37 -0
  57. package/settings/index.js +142 -143
  58. package/tools.js +30 -0
  59. package/utils/field-presets.js +4 -2
  60. package/utils/graph-api.js +65 -22
  61. package/utils/logger.js +251 -0
  62. package/utils/mock-data.js +91 -2
  63. package/utils/read-only.js +59 -0
  64. package/utils/response-formatter.js +54 -15
  65. package/utils/risk-classes.js +324 -0
  66. package/utils/safe-write.js +372 -6
  67. package/utils/safety.js +109 -25
  68. package/utils/server-instructions.js +62 -0
  69. package/utils/tool-error.js +33 -0
@@ -0,0 +1,335 @@
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
+ module.exports = {
329
+ emailDomain,
330
+ summariseAttendees,
331
+ previewCreateEvent,
332
+ previewCancelEvent,
333
+ previewDeclineEvent,
334
+ previewDeleteEvent,
335
+ };
@@ -30,7 +30,13 @@
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');
34
40
 
35
41
  const SENSITIVITY_VALUES = new Set([
36
42
  'normal',
@@ -72,14 +78,7 @@ async function handleUpdateEvent(args) {
72
78
  } = args;
73
79
 
74
80
  if (!eventId) {
75
- return {
76
- content: [
77
- {
78
- type: 'text',
79
- text: 'Event ID is required to update an event.',
80
- },
81
- ],
82
- };
81
+ return toolError('Event ID is required to update an event.');
83
82
  }
84
83
 
85
84
  // Build the patch body from only the fields the caller actually provided.
@@ -119,6 +118,12 @@ async function handleUpdateEvent(args) {
119
118
  };
120
119
  }
121
120
  patch.attendees = buildAttendees(entries);
121
+ // Graph emails every attendee on the new list, so check them all.
122
+ const allowlistError = checkAttendeeAllowlist(patch.attendees, {
123
+ operation: 'update',
124
+ dryRun,
125
+ });
126
+ if (allowlistError) return allowlistError;
122
127
  if (entries.some((entry) => !entry.type)) untypedAttendees = entries;
123
128
  }
124
129
 
@@ -136,42 +141,27 @@ async function handleUpdateEvent(args) {
136
141
 
137
142
  if (sensitivity !== undefined) {
138
143
  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
- };
144
+ return toolError(
145
+ `Invalid sensitivity: '${sensitivity}'. Must be one of: ${[...SENSITIVITY_VALUES].join(', ')}.`
146
+ );
147
147
  }
148
148
  patch.sensitivity = sensitivity;
149
149
  }
150
150
 
151
151
  if (showAs !== undefined) {
152
152
  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
- };
153
+ return toolError(
154
+ `Invalid showAs: '${showAs}'. Must be one of: ${[...SHOW_AS_VALUES].join(', ')}.`
155
+ );
161
156
  }
162
157
  patch.showAs = showAs;
163
158
  }
164
159
 
165
160
  if (importance !== undefined) {
166
161
  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
- };
162
+ return toolError(
163
+ `Invalid importance: '${importance}'. Must be one of: ${[...IMPORTANCE_VALUES].join(', ')}.`
164
+ );
175
165
  }
176
166
  patch.importance = importance;
177
167
  }
@@ -184,27 +174,17 @@ async function handleUpdateEvent(args) {
184
174
  if (reminderMinutesBeforeStart !== undefined) {
185
175
  const reminder = Number(reminderMinutesBeforeStart);
186
176
  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
- };
177
+ return toolError(
178
+ `Invalid reminderMinutesBeforeStart: '${reminderMinutesBeforeStart}'. Must be a non-negative number.`
179
+ );
195
180
  }
196
181
  patch.reminderMinutesBeforeStart = reminder;
197
182
  }
198
183
 
199
184
  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
- };
185
+ return toolError(
186
+ 'No fields to update — provide at least one updatable field (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart).'
187
+ );
208
188
  }
209
189
 
210
190
  try {
@@ -228,28 +208,18 @@ async function handleUpdateEvent(args) {
228
208
 
229
209
  // dryRun: show the caller what would be sent without changing anything.
230
210
  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
- },
211
+ return dryRunResult(
212
+ [
213
+ `Would PATCH \`me/events/${eventId}\` with:`,
214
+ '',
215
+ '```json',
216
+ JSON.stringify(patch, null, 2),
217
+ '```',
218
+ '',
219
+ `Fields that would change: ${Object.keys(patch).join(', ')}`,
245
220
  ],
246
- _meta: {
247
- eventId,
248
- dryRun: true,
249
- patch,
250
- fieldsChanged: Object.keys(patch),
251
- },
252
- };
221
+ { eventId, patch, fieldsChanged: Object.keys(patch) }
222
+ );
253
223
  }
254
224
 
255
225
  accessToken = accessToken || (await ensureAuthenticated());
@@ -295,24 +265,10 @@ async function handleUpdateEvent(args) {
295
265
  };
296
266
  } catch (error) {
297
267
  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
- };
268
+ return authRequiredError();
306
269
  }
307
270
 
308
- return {
309
- content: [
310
- {
311
- type: 'text',
312
- text: `Error updating event: ${error.message}`,
313
- },
314
- ],
315
- };
271
+ return toolError(`Error updating event: ${error.message}`);
316
272
  }
317
273
  }
318
274