@littlebearapps/outlook-assistant 3.7.2 → 3.8.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.
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Update event functionality.
3
+ *
4
+ * Wraps the Microsoft Graph PATCH /me/events/{id} endpoint. Sends only
5
+ * the fields the caller provides — anything left out is preserved
6
+ * server-side. Useful for re-scheduling, adding/removing attendees,
7
+ * editing the body, or tweaking metadata without rebuilding the event
8
+ * from scratch (which loses RSVP state).
9
+ *
10
+ * Supports the following Graph event properties:
11
+ * - subject
12
+ * - start (ISO string or {dateTime, timeZone} object)
13
+ * - end (same shape as start)
14
+ * - attendees (full replacement list of emails)
15
+ * - body (sent as HTML)
16
+ * - location (displayName)
17
+ * - isOnlineMeeting
18
+ * - sensitivity (normal | personal | private | confidential)
19
+ * - showAs (free | tentative | busy | oof | workingElsewhere | unknown)
20
+ * - importance (low | normal | high)
21
+ * - categories (full replacement array of category names)
22
+ * - reminderMinutesBeforeStart
23
+ *
24
+ * `dryRun: true` returns a preview of the PATCH payload without calling
25
+ * Graph — useful for confirming behaviour before mutating real data.
26
+ */
27
+ const { callGraphAPI } = require('../utils/graph-api');
28
+ const { ensureAuthenticated } = require('../auth');
29
+ const { DEFAULT_TIMEZONE } = require('../config');
30
+
31
+ const SENSITIVITY_VALUES = new Set([
32
+ 'normal',
33
+ 'personal',
34
+ 'private',
35
+ 'confidential',
36
+ ]);
37
+ const SHOW_AS_VALUES = new Set([
38
+ 'free',
39
+ 'tentative',
40
+ 'busy',
41
+ 'oof',
42
+ 'workingElsewhere',
43
+ 'unknown',
44
+ ]);
45
+ const IMPORTANCE_VALUES = new Set(['low', 'normal', 'high']);
46
+
47
+ /**
48
+ * Update event handler
49
+ * @param {object} args - Tool arguments
50
+ * @returns {object} - MCP response
51
+ */
52
+ async function handleUpdateEvent(args) {
53
+ const {
54
+ eventId,
55
+ subject,
56
+ start,
57
+ end,
58
+ attendees,
59
+ body,
60
+ location,
61
+ isOnlineMeeting,
62
+ sensitivity,
63
+ showAs,
64
+ importance,
65
+ categories,
66
+ reminderMinutesBeforeStart,
67
+ dryRun = false,
68
+ } = args;
69
+
70
+ if (!eventId) {
71
+ return {
72
+ content: [
73
+ {
74
+ type: 'text',
75
+ text: 'Event ID is required to update an event.',
76
+ },
77
+ ],
78
+ };
79
+ }
80
+
81
+ // Build the patch body from only the fields the caller actually provided.
82
+ // Graph treats absent properties as "no change", so we never overwrite
83
+ // something the user didn't intend to touch.
84
+ const patch = {};
85
+
86
+ if (subject !== undefined) patch.subject = subject;
87
+
88
+ if (start !== undefined) {
89
+ patch.start = {
90
+ dateTime: start.dateTime || start,
91
+ timeZone: start.timeZone || DEFAULT_TIMEZONE,
92
+ };
93
+ }
94
+
95
+ if (end !== undefined) {
96
+ patch.end = {
97
+ dateTime: end.dateTime || end,
98
+ timeZone: end.timeZone || DEFAULT_TIMEZONE,
99
+ };
100
+ }
101
+
102
+ if (attendees !== undefined) {
103
+ // Replaces the full attendee list — Graph PATCH on this property is
104
+ // not additive. Caller must pass the desired complete list.
105
+ patch.attendees = (attendees || []).map((email) => ({
106
+ emailAddress: { address: email },
107
+ type: 'required',
108
+ }));
109
+ }
110
+
111
+ if (body !== undefined) {
112
+ patch.body = { contentType: 'HTML', content: body };
113
+ }
114
+
115
+ if (location !== undefined) {
116
+ patch.location = { displayName: location };
117
+ }
118
+
119
+ if (isOnlineMeeting !== undefined) {
120
+ patch.isOnlineMeeting = Boolean(isOnlineMeeting);
121
+ }
122
+
123
+ if (sensitivity !== undefined) {
124
+ if (!SENSITIVITY_VALUES.has(sensitivity)) {
125
+ return {
126
+ content: [
127
+ {
128
+ type: 'text',
129
+ text: `Invalid sensitivity: '${sensitivity}'. Must be one of: ${[...SENSITIVITY_VALUES].join(', ')}.`,
130
+ },
131
+ ],
132
+ };
133
+ }
134
+ patch.sensitivity = sensitivity;
135
+ }
136
+
137
+ if (showAs !== undefined) {
138
+ if (!SHOW_AS_VALUES.has(showAs)) {
139
+ return {
140
+ content: [
141
+ {
142
+ type: 'text',
143
+ text: `Invalid showAs: '${showAs}'. Must be one of: ${[...SHOW_AS_VALUES].join(', ')}.`,
144
+ },
145
+ ],
146
+ };
147
+ }
148
+ patch.showAs = showAs;
149
+ }
150
+
151
+ if (importance !== undefined) {
152
+ if (!IMPORTANCE_VALUES.has(importance)) {
153
+ return {
154
+ content: [
155
+ {
156
+ type: 'text',
157
+ text: `Invalid importance: '${importance}'. Must be one of: ${[...IMPORTANCE_VALUES].join(', ')}.`,
158
+ },
159
+ ],
160
+ };
161
+ }
162
+ patch.importance = importance;
163
+ }
164
+
165
+ if (categories !== undefined) {
166
+ // Full replacement, like attendees. Pass [] to clear all categories.
167
+ patch.categories = Array.isArray(categories) ? categories : [];
168
+ }
169
+
170
+ if (reminderMinutesBeforeStart !== undefined) {
171
+ const reminder = Number(reminderMinutesBeforeStart);
172
+ if (!Number.isFinite(reminder) || reminder < 0) {
173
+ return {
174
+ content: [
175
+ {
176
+ type: 'text',
177
+ text: `Invalid reminderMinutesBeforeStart: '${reminderMinutesBeforeStart}'. Must be a non-negative number.`,
178
+ },
179
+ ],
180
+ };
181
+ }
182
+ patch.reminderMinutesBeforeStart = reminder;
183
+ }
184
+
185
+ if (Object.keys(patch).length === 0) {
186
+ return {
187
+ content: [
188
+ {
189
+ type: 'text',
190
+ text: 'No fields to update — provide at least one updatable field (subject, start, end, attendees, body, location, isOnlineMeeting, sensitivity, showAs, importance, categories, reminderMinutesBeforeStart).',
191
+ },
192
+ ],
193
+ };
194
+ }
195
+
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'),
211
+ },
212
+ ],
213
+ _meta: {
214
+ eventId,
215
+ dryRun: true,
216
+ patch,
217
+ fieldsChanged: Object.keys(patch),
218
+ },
219
+ };
220
+ }
221
+
222
+ try {
223
+ const accessToken = await ensureAuthenticated();
224
+ const endpoint = `me/events/${eventId}`;
225
+
226
+ const response = await callGraphAPI(accessToken, 'PATCH', endpoint, patch);
227
+
228
+ const output = [
229
+ `Event '${response.subject || eventId}' updated successfully.`,
230
+ ];
231
+ if (response.id) {
232
+ output.push(`**ID**: \`${response.id}\``);
233
+ }
234
+ if (response.start) {
235
+ output.push(
236
+ `**Start**: ${response.start.dateTime} (${response.start.timeZone})`
237
+ );
238
+ }
239
+ if (response.end) {
240
+ output.push(
241
+ `**End**: ${response.end.dateTime} (${response.end.timeZone})`
242
+ );
243
+ }
244
+ if (response.webLink) {
245
+ output.push(`**Link**: ${response.webLink}`);
246
+ }
247
+ output.push(`\nFields changed: ${Object.keys(patch).join(', ')}`);
248
+
249
+ return {
250
+ content: [
251
+ {
252
+ type: 'text',
253
+ text: output.join('\n'),
254
+ },
255
+ ],
256
+ _meta: {
257
+ eventId: response.id,
258
+ subject: response.subject,
259
+ start: response.start,
260
+ end: response.end,
261
+ fieldsChanged: Object.keys(patch),
262
+ },
263
+ };
264
+ } catch (error) {
265
+ if (error.message === 'Authentication required') {
266
+ return {
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ text: "Authentication required. Please use the 'authenticate' tool first.",
271
+ },
272
+ ],
273
+ };
274
+ }
275
+
276
+ return {
277
+ content: [
278
+ {
279
+ type: 'text',
280
+ text: `Error updating event: ${error.message}`,
281
+ },
282
+ ],
283
+ };
284
+ }
285
+ }
286
+
287
+ module.exports = handleUpdateEvent;
@@ -116,9 +116,9 @@ async function handleListCategories(args) {
116
116
  output.push('|----------|-------|-----|');
117
117
  categories.forEach((cat) => {
118
118
  const colorName = COLOR_NAMES[cat.color] || cat.color;
119
- const idDisplay =
120
- outputVerbosity === 'full' ? cat.id : `${cat.id.substring(0, 8)}...`;
121
- output.push(`| ${cat.displayName} | ${colorName} | ${idDisplay} |`);
119
+ // F-9: emit full IDs at standard verbosity. Truncated IDs were
120
+ // unusable downstream (the ellipsis became part of the copy).
121
+ output.push(`| ${cat.displayName} | ${colorName} | ${cat.id} |`);
122
122
  });
123
123
  }
124
124
 
@@ -255,7 +255,9 @@ async function handleCreateCategory(args) {
255
255
  * Update category handler
256
256
  */
257
257
  async function handleUpdateCategory(args) {
258
- const { id, displayName, color } = args;
258
+ // F-34: accept `categoryId` as a deprecated alias for `id`.
259
+ const id = args.id || args.categoryId;
260
+ const { displayName, color } = args;
259
261
 
260
262
  if (!id) {
261
263
  return {
@@ -298,34 +300,45 @@ async function handleUpdateCategory(args) {
298
300
  if (displayName) updateData.displayName = displayName;
299
301
  if (color) updateData.color = color;
300
302
 
301
- const response = await callGraphAPI(
303
+ await callGraphAPI(
302
304
  accessToken,
303
305
  'PATCH',
304
306
  `me/outlook/masterCategories/${id}`,
305
307
  updateData
306
308
  );
307
309
 
308
- // Prefer input values over response (PATCH may return partial data)
309
- const updatedName = displayName || response.displayName;
310
- const updatedColor = color || response.color;
311
- const colorName = COLOR_NAMES[updatedColor] || updatedColor;
310
+ // F-35: Graph silently drops master-category color updates on
311
+ // some account types. Re-fetch the category and diff requested
312
+ // values against what Graph actually stored, so the caller
313
+ // doesn't get a misleading "Category updated!" when nothing
314
+ // changed.
315
+ const fresh = await callGraphAPI(
316
+ accessToken,
317
+ 'GET',
318
+ `me/outlook/masterCategories/${id}`
319
+ );
312
320
 
313
- const updatedCategory = {
314
- ...response,
315
- displayName: updatedName,
316
- color: updatedColor,
317
- };
321
+ const warnings = [];
322
+ if (color && fresh.color !== color) {
323
+ warnings.push(
324
+ `Requested color \`${color}\` but Graph stored \`${fresh.color}\` (master-category colors may be immutable on this account type).`
325
+ );
326
+ }
327
+ if (displayName && fresh.displayName !== displayName) {
328
+ warnings.push(
329
+ `Requested name \`${displayName}\` but Graph stored \`${fresh.displayName}\`.`
330
+ );
331
+ }
332
+
333
+ const colorName = COLOR_NAMES[fresh.color] || fresh.color;
334
+ let text = `Category updated!\n\n**Name**: ${fresh.displayName}\n**Color**: ${colorName} (${fresh.color})\n**ID**: ${fresh.id || id}`;
335
+ if (warnings.length > 0) {
336
+ text += `\n\n**⚠ Warning**:\n${warnings.map((w) => `- ${w}`).join('\n')}`;
337
+ }
318
338
 
319
339
  return {
320
- content: [
321
- {
322
- type: 'text',
323
- text: `Category updated!\n\n**Name**: ${updatedName}\n**Color**: ${colorName} (${updatedColor})\n**ID**: ${response.id || id}`,
324
- },
325
- ],
326
- _meta: {
327
- category: formatCategory(updatedCategory),
328
- },
340
+ content: [{ type: 'text', text }],
341
+ _meta: { category: formatCategory(fresh) },
329
342
  };
330
343
  } catch (error) {
331
344
  if (error.message === 'Authentication required') {
@@ -353,7 +366,8 @@ async function handleUpdateCategory(args) {
353
366
  * Delete category handler
354
367
  */
355
368
  async function handleDeleteCategory(args) {
356
- const { id } = args;
369
+ // F-34: accept `categoryId` as a deprecated alias for `id`.
370
+ const id = args.id || args.categoryId;
357
371
 
358
372
  if (!id) {
359
373
  return {
@@ -824,8 +838,9 @@ const categoriesTools = [
824
838
  properties: {
825
839
  action: {
826
840
  type: 'string',
827
- enum: ['list', 'create', 'update', 'delete'],
828
- description: 'Action to perform (default: list)',
841
+ enum: ['list', 'create', 'update', 'set', 'delete'],
842
+ description:
843
+ "Action to perform (default: list). 'set' is a deprecated alias for 'update'.",
829
844
  },
830
845
  // list params
831
846
  outputVerbosity: {
@@ -850,7 +865,12 @@ const categoriesTools = [
850
865
  type: 'string',
851
866
  description: 'Category ID (action=update/delete, required)',
852
867
  },
868
+ categoryId: {
869
+ type: 'string',
870
+ description: 'DEPRECATED: alias for `id`. Will be removed in v3.8.0.',
871
+ },
853
872
  },
873
+ additionalProperties: false,
854
874
  required: [],
855
875
  },
856
876
  handler: async (args) => {
@@ -858,13 +878,22 @@ const categoriesTools = [
858
878
  switch (action) {
859
879
  case 'create':
860
880
  return handleCreateCategory(args);
881
+ case 'set': // deprecated alias
861
882
  case 'update':
862
883
  return handleUpdateCategory(args);
863
884
  case 'delete':
864
885
  return handleDeleteCategory(args);
865
886
  case 'list':
866
- default:
867
887
  return handleListCategories(args);
888
+ default:
889
+ return {
890
+ content: [
891
+ {
892
+ type: 'text',
893
+ text: `Unknown action '${action}'. Valid actions: list, create, update, delete.`,
894
+ },
895
+ ],
896
+ };
868
897
  }
869
898
  },
870
899
  },
@@ -901,6 +930,7 @@ const categoriesTools = [
901
930
  'set (replace all), add (append), remove (remove specific). Default: set',
902
931
  },
903
932
  },
933
+ additionalProperties: false,
904
934
  required: ['categories'],
905
935
  },
906
936
  handler: handleApplyCategory,
@@ -945,6 +975,7 @@ const categoriesTools = [
945
975
  'Where to put emails from this sender (action=set, default: focused)',
946
976
  },
947
977
  },
978
+ additionalProperties: false,
948
979
  required: [],
949
980
  },
950
981
  handler: async (args) => {
@@ -955,8 +986,16 @@ const categoriesTools = [
955
986
  case 'delete':
956
987
  return handleSetFocusedInboxOverride(args);
957
988
  case 'list':
958
- default:
959
989
  return handleGetFocusedInboxOverrides(args);
990
+ default:
991
+ return {
992
+ content: [
993
+ {
994
+ type: 'text',
995
+ text: `Unknown action '${action}'. Valid actions: list, set, delete.`,
996
+ },
997
+ ],
998
+ };
960
999
  }
961
1000
  },
962
1001
  },
package/config.js CHANGED
@@ -23,6 +23,48 @@ if (!homeDir) {
23
23
  );
24
24
  }
25
25
 
26
+ /**
27
+ * Resolve the OAuth audience segment used in Microsoft Graph endpoints.
28
+ *
29
+ * Microsoft's identity platform v2.0 routes by audience:
30
+ * - `common` — personal AND work/school accounts (multi-tenant + personal)
31
+ * - `consumers` — personal Microsoft accounts only
32
+ * - `organizations` — work/school accounts only
33
+ * - `<tenant-guid>` — single-tenant
34
+ *
35
+ * The right value depends on the Azure app registration's "Supported account
36
+ * types" setting. An app registered as "Personal Microsoft accounts only" is
37
+ * rejected by `/common/` with `AADSTS9002331` and must use `/consumers/`;
38
+ * a single-tenant app must use its tenant GUID; etc.
39
+ *
40
+ * Defaulting to `common` preserves existing behaviour. Set
41
+ * `OUTLOOK_AUTH_AUDIENCE` to override.
42
+ */
43
+ const AUTH_AUDIENCE = process.env.OUTLOOK_AUTH_AUDIENCE || 'common';
44
+
45
+ // Surface obvious misconfigurations at startup rather than failing later with a
46
+ // cryptic AADSTS error from Microsoft. Warn rather than throw so we never break
47
+ // an existing deployment on upgrade — Graph itself remains the source of truth
48
+ // for what audiences it accepts.
49
+ const VALID_AUDIENCE_LITERALS = new Set([
50
+ 'common',
51
+ 'consumers',
52
+ 'organizations',
53
+ ]);
54
+ const TENANT_GUID_RE =
55
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
56
+ if (
57
+ !VALID_AUDIENCE_LITERALS.has(AUTH_AUDIENCE) &&
58
+ !TENANT_GUID_RE.test(AUTH_AUDIENCE)
59
+ ) {
60
+ // eslint-disable-next-line no-console
61
+ console.warn(
62
+ `[outlook-assistant] OUTLOOK_AUTH_AUDIENCE="${AUTH_AUDIENCE}" is not a recognised value. ` +
63
+ `Expected one of: common, consumers, organizations, or a tenant GUID. ` +
64
+ `Proceeding anyway — Microsoft's identity platform will reject it at runtime if invalid.`
65
+ );
66
+ }
67
+
26
68
  module.exports = {
27
69
  // Server information
28
70
  SERVER_NAME: 'outlook-assistant',
@@ -54,9 +96,10 @@ module.exports = {
54
96
  ],
55
97
  tokenStorePath: path.join(homeDir, '.outlook-assistant-tokens.json'),
56
98
  authServerUrl: 'http://localhost:3333',
57
- deviceCodeEndpoint:
58
- 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode',
59
- tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
99
+ audience: AUTH_AUDIENCE,
100
+ deviceCodeEndpoint: `https://login.microsoftonline.com/${AUTH_AUDIENCE}/oauth2/v2.0/devicecode`,
101
+ tokenEndpoint: `https://login.microsoftonline.com/${AUTH_AUDIENCE}/oauth2/v2.0/token`,
102
+ authorizeEndpoint: `https://login.microsoftonline.com/${AUTH_AUDIENCE}/oauth2/v2.0/authorize`,
60
103
  defaultAuthMethod: process.env.OUTLOOK_AUTH_METHOD || 'device-code',
61
104
  },
62
105
 
@@ -97,6 +140,9 @@ module.exports = {
97
140
  // Immutable IDs (opt-in: IDs persist through folder moves)
98
141
  USE_IMMUTABLE_IDS: process.env.OUTLOOK_IMMUTABLE_IDS === 'true',
99
142
 
100
- // Timezone
101
- DEFAULT_TIMEZONE: 'Australia/Melbourne', // Updated for Nathan's timezone
143
+ // Timezone — IANA zone (e.g. "Australia/Melbourne", "Europe/London",
144
+ // "America/New_York"). Override per-deployment via OUTLOOK_DEFAULT_TIMEZONE.
145
+ // Default preserves the historic value for backwards compatibility.
146
+ DEFAULT_TIMEZONE:
147
+ process.env.OUTLOOK_DEFAULT_TIMEZONE || 'Australia/Melbourne',
102
148
  };