@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.
- package/.env.example +30 -3
- package/README.md +67 -27
- package/advanced/index.js +44 -174
- package/auth/auth-errors.js +23 -1
- package/auth/oauth-server.js +7 -1
- package/auth/token-manager.js +7 -3
- package/auth/token-storage.js +28 -30
- package/auth/tools.js +61 -82
- package/calendar/attendees.js +36 -0
- package/calendar/cancel.js +9 -25
- package/calendar/create.js +42 -48
- package/calendar/decline.js +10 -25
- package/calendar/delete.js +10 -25
- package/calendar/index.js +20 -37
- package/calendar/list.js +4 -16
- package/calendar/preview.js +461 -0
- package/calendar/update.js +55 -83
- package/categories/index.js +68 -265
- package/config.js +29 -1
- package/contacts/index.js +72 -128
- package/email/attachments.js +43 -125
- package/email/conversations.js +44 -78
- package/email/delta.js +69 -46
- package/email/draft.js +170 -103
- package/email/export.js +145 -110
- package/email/folder-utils.js +3 -2
- package/email/headers.js +11 -49
- package/email/index.js +86 -110
- package/email/list.js +4 -17
- package/email/mail-tips.js +86 -57
- package/email/mark-as-read.js +13 -49
- package/email/mime.js +39 -51
- package/email/read.js +16 -50
- package/email/search.js +47 -87
- package/email/send.js +82 -48
- package/folder/create.js +6 -25
- package/folder/delete.js +117 -38
- package/folder/index.js +19 -17
- package/folder/list.js +5 -17
- package/folder/move.js +13 -42
- package/folder/resolve.js +11 -6
- package/folder/stats.js +18 -27
- package/index.js +39 -45
- package/llms-install.md +22 -4
- package/llms.txt +20 -11
- package/outlook-auth-server.js +10 -3
- package/package.json +4 -1
- package/request-handler.js +217 -116
- package/rules/create.js +28 -71
- package/rules/index.js +52 -93
- package/rules/list.js +7 -19
- package/rules/rule-builder.js +59 -22
- package/rules/update.js +27 -61
- package/server.js +41 -0
- package/settings/index.js +162 -145
- package/tools.js +30 -0
- package/utils/field-presets.js +4 -2
- package/utils/graph-api.js +65 -22
- package/utils/logger.js +251 -0
- package/utils/mock-data.js +91 -2
- package/utils/read-only.js +59 -0
- package/utils/response-formatter.js +54 -15
- package/utils/risk-classes.js +324 -0
- package/utils/safe-write.js +372 -6
- package/utils/safety.js +247 -42
- package/utils/server-instructions.js +73 -0
- 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
|
+
};
|
package/calendar/update.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|