@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
@@ -14,14 +14,22 @@ const { ensureAuthenticated } = require('../auth');
14
14
  const { getEmailFields } = require('../utils/field-presets');
15
15
  const { resolveFolderPath } = require('./folder-utils');
16
16
  const { buildMailboxPrefix } = require('../utils/mailbox');
17
- const { writeClaimedFile, makeClaimedDir } = require('../utils/safe-write');
17
+ const {
18
+ writeClaimedFile,
19
+ ensureOutputDir,
20
+ makeClaimedDir,
21
+ confineOutputPath,
22
+ OutputPathError,
23
+ } = require('../utils/safe-write');
18
24
  const { escapeODataString } = require('../utils/odata-helpers');
19
25
  const {
20
26
  formatEmailContent,
21
27
  formatEmailsAsCSV,
22
28
  stripHtml,
23
29
  VERBOSITY,
30
+ DEFAULT_LIMITS,
24
31
  } = require('../utils/response-formatter');
32
+ const { toolError, authRequiredError } = require('../utils/tool-error');
25
33
  // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
26
34
  // InefficientFilter on personal accounts with $orderby. Client-side filtering used instead.
27
35
 
@@ -235,20 +243,9 @@ async function handleListConversations(args) {
235
243
  };
236
244
  } catch (error) {
237
245
  if (error.message === 'Authentication required') {
238
- return {
239
- content: [
240
- {
241
- type: 'text',
242
- text: "Authentication required. Please use the 'authenticate' tool first.",
243
- },
244
- ],
245
- };
246
+ return authRequiredError();
246
247
  }
247
- return {
248
- content: [
249
- { type: 'text', text: `Error listing conversations: ${error.message}` },
250
- ],
251
- };
248
+ return toolError(`Error listing conversations: ${error.message}`);
252
249
  }
253
250
  }
254
251
 
@@ -264,8 +261,8 @@ const EXPORT_CONVERSATION_MESSAGE_LIMIT = 1000;
264
261
  * personal Microsoft accounts (400 InefficientFilter), so the query carries no
265
262
  * `$orderby`: the pages are fetched and the messages are sorted here. Paging
266
263
  * stops at the caller's `limit` or if Graph repeats a nextLink; either
267
- * way the result is marked truncated. (Not callGraphAPIPaginated: it can't
268
- * report truncation or catch a repeated nextLink.)
264
+ * way the result is marked truncated. (callGraphAPIPaginated stops the same
265
+ * way and reports `hasMore` since #279; this loop predates that.)
269
266
  * @param {string} accessToken - Access token
270
267
  * @param {string} prefix - Mailbox prefix (`me` or `users/{mailbox}`)
271
268
  * @param {string} conversationId - Conversation ID
@@ -364,9 +361,7 @@ async function handleGetConversation(args) {
364
361
  const prefix = buildMailboxPrefix(sharedMailbox);
365
362
 
366
363
  if (!conversationId) {
367
- return {
368
- content: [{ type: 'text', text: 'Conversation ID is required.' }],
369
- };
364
+ return toolError('Conversation ID is required.');
370
365
  }
371
366
 
372
367
  try {
@@ -386,14 +381,9 @@ async function handleGetConversation(args) {
386
381
  );
387
382
 
388
383
  if (messages.length === 0) {
389
- return {
390
- content: [
391
- {
392
- type: 'text',
393
- text: `No messages found for conversation ID: ${conversationId}`,
394
- },
395
- ],
396
- };
384
+ return toolError(
385
+ `No messages found for conversation ID: ${conversationId}`
386
+ );
397
387
  }
398
388
 
399
389
  // Format output
@@ -407,7 +397,13 @@ async function handleGetConversation(args) {
407
397
 
408
398
  messages.forEach((msg, index) => {
409
399
  output.push(`## Message ${index + 1} of ${messages.length}`);
410
- output.push(formatEmailContent(msg, verbosity, { includeHeaders }));
400
+ output.push(
401
+ formatEmailContent(msg, verbosity, {
402
+ includeHeaders,
403
+ sharedMailbox,
404
+ maxFullBodyChars: DEFAULT_LIMITS.maxFullBodyChars,
405
+ })
406
+ );
411
407
  output.push('\n---\n');
412
408
  });
413
409
 
@@ -427,20 +423,9 @@ async function handleGetConversation(args) {
427
423
  };
428
424
  } catch (error) {
429
425
  if (error.message === 'Authentication required') {
430
- return {
431
- content: [
432
- {
433
- type: 'text',
434
- text: "Authentication required. Please use the 'authenticate' tool first.",
435
- },
436
- ],
437
- };
426
+ return authRequiredError();
438
427
  }
439
- return {
440
- content: [
441
- { type: 'text', text: `Error getting conversation: ${error.message}` },
442
- ],
443
- };
428
+ return toolError(`Error getting conversation: ${error.message}`);
444
429
  }
445
430
  }
446
431
 
@@ -467,21 +452,22 @@ async function handleExportConversation(args) {
467
452
  const prefix = buildMailboxPrefix(sharedMailbox);
468
453
 
469
454
  if (!conversationId) {
470
- return {
471
- content: [{ type: 'text', text: 'Conversation ID is required.' }],
472
- };
455
+ return toolError('Conversation ID is required.');
473
456
  }
474
457
 
475
458
  const validFormats = ['eml', 'mbox', 'markdown', 'json', 'html', 'csv'];
476
459
  if (!validFormats.includes(format)) {
477
- return {
478
- content: [
479
- {
480
- type: 'text',
481
- text: `Invalid format. Use: ${validFormats.join(', ')}`,
482
- },
483
- ],
484
- };
460
+ return toolError(`Invalid format. Use: ${validFormats.join(', ')}`);
461
+ }
462
+
463
+ // Resolve and check the directory before fetching anything; write only to
464
+ // the resolved path.
465
+ let resolvedDir;
466
+ try {
467
+ resolvedDir = confineOutputPath(outputDir);
468
+ } catch (error) {
469
+ if (!(error instanceof OutputPathError)) throw error;
470
+ return toolError(error.message, { nextStep: error.nextStep });
485
471
  }
486
472
 
487
473
  try {
@@ -500,20 +486,14 @@ async function handleExportConversation(args) {
500
486
  );
501
487
 
502
488
  if (messages.length === 0) {
503
- return {
504
- content: [
505
- {
506
- type: 'text',
507
- text: `No messages found for conversation ID: ${conversationId}`,
508
- },
509
- ],
510
- };
489
+ return toolError(
490
+ `No messages found for conversation ID: ${conversationId}`
491
+ );
511
492
  }
512
493
 
513
494
  // Create output directory
514
- const resolvedDir = path.resolve(outputDir);
515
495
  if (!fs.existsSync(resolvedDir)) {
516
- fs.mkdirSync(resolvedDir, { recursive: true });
496
+ ensureOutputDir(resolvedDir);
517
497
  }
518
498
 
519
499
  // Generate filename base
@@ -788,23 +768,9 @@ async function handleExportConversation(args) {
788
768
  };
789
769
  } catch (error) {
790
770
  if (error.message === 'Authentication required') {
791
- return {
792
- content: [
793
- {
794
- type: 'text',
795
- text: "Authentication required. Please use the 'authenticate' tool first.",
796
- },
797
- ],
798
- };
771
+ return authRequiredError();
799
772
  }
800
- return {
801
- content: [
802
- {
803
- type: 'text',
804
- text: `Error exporting conversation: ${error.message}`,
805
- },
806
- ],
807
- };
773
+ return toolError(`Error exporting conversation: ${error.message}`);
808
774
  }
809
775
  }
810
776
 
package/email/delta.js CHANGED
@@ -10,6 +10,7 @@ const { formatEmailList, VERBOSITY } = require('../utils/response-formatter');
10
10
  const { getEmailFields } = require('../utils/field-presets');
11
11
  const { buildMailboxPrefix } = require('../utils/mailbox');
12
12
  const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
13
+ const { toolError, authRequiredError } = require('../utils/tool-error');
13
14
 
14
15
  /**
15
16
  * Extract the mailbox segment (`me` or `users/{address}`) from a delta/
@@ -116,16 +117,10 @@ async function handleListEmailsDelta(args) {
116
117
  // different mailbox rather than silently syncing the wrong one.
117
118
  const tokenMailbox = mailboxFromToken(deltaToken);
118
119
  if (tokenMailbox && mailboxesConflict(tokenMailbox, prefix)) {
119
- return {
120
- content: [
121
- {
122
- type: 'text',
123
- text:
124
- `Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
125
- 'A delta token is bound to the mailbox and folder it was issued for. Use the token from that same mailbox/folder, or omit `deltaToken` to start a fresh initial sync here.',
126
- },
127
- ],
128
- };
120
+ return toolError(
121
+ `Delta token mailbox mismatch: the token belongs to \`${tokenMailbox}\` but this call targets \`${prefix}\`.\n\n` +
122
+ 'A delta token is bound to the mailbox and folder it was issued for. Use the token from that same mailbox/folder, or omit `deltaToken` to start a fresh initial sync here.'
123
+ );
129
124
  }
130
125
  endpoint = deltaToken;
131
126
  } else {
@@ -295,14 +290,7 @@ async function handleListEmailsDelta(args) {
295
290
  };
296
291
  } catch (error) {
297
292
  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
- };
293
+ return authRequiredError();
306
294
  }
307
295
 
308
296
  // Handle expired delta token
@@ -310,24 +298,12 @@ async function handleListEmailsDelta(args) {
310
298
  error.message.includes('410') ||
311
299
  error.message.includes('resyncRequired')
312
300
  ) {
313
- return {
314
- content: [
315
- {
316
- type: 'text',
317
- text: `## Delta Token Expired\n\nThe provided delta token has expired. Please start a new initial sync by calling without a deltaToken.\n\n**Error:** ${error.message}`,
318
- },
319
- ],
320
- };
301
+ return toolError(
302
+ `## Delta Token Expired\n\nThe provided delta token has expired. Please start a new initial sync by calling without a deltaToken.\n\n**Error:** ${error.message}`
303
+ );
321
304
  }
322
305
 
323
- return {
324
- content: [
325
- {
326
- type: 'text',
327
- text: `Delta sync failed: ${error.message}`,
328
- },
329
- ],
330
- };
306
+ return toolError(`Delta sync failed: ${error.message}`);
331
307
  }
332
308
  }
333
309
 
package/email/draft.js CHANGED
@@ -9,9 +9,12 @@ const { ensureAuthenticated } = require('../auth');
9
9
  const {
10
10
  checkRateLimit,
11
11
  checkRecipientAllowlist,
12
+ findBlockedRecipients,
13
+ getRecipientAllowlist,
12
14
  formatDryRunPreview,
13
15
  } = require('../utils/safety');
14
16
  const { handleGetMailTips } = require('./mail-tips');
17
+ const { toolError, authRequiredError } = require('../utils/tool-error');
15
18
 
16
19
  /**
17
20
  * Format comma-separated email string into Graph API recipient objects
@@ -25,6 +28,18 @@ function formatRecipients(recipientString) {
25
28
  }));
26
29
  }
27
30
 
31
+ /** Graph fields holding a message's recipients. */
32
+ const RECIPIENT_FIELDS = ['toRecipients', 'ccRecipients', 'bccRecipients'];
33
+
34
+ /**
35
+ * Every to/cc/bcc recipient on a Graph message.
36
+ * @param {object} message - Graph message
37
+ * @returns {Array<{emailAddress: {address: string}}>}
38
+ */
39
+ function recipientsOf(message) {
40
+ return RECIPIENT_FIELDS.flatMap((field) => message?.[field] || []);
41
+ }
42
+
28
43
  /**
29
44
  * Auto-detect HTML vs plain text body
30
45
  * @param {string} body - Email body content
@@ -108,9 +123,11 @@ class DraftGuardError extends Error {}
108
123
  * @param {string} accessToken - Graph access token
109
124
  * @param {string} id - Message id the caller passed as the draft id
110
125
  * @param {string} action - The draft action being guarded (for the message)
126
+ * @param {string[]} [extraFields] - Further fields to fetch in the same GET
127
+ * @returns {Promise<object>} The draft, with id, isDraft, subject and extraFields
111
128
  * @throws {DraftGuardError} If the id is not a draft or does not exist
112
129
  */
113
- async function assertIsDraft(accessToken, id, action) {
130
+ async function assertIsDraft(accessToken, id, action, extraFields = []) {
114
131
  let message;
115
132
  try {
116
133
  message = await callGraphAPI(
@@ -119,7 +136,7 @@ async function assertIsDraft(accessToken, id, action) {
119
136
  `me/messages/${id}`,
120
137
  null,
121
138
  {
122
- $select: 'id,isDraft,subject',
139
+ $select: ['id', 'isDraft', 'subject', ...extraFields].join(','),
123
140
  }
124
141
  );
125
142
  } catch (error) {
@@ -137,6 +154,7 @@ async function assertIsDraft(accessToken, id, action) {
137
154
  `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
155
  );
139
156
  }
157
+ return message;
140
158
  }
141
159
 
142
160
  /**
@@ -148,14 +166,9 @@ async function handleDraft(args) {
148
166
  const { action } = args;
149
167
 
150
168
  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
- };
169
+ return toolError(
170
+ "Action is required. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'."
171
+ );
159
172
  }
160
173
 
161
174
  switch (action) {
@@ -174,14 +187,9 @@ async function handleDraft(args) {
174
187
  case 'forward':
175
188
  return handleForwardDraft(args);
176
189
  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
- };
190
+ return toolError(
191
+ `Invalid action '${action}'. Use 'create', 'update', 'send', 'delete', 'reply', 'reply-all', or 'forward'.`
192
+ );
185
193
  }
186
194
  }
187
195
 
@@ -203,10 +211,12 @@ async function handleCreateDraft(args) {
203
211
  if (allowlistError) return allowlistError;
204
212
  }
205
213
 
206
- // Pre-save recipient validation via mail-tips
214
+ // Pre-save recipient validation via mail-tips. The tips are returned
215
+ // with the result, never added to the draft payload (#272).
216
+ let tipsResult = null;
207
217
  if (doCheckRecipients && allRecipients.length > 0) {
208
218
  const allAddresses = allRecipients.map((r) => r.emailAddress.address);
209
- const tipsResult = await handleGetMailTips({ recipients: allAddresses });
219
+ tipsResult = await handleGetMailTips({ recipients: allAddresses });
210
220
  const tipsText = tipsResult.content[0]?.text || '';
211
221
 
212
222
  if (dryRun) {
@@ -254,7 +264,12 @@ async function handleCreateDraft(args) {
254
264
  'me/messages',
255
265
  message
256
266
  );
257
- return formatDraftResponse(draft, 'created');
267
+ const response = formatDraftResponse(draft, 'created');
268
+ if (tipsResult) {
269
+ response.content[0].text += `\n---\n\n${tipsResult.content[0]?.text || ''}`;
270
+ response._meta.mailTips = tipsResult._meta;
271
+ }
272
+ return response;
258
273
  } catch (error) {
259
274
  return handleError('creating draft', error);
260
275
  }
@@ -267,11 +282,7 @@ async function handleUpdateDraft(args) {
267
282
  const { id } = args;
268
283
 
269
284
  if (!id) {
270
- return {
271
- content: [
272
- { type: 'text', text: 'Draft ID (id) is required for update.' },
273
- ],
274
- };
285
+ return toolError('Draft ID (id) is required for update.');
275
286
  }
276
287
 
277
288
  const message = buildMessageObject(args);
@@ -314,14 +325,29 @@ async function handleSendDraft(args) {
314
325
  const { id } = args;
315
326
 
316
327
  if (!id) {
317
- return {
318
- content: [{ type: 'text', text: 'Draft ID (id) is required for send.' }],
319
- };
328
+ return toolError('Draft ID (id) is required for send.');
320
329
  }
321
330
 
322
331
  try {
323
332
  const accessToken = await ensureAuthenticated();
324
- await assertIsDraft(accessToken, id, 'send');
333
+ // Fetch the recipients as they are now: the draft may have been edited
334
+ // (here or in Outlook) since it was created.
335
+ const draft = await assertIsDraft(
336
+ accessToken,
337
+ id,
338
+ 'send',
339
+ RECIPIENT_FIELDS
340
+ );
341
+ const blocked = findBlockedRecipients(recipientsOf(draft));
342
+ if (blocked) {
343
+ return toolError(
344
+ `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.`,
345
+ {
346
+ nextStep:
347
+ 'Remove those recipients from the draft (in Outlook, or with draft action=update), or ask the user to add them to OUTLOOK_ALLOWED_RECIPIENTS.',
348
+ }
349
+ );
350
+ }
325
351
 
326
352
  // Rate limit via send-email counter (shares limit with direct sends)
327
353
  const rateLimitError = checkRateLimit('send-email');
@@ -348,11 +374,7 @@ async function handleDeleteDraft(args) {
348
374
  const { id } = args;
349
375
 
350
376
  if (!id) {
351
- return {
352
- content: [
353
- { type: 'text', text: 'Draft ID (id) is required for delete.' },
354
- ],
355
- };
377
+ return toolError('Draft ID (id) is required for delete.');
356
378
  }
357
379
 
358
380
  try {
@@ -379,25 +401,15 @@ async function handleReplyDraft(args, endpoint) {
379
401
  const { id, body, comment } = args;
380
402
 
381
403
  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
- };
404
+ return toolError(
405
+ `Message ID (id) is required for ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'}.`
406
+ );
390
407
  }
391
408
 
392
409
  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
- };
410
+ return toolError(
411
+ 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.'
412
+ );
401
413
  }
402
414
 
403
415
  const requestBody = {};
@@ -412,6 +424,13 @@ async function handleReplyDraft(args, endpoint) {
412
424
  };
413
425
  }
414
426
 
427
+ const actionName = endpoint === 'createReplyAll' ? 'reply-all' : 'reply';
428
+
429
+ // Counted before the draft is created: a reply that the allowlist refuses
430
+ // below has still written (and removed) a draft.
431
+ const rateLimitError = checkRateLimit('draft');
432
+ if (rateLimitError) return rateLimitError;
433
+
415
434
  try {
416
435
  const accessToken = await ensureAuthenticated();
417
436
  const draft = await callGraphAPI(
@@ -420,17 +439,69 @@ async function handleReplyDraft(args, endpoint) {
420
439
  `me/messages/${id}/${endpoint}`,
421
440
  Object.keys(requestBody).length > 0 ? requestBody : null
422
441
  );
423
- const label =
424
- endpoint === 'createReplyAll'
425
- ? 'reply-all draft created'
426
- : 'reply draft created';
427
- return formatDraftResponse(draft, label);
442
+
443
+ // Graph fills in the recipients from the original message, so they can
444
+ // only be checked once the draft exists.
445
+ const refusal = await refuseBlockedReply(accessToken, draft, actionName);
446
+ if (refusal) return refusal;
447
+
448
+ return formatDraftResponse(draft, `${actionName} draft created`);
449
+ } catch (error) {
450
+ return handleError(`creating ${actionName} draft`, error);
451
+ }
452
+ }
453
+
454
+ /**
455
+ * When a recipient allowlist is configured, check the recipients Graph put
456
+ * on a new reply draft. If any is not allowed, delete the draft and return a
457
+ * refusal; otherwise return null.
458
+ * @param {string} accessToken - Graph access token
459
+ * @param {object} draft - The draft Graph returned from createReply/createReplyAll
460
+ * @param {string} actionName - 'reply' or 'reply-all'
461
+ * @returns {Promise<object|null>} A tool error, or null to keep the draft
462
+ */
463
+ async function refuseBlockedReply(accessToken, draft, actionName) {
464
+ if (!getRecipientAllowlist()) return null;
465
+
466
+ let reason;
467
+ let nextStep;
468
+ try {
469
+ let message = draft;
470
+ if (!RECIPIENT_FIELDS.every((field) => Array.isArray(draft?.[field]))) {
471
+ message = await callGraphAPI(
472
+ accessToken,
473
+ 'GET',
474
+ `me/messages/${draft.id}`,
475
+ null,
476
+ { $select: ['id', ...RECIPIENT_FIELDS].join(',') }
477
+ );
478
+ }
479
+ const blocked = findBlockedRecipients(recipientsOf(message));
480
+ if (!blocked) return null;
481
+ reason = `The ${actionName} draft would be addressed to ${blocked.blocked.join(', ')}, which OUTLOOK_ALLOWED_RECIPIENTS does not allow (allowed recipients/domains: ${blocked.allowed.join(', ')}).`;
482
+ nextStep =
483
+ '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.';
428
484
  } catch (error) {
429
- return handleError(
430
- `creating ${endpoint === 'createReplyAll' ? 'reply-all' : 'reply'} draft`,
431
- error
485
+ // Unchecked recipients are treated like blocked ones.
486
+ reason = `The ${actionName} draft's recipients could not be checked against OUTLOOK_ALLOWED_RECIPIENTS (${error.message}).`;
487
+ nextStep = 'Try the call again.';
488
+ }
489
+
490
+ try {
491
+ await callGraphAPI(accessToken, 'DELETE', `me/messages/${draft.id}`);
492
+ } catch (error) {
493
+ return toolError(
494
+ `${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.`,
495
+ {
496
+ nextStep: `Delete it with draft action=delete id=${draft.id} (or in Outlook). ${nextStep}`,
497
+ }
432
498
  );
433
499
  }
500
+
501
+ return toolError(
502
+ `${reason} The draft Graph created was deleted, so nothing was kept.`,
503
+ { nextStep }
504
+ );
434
505
  }
435
506
 
436
507
  /**
@@ -440,33 +511,17 @@ async function handleForwardDraft(args) {
440
511
  const { id, to, body, comment } = args;
441
512
 
442
513
  if (!id) {
443
- return {
444
- content: [
445
- { type: 'text', text: 'Message ID (id) is required for forward.' },
446
- ],
447
- };
514
+ return toolError('Message ID (id) is required for forward.');
448
515
  }
449
516
 
450
517
  if (!to) {
451
- return {
452
- content: [
453
- {
454
- type: 'text',
455
- text: 'Forward recipient (to) is required for forward.',
456
- },
457
- ],
458
- };
518
+ return toolError('Forward recipient (to) is required for forward.');
459
519
  }
460
520
 
461
521
  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
- };
522
+ return toolError(
523
+ 'Cannot use both comment and body. Use comment for a short prepended note, or body for full HTML/text content.'
524
+ );
470
525
  }
471
526
 
472
527
  const toRecipients = formatRecipients(to);
@@ -475,6 +530,9 @@ async function handleForwardDraft(args) {
475
530
  const allowlistError = checkRecipientAllowlist(toRecipients);
476
531
  if (allowlistError) return allowlistError;
477
532
 
533
+ const rateLimitError = checkRateLimit('draft');
534
+ if (rateLimitError) return rateLimitError;
535
+
478
536
  const requestBody = {
479
537
  toRecipients,
480
538
  };
@@ -516,24 +574,10 @@ function handleError(actionLabel, error) {
516
574
  }
517
575
 
518
576
  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
- };
577
+ return authRequiredError();
527
578
  }
528
579
 
529
- return {
530
- content: [
531
- {
532
- type: 'text',
533
- text: `Error ${actionLabel}: ${error.message}`,
534
- },
535
- ],
536
- };
580
+ return toolError(`Error ${actionLabel}: ${error.message}`);
537
581
  }
538
582
 
539
583
  module.exports = handleDraft;