@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
package/email/draft.js CHANGED
@@ -8,10 +8,14 @@ const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const {
10
10
  checkRateLimit,
11
+ releaseRateLimit,
11
12
  checkRecipientAllowlist,
13
+ findBlockedRecipients,
14
+ getRecipientAllowlist,
12
15
  formatDryRunPreview,
13
16
  } = require('../utils/safety');
14
17
  const { handleGetMailTips } = require('./mail-tips');
18
+ const { toolError, authRequiredError } = require('../utils/tool-error');
15
19
 
16
20
  /**
17
21
  * Format comma-separated email string into Graph API recipient objects
@@ -25,6 +29,18 @@ function formatRecipients(recipientString) {
25
29
  }));
26
30
  }
27
31
 
32
+ /** Graph fields holding a message's recipients. */
33
+ const RECIPIENT_FIELDS = ['toRecipients', 'ccRecipients', 'bccRecipients'];
34
+
35
+ /**
36
+ * Every to/cc/bcc recipient on a Graph message.
37
+ * @param {object} message - Graph message
38
+ * @returns {Array<{emailAddress: {address: string}}>}
39
+ */
40
+ function recipientsOf(message) {
41
+ return RECIPIENT_FIELDS.flatMap((field) => message?.[field] || []);
42
+ }
43
+
28
44
  /**
29
45
  * Auto-detect HTML vs plain text body
30
46
  * @param {string} body - Email body content
@@ -71,7 +87,7 @@ function buildMessageObject(args) {
71
87
  /**
72
88
  * Format a draft response with key details
73
89
  * @param {object} draft - Graph API message response
74
- * @param {string} actionLabel - Human-readable action (e.g. "created", "updated")
90
+ * @param {string} actionLabel - Heading, e.g. "Draft created" or "Reply draft created"
75
91
  * @returns {object} - MCP response
76
92
  */
77
93
  function formatDraftResponse(draft, actionLabel) {
@@ -79,7 +95,7 @@ function formatDraftResponse(draft, actionLabel) {
79
95
  .map((r) => r.emailAddress?.address)
80
96
  .join(', ');
81
97
 
82
- let text = `Draft ${actionLabel}.\n\n`;
98
+ let text = `${actionLabel}.\n\n`;
83
99
  text += `**ID**: \`${draft.id}\`\n`;
84
100
  if (draft.subject) text += `**Subject**: ${draft.subject}\n`;
85
101
  if (to) text += `**To**: ${to}\n`;
@@ -108,9 +124,11 @@ class DraftGuardError extends Error {}
108
124
  * @param {string} accessToken - Graph access token
109
125
  * @param {string} id - Message id the caller passed as the draft id
110
126
  * @param {string} action - The draft action being guarded (for the message)
127
+ * @param {string[]} [extraFields] - Further fields to fetch in the same GET
128
+ * @returns {Promise<object>} The draft, with id, isDraft, subject and extraFields
111
129
  * @throws {DraftGuardError} If the id is not a draft or does not exist
112
130
  */
113
- async function assertIsDraft(accessToken, id, action) {
131
+ async function assertIsDraft(accessToken, id, action, extraFields = []) {
114
132
  let message;
115
133
  try {
116
134
  message = await callGraphAPI(
@@ -119,7 +137,7 @@ async function assertIsDraft(accessToken, id, action) {
119
137
  `me/messages/${id}`,
120
138
  null,
121
139
  {
122
- $select: 'id,isDraft,subject',
140
+ $select: ['id', 'isDraft', 'subject', ...extraFields].join(','),
123
141
  }
124
142
  );
125
143
  } catch (error) {
@@ -137,6 +155,7 @@ async function assertIsDraft(accessToken, id, action) {
137
155
  `Message \`${id}\`${subject} is not a draft, so draft action=${action} refused it and nothing was changed. update/send/delete only act on unsent drafts.`
138
156
  );
139
157
  }
158
+ return message;
140
159
  }
141
160
 
142
161
  /**
@@ -148,14 +167,9 @@ async function handleDraft(args) {
148
167
  const { action } = args;
149
168
 
150
169
  if (!action) {
151
- return {
152
- content: [
153
- {
154
- type: 'text',
155
- text: "Action is required. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.",
156
- },
157
- ],
158
- };
170
+ return toolError(
171
+ "Action is required. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'."
172
+ );
159
173
  }
160
174
 
161
175
  switch (action) {
@@ -174,14 +188,9 @@ async function handleDraft(args) {
174
188
  case 'forward':
175
189
  return handleForwardDraft(args);
176
190
  default:
177
- return {
178
- content: [
179
- {
180
- type: 'text',
181
- text: `Invalid action '${action}'. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.`,
182
- },
183
- ],
184
- };
191
+ return toolError(
192
+ `Invalid action '${action}'. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.`
193
+ );
185
194
  }
186
195
  }
187
196
 
@@ -203,14 +212,16 @@ async function handleCreateDraft(args) {
203
212
  if (allowlistError) return allowlistError;
204
213
  }
205
214
 
206
- // Pre-save recipient validation via mail-tips
215
+ // Pre-save recipient validation via mail-tips. The tips are returned
216
+ // with the result, never added to the draft payload (#272).
217
+ let tipsResult = null;
207
218
  if (doCheckRecipients && allRecipients.length > 0) {
208
219
  const allAddresses = allRecipients.map((r) => r.emailAddress.address);
209
- const tipsResult = await handleGetMailTips({ recipients: allAddresses });
220
+ tipsResult = await handleGetMailTips({ recipients: allAddresses });
210
221
  const tipsText = tipsResult.content[0]?.text || '';
211
222
 
212
223
  if (dryRun) {
213
- const preview = formatDryRunPreview({ message, saveToSentItems: true });
224
+ const preview = formatDryRunPreview({ message, isDraft: true });
214
225
  return {
215
226
  content: [
216
227
  {
@@ -228,7 +239,7 @@ async function handleCreateDraft(args) {
228
239
 
229
240
  // Dry-run mode: preview without saving
230
241
  if (dryRun) {
231
- const preview = formatDryRunPreview({ message, saveToSentItems: true });
242
+ const preview = formatDryRunPreview({ message, isDraft: true });
232
243
  return {
233
244
  content: [
234
245
  {
@@ -254,7 +265,12 @@ async function handleCreateDraft(args) {
254
265
  'me/messages',
255
266
  message
256
267
  );
257
- return formatDraftResponse(draft, 'created');
268
+ const response = formatDraftResponse(draft, 'Draft created');
269
+ if (tipsResult) {
270
+ response.content[0].text += `\n---\n\n${tipsResult.content[0]?.text || ''}`;
271
+ response._meta.mailTips = tipsResult._meta;
272
+ }
273
+ return response;
258
274
  } catch (error) {
259
275
  return handleError('creating draft', error);
260
276
  }
@@ -267,11 +283,7 @@ async function handleUpdateDraft(args) {
267
283
  const { id } = args;
268
284
 
269
285
  if (!id) {
270
- return {
271
- content: [
272
- { type: 'text', text: 'Draft ID (id) is required for update.' },
273
- ],
274
- };
286
+ return toolError('Draft ID (id) is required for update.');
275
287
  }
276
288
 
277
289
  const message = buildMessageObject(args);
@@ -301,7 +313,7 @@ async function handleUpdateDraft(args) {
301
313
  `me/messages/${id}`,
302
314
  message
303
315
  );
304
- return formatDraftResponse(draft, 'updated');
316
+ return formatDraftResponse(draft, 'Draft updated');
305
317
  } catch (error) {
306
318
  return handleError('updating draft', error);
307
319
  }
@@ -314,14 +326,29 @@ async function handleSendDraft(args) {
314
326
  const { id } = args;
315
327
 
316
328
  if (!id) {
317
- return {
318
- content: [{ type: 'text', text: 'Draft ID (id) is required for send.' }],
319
- };
329
+ return toolError('Draft ID (id) is required for send.');
320
330
  }
321
331
 
322
332
  try {
323
333
  const accessToken = await ensureAuthenticated();
324
- await assertIsDraft(accessToken, id, 'send');
334
+ // Fetch the recipients as they are now: the draft may have been edited
335
+ // (here or in Outlook) since it was created.
336
+ const draft = await assertIsDraft(
337
+ accessToken,
338
+ id,
339
+ 'send',
340
+ RECIPIENT_FIELDS
341
+ );
342
+ const blocked = findBlockedRecipients(recipientsOf(draft));
343
+ if (blocked) {
344
+ return toolError(
345
+ `Draft not sent: it is addressed to ${blocked.blocked.join(', ')}, which OUTLOOK_ALLOWED_RECIPIENTS does not allow (allowed recipients/domains: ${blocked.allowed.join(', ')}). The draft is unchanged and still in Drafts.`,
346
+ {
347
+ nextStep:
348
+ 'Remove those recipients from the draft (in Outlook, or with draft action=update), or ask the user to add them to OUTLOOK_ALLOWED_RECIPIENTS.',
349
+ }
350
+ );
351
+ }
325
352
 
326
353
  // Rate limit via send-email counter (shares limit with direct sends)
327
354
  const rateLimitError = checkRateLimit('send-email');
@@ -348,11 +375,7 @@ async function handleDeleteDraft(args) {
348
375
  const { id } = args;
349
376
 
350
377
  if (!id) {
351
- return {
352
- content: [
353
- { type: 'text', text: 'Draft ID (id) is required for delete.' },
354
- ],
355
- };
378
+ return toolError('Draft ID (id) is required for delete.');
356
379
  }
357
380
 
358
381
  try {
@@ -379,25 +402,15 @@ async function handleReplyDraft(args, endpoint) {
379
402
  const { id, body, comment } = args;
380
403
 
381
404
  if (!id) {
382
- return {
383
- content: [
384
- {
385
- type: 'text',
386
- text: `Message ID (id) is required for ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'}.`,
387
- },
388
- ],
389
- };
405
+ return toolError(
406
+ `Message ID (id) is required for ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'}.`
407
+ );
390
408
  }
391
409
 
392
410
  if (comment && body) {
393
- return {
394
- content: [
395
- {
396
- type: 'text',
397
- text: 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.',
398
- },
399
- ],
400
- };
411
+ return toolError(
412
+ 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.'
413
+ );
401
414
  }
402
415
 
403
416
  const requestBody = {};
@@ -412,25 +425,106 @@ async function handleReplyDraft(args, endpoint) {
412
425
  };
413
426
  }
414
427
 
428
+ const actionName = endpoint === 'createReplyAll' ? 'reply-all' : 'reply';
429
+
430
+ // Taken before the draft is created, and given back only when nothing is
431
+ // definitely left behind (#299): Graph rejected the create with a 4xx, or
432
+ // the allowlist refused the reply and its draft was deleted again. A
433
+ // timeout, 5xx or failed delete may leave a draft, so the slot stays used.
434
+ const rateLimitError = checkRateLimit('draft');
435
+ if (rateLimitError) return rateLimitError;
436
+
437
+ let draft;
415
438
  try {
416
439
  const accessToken = await ensureAuthenticated();
417
- const draft = await callGraphAPI(
440
+ draft = await callGraphAPI(
418
441
  accessToken,
419
442
  'POST',
420
443
  `me/messages/${id}/${endpoint}`,
421
444
  Object.keys(requestBody).length > 0 ? requestBody : null
422
445
  );
423
- const label =
424
- endpoint === 'createReplyAll'
425
- ? 'reply-all draft created'
426
- : 'reply draft created';
427
- return formatDraftResponse(draft, label);
446
+
447
+ // Graph fills in the recipients from the original message, so they can
448
+ // only be checked once the draft exists.
449
+ const refusal = await refuseBlockedReply(accessToken, draft, actionName);
450
+ if (refusal) {
451
+ if (refusal.draftDeleted) releaseRateLimit('draft');
452
+ return refusal.error;
453
+ }
454
+
455
+ return formatDraftResponse(
456
+ draft,
457
+ `${actionName.charAt(0).toUpperCase()}${actionName.slice(1)} draft created`
458
+ );
459
+ } catch (error) {
460
+ // Only Graph's own rejection, read from the start of the error (the
461
+ // body after it is server text). 408 means it may have gone through.
462
+ const rejected = /^API call failed with status (4\d\d):/.exec(
463
+ error.message || ''
464
+ );
465
+ if (!draft && rejected && rejected[1] !== '408') {
466
+ releaseRateLimit('draft');
467
+ }
468
+ return handleError(`creating ${actionName} draft`, error);
469
+ }
470
+ }
471
+
472
+ /**
473
+ * When a recipient allowlist is configured, check the recipients Graph put
474
+ * on a new reply draft. If any is not allowed, delete the draft and return a
475
+ * refusal; otherwise return null.
476
+ * @param {string} accessToken - Graph access token
477
+ * @param {object} draft - The draft Graph returned from createReply/createReplyAll
478
+ * @param {string} actionName - 'reply' or 'reply-all'
479
+ * @returns {Promise<{error: object, draftDeleted: boolean}|null>} The
480
+ * refusal and whether its draft was deleted, or null to keep the draft
481
+ */
482
+ async function refuseBlockedReply(accessToken, draft, actionName) {
483
+ if (!getRecipientAllowlist()) return null;
484
+
485
+ let reason;
486
+ let nextStep;
487
+ try {
488
+ let message = draft;
489
+ if (!RECIPIENT_FIELDS.every((field) => Array.isArray(draft?.[field]))) {
490
+ message = await callGraphAPI(
491
+ accessToken,
492
+ 'GET',
493
+ `me/messages/${draft.id}`,
494
+ null,
495
+ { $select: ['id', ...RECIPIENT_FIELDS].join(',') }
496
+ );
497
+ }
498
+ const blocked = findBlockedRecipients(recipientsOf(message));
499
+ if (!blocked) return null;
500
+ reason = `The ${actionName} draft would be addressed to ${blocked.blocked.join(', ')}, which OUTLOOK_ALLOWED_RECIPIENTS does not allow (allowed recipients/domains: ${blocked.allowed.join(', ')}).`;
501
+ nextStep =
502
+ 'Reply only to allowed recipients (for example, action=reply rather than reply-all, or draft action=create addressed to them), or ask the user to add those recipients to OUTLOOK_ALLOWED_RECIPIENTS.';
503
+ } catch (error) {
504
+ // Unchecked recipients are treated like blocked ones.
505
+ reason = `The ${actionName} draft's recipients could not be checked against OUTLOOK_ALLOWED_RECIPIENTS (${error.message}).`;
506
+ nextStep = 'Try the call again.';
507
+ }
508
+
509
+ try {
510
+ await callGraphAPI(accessToken, 'DELETE', `me/messages/${draft.id}`);
428
511
  } catch (error) {
429
- return handleError(
430
- `creating ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'} draft`,
431
- error
512
+ const stillThere = toolError(
513
+ `${reason} The draft Graph created could not be deleted (${error.message}), so it is still in Drafts with ID \`${draft.id}\`. Do not send it.`,
514
+ {
515
+ nextStep: `Delete it with draft action=delete id=${draft.id} (or in Outlook). ${nextStep}`,
516
+ }
432
517
  );
518
+ return { error: stillThere, draftDeleted: false };
433
519
  }
520
+
521
+ return {
522
+ error: toolError(
523
+ `${reason} The draft Graph created was deleted, so nothing was kept.`,
524
+ { nextStep }
525
+ ),
526
+ draftDeleted: true,
527
+ };
434
528
  }
435
529
 
436
530
  /**
@@ -440,33 +534,17 @@ async function handleForwardDraft(args) {
440
534
  const { id, to, body, comment } = args;
441
535
 
442
536
  if (!id) {
443
- return {
444
- content: [
445
- { type: 'text', text: 'Message ID (id) is required for forward.' },
446
- ],
447
- };
537
+ return toolError('Message ID (id) is required for forward.');
448
538
  }
449
539
 
450
540
  if (!to) {
451
- return {
452
- content: [
453
- {
454
- type: 'text',
455
- text: 'Forward recipient (to) is required for forward.',
456
- },
457
- ],
458
- };
541
+ return toolError('Forward recipient (to) is required for forward.');
459
542
  }
460
543
 
461
544
  if (comment && body) {
462
- return {
463
- content: [
464
- {
465
- type: 'text',
466
- text: 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.',
467
- },
468
- ],
469
- };
545
+ return toolError(
546
+ 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.'
547
+ );
470
548
  }
471
549
 
472
550
  const toRecipients = formatRecipients(to);
@@ -475,6 +553,9 @@ async function handleForwardDraft(args) {
475
553
  const allowlistError = checkRecipientAllowlist(toRecipients);
476
554
  if (allowlistError) return allowlistError;
477
555
 
556
+ const rateLimitError = checkRateLimit('draft');
557
+ if (rateLimitError) return rateLimitError;
558
+
478
559
  const requestBody = {
479
560
  toRecipients,
480
561
  };
@@ -498,7 +579,7 @@ async function handleForwardDraft(args) {
498
579
  `me/messages/${id}/createForward`,
499
580
  requestBody
500
581
  );
501
- return formatDraftResponse(draft, 'forward draft created');
582
+ return formatDraftResponse(draft, 'Forward draft created');
502
583
  } catch (error) {
503
584
  return handleError('creating forward draft', error);
504
585
  }
@@ -516,24 +597,10 @@ function handleError(actionLabel, error) {
516
597
  }
517
598
 
518
599
  if (error.message === 'Authentication required') {
519
- return {
520
- content: [
521
- {
522
- type: 'text',
523
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
524
- },
525
- ],
526
- };
600
+ return authRequiredError();
527
601
  }
528
602
 
529
- return {
530
- content: [
531
- {
532
- type: 'text',
533
- text: `Error ${actionLabel}: ${error.message}`,
534
- },
535
- ],
536
- };
603
+ return toolError(`Error ${actionLabel}: ${error.message}`);
537
604
  }
538
605
 
539
606
  module.exports = handleDraft;