@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
package/email/search.js CHANGED
@@ -19,6 +19,8 @@ const {
19
19
  escapeSearchPhrase,
20
20
  quoteSearchPhrase,
21
21
  } = require('../utils/odata-helpers');
22
+ const { toolError, authRequiredError } = require('../utils/tool-error');
23
+ const { log } = require('../utils/logger');
22
24
 
23
25
  // Upper bound on how many recent messages the client-side fallback scans
24
26
  // before giving up. Deliberately DECOUPLED from the requested result count so
@@ -50,9 +52,7 @@ async function handleSearchEmails(args) {
50
52
 
51
53
  // Validate count
52
54
  if (args.count !== undefined && args.count < 1) {
53
- return {
54
- content: [{ type: 'text', text: 'count must be at least 1.' }],
55
- };
55
+ return toolError('count must be at least 1.');
56
56
  }
57
57
 
58
58
  // F-17: accept `maxResults` as an alias for `count` in non-delta mode.
@@ -111,12 +111,12 @@ async function handleSearchEmails(args) {
111
111
  let endpoint;
112
112
  if (searchAllFolders) {
113
113
  endpoint = `${buildMailboxPrefix(sharedMailbox)}/messages`;
114
- console.error(
114
+ log.debug(
115
115
  `Searching across all mail folders${sharedMailbox ? ` in ${sharedMailbox}` : ''}`
116
116
  );
117
117
  } else {
118
118
  endpoint = await resolveFolderPath(accessToken, folder, sharedMailbox);
119
- console.error(`Using endpoint: ${endpoint} for folder: ${folder}`);
119
+ log.debug(`Using endpoint: ${endpoint} for folder: ${folder}`);
120
120
  }
121
121
 
122
122
  // Execute progressive search with pagination
@@ -140,25 +140,11 @@ async function handleSearchEmails(args) {
140
140
  } catch (error) {
141
141
  // Handle authentication errors
142
142
  if (error.message === 'Authentication required') {
143
- return {
144
- content: [
145
- {
146
- type: 'text',
147
- text: "Authentication required. Please use the 'authenticate' tool first.",
148
- },
149
- ],
150
- };
143
+ return authRequiredError();
151
144
  }
152
145
 
153
146
  // General error response
154
- return {
155
- content: [
156
- {
157
- type: 'text',
158
- text: `Error searching emails: ${error.message}`,
159
- },
160
- ],
161
- };
147
+ return toolError(`Error searching emails: ${error.message}`);
162
148
  }
163
149
  }
164
150
 
@@ -256,7 +242,7 @@ async function retryFieldScopedExpression(ctx) {
256
242
  const translated = parseFieldScopedExpression(trimmedKql);
257
243
  if (!translated) return null;
258
244
 
259
- console.error(
245
+ log.debug(
260
246
  `Retrying field-scoped searchExpression as OData filters: ${JSON.stringify(translated)}`
261
247
  );
262
248
  const retry = await progressiveSearch(
@@ -357,7 +343,7 @@ async function progressiveSearch(
357
343
  };
358
344
 
359
345
  try {
360
- console.error(`Attempting raw KQL search: ${kqlForSearch}`);
346
+ log.debug(`Attempting raw KQL search: ${kqlForSearch}`);
361
347
  searchAttempts.push('raw-kql');
362
348
 
363
349
  const kqlParams = {
@@ -373,7 +359,7 @@ async function progressiveSearch(
373
359
  kqlParams,
374
360
  maxCount
375
361
  );
376
- console.error(
362
+ log.debug(
377
363
  `Raw KQL search complete: ${response.value?.length || 0} results`
378
364
  );
379
365
  const matched = response.value?.length || 0;
@@ -400,7 +386,7 @@ async function progressiveSearch(
400
386
  const retry = await retryFieldScopedExpression(translationContext);
401
387
  if (retry) return retry;
402
388
  } catch (retryError) {
403
- console.error(`Translated retry failed: ${retryError.message}`);
389
+ log.debug(`Translated retry failed: ${retryError.message}`);
404
390
  }
405
391
  }
406
392
 
@@ -408,7 +394,7 @@ async function progressiveSearch(
408
394
  // would ignore kqlQuery and return unrelated emails.
409
395
  return response;
410
396
  } catch (error) {
411
- console.error(`Raw KQL search failed: ${error.message}`);
397
+ log.debug(`Raw KQL search failed: ${error.message}`);
412
398
  searchAttempts.push('raw-kql-error');
413
399
 
414
400
  // A field-scoped expression is what Graph rejects here on personal
@@ -422,7 +408,7 @@ async function progressiveSearch(
422
408
  return retry;
423
409
  }
424
410
  } catch (retryError) {
425
- console.error(`Translated retry also failed: ${retryError.message}`);
411
+ log.debug(`Translated retry also failed: ${retryError.message}`);
426
412
  }
427
413
 
428
414
  // Surface the failure rather than masking it with unrelated results.
@@ -454,7 +440,7 @@ async function progressiveSearch(
454
440
  (filterTerms.hasAttachments === true || filterTerms.unreadOnly === true)
455
441
  ) {
456
442
  // Skip directly to boolean-only filter (step 3) — combined search is redundant
457
- console.error('Only boolean filters provided, skipping combined search');
443
+ log.debug('Only boolean filters provided, skipping combined search');
458
444
  } else {
459
445
  try {
460
446
  const params = buildSearchParams(
@@ -463,7 +449,7 @@ async function progressiveSearch(
463
449
  Math.min(50, maxCount),
464
450
  selectFields
465
451
  );
466
- console.error('Attempting combined search with params:', params);
452
+ log.debug('Attempting combined search with params:', params);
467
453
  searchAttempts.push('combined-search');
468
454
 
469
455
  const response = await callGraphAPIPaginated(
@@ -474,7 +460,7 @@ async function progressiveSearch(
474
460
  maxCount
475
461
  );
476
462
  if (response.value && response.value.length > 0) {
477
- console.error(
463
+ log.debug(
478
464
  `Combined search successful: found ${response.value.length} results`
479
465
  );
480
466
  response._searchInfo = {
@@ -488,7 +474,7 @@ async function progressiveSearch(
488
474
  return response;
489
475
  }
490
476
  } catch (error) {
491
- console.error(`Combined search failed: ${error.message}`);
477
+ log.debug(`Combined search failed: ${error.message}`);
492
478
  }
493
479
  }
494
480
 
@@ -503,9 +489,7 @@ async function progressiveSearch(
503
489
  // 2a. Server-side single-term attempt (isolated try/catch — a failure
504
490
  // here just falls through to the one client-side fallback below).
505
491
  try {
506
- console.error(
507
- `Attempting search with only ${term}: "${searchTerms[term]}"`
508
- );
492
+ log.debug(`Attempting search with only ${term}: "${searchTerms[term]}"`);
509
493
  searchAttempts.push(`single-term-${term}`);
510
494
 
511
495
  const simplifiedParams = {
@@ -565,7 +549,7 @@ async function progressiveSearch(
565
549
  secondaryApplied.length > 0
566
550
  ? ` after local ${secondaryApplied.join(', ')} narrowing of ${response.value.length}`
567
551
  : '';
568
- console.error(
552
+ log.debug(
569
553
  `Search with ${term} successful: found ${matched.length} results${narrowing}`
570
554
  );
571
555
  narrowResponse(response, matched);
@@ -582,12 +566,12 @@ async function progressiveSearch(
582
566
  return response;
583
567
  }
584
568
  recordNarrowingMiss(scanState, response.value.length);
585
- console.error(
569
+ log.debug(
586
570
  `Search with ${term} found ${response.value.length} results, but none also satisfied ${secondaryApplied.join(', ')} — continuing`
587
571
  );
588
572
  }
589
573
  } catch (error) {
590
- console.error(`Search with ${term} failed: ${error.message}`);
574
+ log.debug(`Search with ${term} failed: ${error.message}`);
591
575
  // Fall through to the client-side fallback below.
592
576
  }
593
577
 
@@ -597,7 +581,7 @@ async function progressiveSearch(
597
581
  // try/catch prevents the double-scan/double-label a throw inside a
598
582
  // success-path fallback would otherwise cause. (#169)
599
583
  if (term === 'to' || term === 'query') {
600
- console.error(
584
+ log.debug(
601
585
  `${term} unsatisfied server-side, trying client-side filtering`
602
586
  );
603
587
  searchAttempts.push(`client-side-${term}`);
@@ -614,7 +598,7 @@ async function progressiveSearch(
614
598
  );
615
599
  if (fallback) return fallback;
616
600
  } catch (fallbackError) {
617
- console.error(
601
+ log.debug(
618
602
  `Client-side ${term} fallback also failed: ${fallbackError.message}`
619
603
  );
620
604
  }
@@ -628,7 +612,7 @@ async function progressiveSearch(
628
612
  filterTerms.receivedAfter || filterTerms.receivedBefore;
629
613
  if (hasBooleanFilters || hasDateFilters) {
630
614
  try {
631
- console.error('Attempting search with only boolean/date filters');
615
+ log.debug('Attempting search with only boolean/date filters');
632
616
  searchAttempts.push('boolean-filters-only');
633
617
 
634
618
  const filterOnlyParams = {
@@ -651,7 +635,7 @@ async function progressiveSearch(
651
635
  filterOnlyParams,
652
636
  maxCount
653
637
  );
654
- console.error(
638
+ log.debug(
655
639
  `Boolean filter search found ${response.value?.length || 0} results`
656
640
  );
657
641
  // This step applied only the boolean/date filters. Narrow by the
@@ -677,15 +661,15 @@ async function progressiveSearch(
677
661
  return response;
678
662
  }
679
663
  recordNarrowingMiss(scanState, response.value.length);
680
- console.error(
664
+ log.debug(
681
665
  `Boolean filter search found ${response.value.length} results, but none satisfied ${secondaryApplied.join(', ')} — continuing`
682
666
  );
683
667
  } catch (error) {
684
- console.error(`Boolean filter search failed: ${error.message}`);
668
+ log.debug(`Boolean filter search failed: ${error.message}`);
685
669
  // Retry without $orderby if it was the issue
686
670
  if (error.message && error.message.includes('InefficientFilter')) {
687
671
  try {
688
- console.error('Retrying boolean filters without $orderby');
672
+ log.debug('Retrying boolean filters without $orderby');
689
673
  const retryParams = {
690
674
  $top: Math.min(50, maxCount),
691
675
  $select: selectFields,
@@ -722,13 +706,11 @@ async function progressiveSearch(
722
706
  return response;
723
707
  }
724
708
  recordNarrowingMiss(scanState, response.value.length);
725
- console.error(
709
+ log.debug(
726
710
  `Boolean filter retry found ${response.value.length} results, but none satisfied ${secondaryApplied.join(', ')} — continuing`
727
711
  );
728
712
  } catch (retryError) {
729
- console.error(
730
- `Boolean filter retry also failed: ${retryError.message}`
731
- );
713
+ log.debug(`Boolean filter retry also failed: ${retryError.message}`);
732
714
  }
733
715
  }
734
716
  }
@@ -745,7 +727,7 @@ async function progressiveSearch(
745
727
  searchTerms.kqlQuery;
746
728
 
747
729
  if (hasAnyFilters) {
748
- console.error(
730
+ log.debug(
749
731
  'All search strategies exhausted with filters active — returning 0 results'
750
732
  );
751
733
  searchAttempts.push('no-results');
@@ -774,7 +756,7 @@ async function progressiveSearch(
774
756
  }
775
757
 
776
758
  // No search filters specified — return recent emails (list mode)
777
- console.error('No search filters specified, returning recent emails');
759
+ log.debug('No search filters specified, returning recent emails');
778
760
  searchAttempts.push('recent-emails');
779
761
 
780
762
  const basicParams = {
@@ -790,7 +772,7 @@ async function progressiveSearch(
790
772
  basicParams,
791
773
  maxCount
792
774
  );
793
- console.error(`Recent emails: found ${response.value?.length || 0} results`);
775
+ log.debug(`Recent emails: found ${response.value?.length || 0} results`);
794
776
 
795
777
  response._searchInfo = {
796
778
  attemptsCount: searchAttempts.length,
@@ -1023,6 +1005,7 @@ function narrowResponse(response, matched) {
1023
1005
  if (dropped) {
1024
1006
  delete response['@odata.count'];
1025
1007
  delete response['@odata.nextLink'];
1008
+ delete response.hasMore;
1026
1009
  }
1027
1010
  }
1028
1011
 
@@ -1177,7 +1160,7 @@ async function runClientSideFallback(
1177
1160
  if (matched.length === 0) {
1178
1161
  return null;
1179
1162
  }
1180
- console.error(
1163
+ log.debug(
1181
1164
  `Client-side ${kind} matched ${matched.length} of ${messages.length} scanned messages`
1182
1165
  );
1183
1166
  return {
@@ -1257,7 +1240,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
1257
1240
  const afterDate = new Date(filterTerms.receivedAfter).toISOString();
1258
1241
  filterConditions.push(`receivedDateTime ge ${afterDate}`);
1259
1242
  } catch (_e) {
1260
- console.error(`Invalid receivedAfter date: ${filterTerms.receivedAfter}`);
1243
+ log.debug(`Invalid receivedAfter date: ${filterTerms.receivedAfter}`);
1261
1244
  }
1262
1245
  }
1263
1246
 
@@ -1266,9 +1249,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
1266
1249
  const beforeDate = new Date(filterTerms.receivedBefore).toISOString();
1267
1250
  filterConditions.push(`receivedDateTime le ${beforeDate}`);
1268
1251
  } catch (_e) {
1269
- console.error(
1270
- `Invalid receivedBefore date: ${filterTerms.receivedBefore}`
1271
- );
1252
+ log.debug(`Invalid receivedBefore date: ${filterTerms.receivedBefore}`);
1272
1253
  }
1273
1254
  }
1274
1255
 
@@ -1307,7 +1288,7 @@ function addBooleanFilters(params, filterTerms) {
1307
1288
  const afterDate = new Date(filterTerms.receivedAfter).toISOString();
1308
1289
  filterConditions.push(`receivedDateTime ge ${afterDate}`);
1309
1290
  } catch (_e) {
1310
- console.error(`Invalid receivedAfter date: ${filterTerms.receivedAfter}`);
1291
+ log.debug(`Invalid receivedAfter date: ${filterTerms.receivedAfter}`);
1311
1292
  }
1312
1293
  }
1313
1294
 
@@ -1316,9 +1297,7 @@ function addBooleanFilters(params, filterTerms) {
1316
1297
  const beforeDate = new Date(filterTerms.receivedBefore).toISOString();
1317
1298
  filterConditions.push(`receivedDateTime le ${beforeDate}`);
1318
1299
  } catch (_e) {
1319
- console.error(
1320
- `Invalid receivedBefore date: ${filterTerms.receivedBefore}`
1321
- );
1300
+ log.debug(`Invalid receivedBefore date: ${filterTerms.receivedBefore}`);
1322
1301
  }
1323
1302
  }
1324
1303
 
@@ -1440,7 +1419,7 @@ function formatSearchResults(response, folder, verbosity, searchAllFolders) {
1440
1419
  const meta = {
1441
1420
  returned: (response.value || []).length,
1442
1421
  totalAvailable: response['@odata.count'] || null,
1443
- hasMore: Boolean(response['@odata.nextLink']),
1422
+ hasMore: Boolean(response.hasMore || response['@odata.nextLink']),
1444
1423
  verbosity: verbosity,
1445
1424
  };
1446
1425
 
@@ -1585,14 +1564,9 @@ async function handleSearchByMessageId(args) {
1585
1564
  const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
1586
1565
 
1587
1566
  if (!messageId) {
1588
- return {
1589
- content: [
1590
- {
1591
- type: 'text',
1592
- text: 'Message-ID is required. Provide the full Message-ID header value (e.g., <abc123@example.com>)',
1593
- },
1594
- ],
1595
- };
1567
+ return toolError(
1568
+ 'Message-ID is required. Provide the full Message-ID header value (e.g., <abc123@example.com>)'
1569
+ );
1596
1570
  }
1597
1571
 
1598
1572
  try {
@@ -1613,7 +1587,7 @@ async function handleSearchByMessageId(args) {
1613
1587
  $top: '10', // Usually only one match, but allow for edge cases
1614
1588
  };
1615
1589
 
1616
- console.error(`Searching for Message-ID: ${messageId}`);
1590
+ log.debug(`Searching for Message-ID: ${messageId}`);
1617
1591
 
1618
1592
  const response = await callGraphAPI(
1619
1593
  accessToken,
@@ -1659,24 +1633,10 @@ async function handleSearchByMessageId(args) {
1659
1633
  };
1660
1634
  } catch (error) {
1661
1635
  if (error.message === 'Authentication required') {
1662
- return {
1663
- content: [
1664
- {
1665
- type: 'text',
1666
- text: "Authentication required. Please use the 'authenticate' tool first.",
1667
- },
1668
- ],
1669
- };
1636
+ return authRequiredError();
1670
1637
  }
1671
1638
 
1672
- return {
1673
- content: [
1674
- {
1675
- type: 'text',
1676
- text: `Error searching by Message-ID: ${error.message}`,
1677
- },
1678
- ],
1679
- };
1639
+ return toolError(`Error searching by Message-ID: ${error.message}`);
1680
1640
  }
1681
1641
  }
1682
1642
 
package/email/send.js CHANGED
@@ -10,6 +10,34 @@ const {
10
10
  formatDryRunPreview,
11
11
  } = require('../utils/safety');
12
12
  const { handleGetMailTips } = require('./mail-tips');
13
+ const { toolError, authRequiredError } = require('../utils/tool-error');
14
+
15
+ /**
16
+ * Mail-tip issues that stop a send until the caller passes
17
+ * `acknowledgeWarnings: true` (#272). Custom tips and moderation are
18
+ * returned with the result but do not block.
19
+ */
20
+ const BLOCKING_TIP_LABELS = {
21
+ outOfOffice: 'out of office',
22
+ mailboxFull: 'mailbox full',
23
+ deliveryRestricted: 'delivery restricted',
24
+ external: 'external recipient',
25
+ externalMembers: 'group with external members',
26
+ };
27
+
28
+ const DELIVERY_CAVEAT =
29
+ 'Mail tips are M365-only: personal Outlook.com accounts return none, and no warnings is not proof the email will be delivered.';
30
+
31
+ /**
32
+ * One line per blocking issue, e.g. "- a@b.com: mailbox full".
33
+ * @param {Array<{address: string, type: string}>} issues
34
+ * @returns {string}
35
+ */
36
+ function formatBlockingIssues(issues) {
37
+ return issues
38
+ .map((issue) => `- ${issue.address}: ${BLOCKING_TIP_LABELS[issue.type]}`)
39
+ .join('\n');
40
+ }
13
41
 
14
42
  /**
15
43
  * Send email handler
@@ -27,40 +55,20 @@ async function handleSendEmail(args) {
27
55
  saveToSentItems = true,
28
56
  dryRun = false,
29
57
  checkRecipients = false,
58
+ acknowledgeWarnings = false,
30
59
  } = args;
31
60
 
32
61
  // Validate required parameters
33
62
  if (!to) {
34
- return {
35
- content: [
36
- {
37
- type: 'text',
38
- text: 'Recipient (to) is required.',
39
- },
40
- ],
41
- };
63
+ return toolError('Recipient (to) is required.');
42
64
  }
43
65
 
44
66
  if (!subject) {
45
- return {
46
- content: [
47
- {
48
- type: 'text',
49
- text: 'Subject is required.',
50
- },
51
- ],
52
- };
67
+ return toolError('Subject is required.');
53
68
  }
54
69
 
55
70
  if (!body) {
56
- return {
57
- content: [
58
- {
59
- type: 'text',
60
- text: 'Body content is required.',
61
- },
62
- ],
63
- };
71
+ return toolError('Body content is required.');
64
72
  }
65
73
 
66
74
  try {
@@ -122,7 +130,9 @@ async function handleSendEmail(args) {
122
130
  saveToSentItems,
123
131
  };
124
132
 
125
- // Pre-send mail tips check
133
+ // Pre-send mail tips check. The tips are kept out of emailObject: only
134
+ // Graph message properties may go in the sendMail payload (#272).
135
+ let mailTips = null;
126
136
  if (checkRecipients) {
127
137
  const allAddresses = allRecipients.map((r) => r.emailAddress.address);
128
138
  const tipsResult = await handleGetMailTips({
@@ -130,27 +140,52 @@ async function handleSendEmail(args) {
130
140
  });
131
141
 
132
142
  const tipsText = tipsResult.content[0]?.text || '';
143
+ const blocking = (tipsResult._meta?.issues || []).filter(
144
+ (issue) => BLOCKING_TIP_LABELS[issue.type]
145
+ );
133
146
 
134
147
  // In dry-run mode, always include mail tips in the preview
135
148
  if (dryRun) {
136
149
  const preview = formatDryRunPreview(emailObject);
150
+ const refusalNote =
151
+ blocking.length > 0 && !acknowledgeWarnings
152
+ ? `\n\nNote: a real send would be refused until acknowledgeWarnings: true is passed, because of:\n${formatBlockingIssues(blocking)}`
153
+ : '';
137
154
  return {
138
155
  content: [
139
156
  {
140
157
  type: 'text',
141
- text: `${tipsText}\n\n---\n\n${preview.content[0].text}`,
158
+ text: `${tipsText}\n\n---\n\n${preview.content[0].text}${refusalNote}`,
142
159
  },
143
160
  ],
144
161
  _meta: { mailTips: tipsResult._meta },
145
162
  };
146
163
  }
147
164
 
148
- // In send mode, warn if there are issues but proceed
149
- if (tipsResult._meta?.warningCount > 0) {
150
- // Store tips to prepend to send response
151
- emailObject._mailTipsText = tipsText;
152
- emailObject._mailTipsMeta = tipsResult._meta;
165
+ // The caller asked for a check, so a failed check stops the send.
166
+ if (tipsResult.isError) {
167
+ return toolError(
168
+ `Email not sent: the recipient check failed.\n\n${tipsText}`,
169
+ tipsText.includes('Next step:')
170
+ ? {}
171
+ : {
172
+ nextStep:
173
+ 'Retry the call, or call send-email without checkRecipients to send without the check.',
174
+ }
175
+ );
153
176
  }
177
+
178
+ if (blocking.length > 0 && !acknowledgeWarnings) {
179
+ return toolError(
180
+ `Email not sent: the recipient check flagged ${blocking.length} issue(s):\n${formatBlockingIssues(blocking)}\n\n${tipsText}`,
181
+ {
182
+ nextStep:
183
+ 'Show these warnings to the user. If they still want to send, call send-email again with the same arguments plus acknowledgeWarnings: true; otherwise change the recipients.',
184
+ }
185
+ );
186
+ }
187
+
188
+ mailTips = { text: tipsText, meta: tipsResult._meta, blocking };
154
189
  }
155
190
 
156
191
  // Dry-run mode: return preview without sending
@@ -168,34 +203,33 @@ async function handleSendEmail(args) {
168
203
  // Make API call to send email
169
204
  await callGraphAPI(accessToken, 'POST', 'me/sendMail', emailObject);
170
205
 
206
+ const sentText = `Email sent successfully!\n\nSubject: ${subject}\nRecipients: ${toRecipients.length}${ccRecipients.length > 0 ? ` + ${ccRecipients.length} CC` : ''}${bccRecipients.length > 0 ? ` + ${bccRecipients.length} BCC` : ''}\nMessage Length: ${body.length} characters`;
207
+
208
+ if (!mailTips) {
209
+ return { content: [{ type: 'text', text: sentText }] };
210
+ }
211
+
212
+ // Empty tips already carry their own M365-only note; don't repeat it.
213
+ const caveat = mailTips.meta?.allEmpty ? '' : `\n\n${DELIVERY_CAVEAT}`;
214
+ const acknowledged =
215
+ mailTips.blocking.length > 0
216
+ ? `\n\nSent with ${mailTips.blocking.length} acknowledged warning(s):\n${formatBlockingIssues(mailTips.blocking)}`
217
+ : '';
171
218
  return {
172
219
  content: [
173
220
  {
174
221
  type: 'text',
175
- text: `Email sent successfully!\n\nSubject: ${subject}\nRecipients: ${toRecipients.length}${ccRecipients.length > 0 ? ` + ${ccRecipients.length} CC` : ''}${bccRecipients.length > 0 ? ` + ${bccRecipients.length} BCC` : ''}\nMessage Length: ${body.length} characters`,
222
+ text: `${sentText}${acknowledged}\n\n---\n\n${mailTips.text}${caveat}`,
176
223
  },
177
224
  ],
225
+ _meta: { mailTips: mailTips.meta },
178
226
  };
179
227
  } catch (error) {
180
228
  if (error.message === 'Authentication required') {
181
- return {
182
- content: [
183
- {
184
- type: 'text',
185
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
186
- },
187
- ],
188
- };
229
+ return authRequiredError();
189
230
  }
190
231
 
191
- return {
192
- content: [
193
- {
194
- type: 'text',
195
- text: `Error sending email: ${error.message}`,
196
- },
197
- ],
198
- };
232
+ return toolError(`Error sending email: ${error.message}`);
199
233
  }
200
234
  }
201
235
 
package/folder/create.js CHANGED
@@ -5,6 +5,8 @@ const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
6
  const { resolveFolder, listChildFolders } = require('./resolve');
7
7
  const { buildMailboxPrefix } = require('../utils/mailbox');
8
+ const { toolError, authRequiredError } = require('../utils/tool-error');
9
+ const { log } = require('../utils/logger');
8
10
 
9
11
  /**
10
12
  * Create folder handler
@@ -18,14 +20,7 @@ async function handleCreateFolder(args) {
18
20
  const sharedMailbox = args.sharedMailbox || args.email || null;
19
21
 
20
22
  if (!folderName) {
21
- return {
22
- content: [
23
- {
24
- type: 'text',
25
- text: 'Folder name is required.',
26
- },
27
- ],
28
- };
23
+ return toolError('Folder name is required.');
29
24
  }
30
25
 
31
26
  try {
@@ -52,24 +47,10 @@ async function handleCreateFolder(args) {
52
47
  };
53
48
  } catch (error) {
54
49
  if (error.message === 'Authentication required') {
55
- return {
56
- content: [
57
- {
58
- type: 'text',
59
- text: "Authentication required. Please use the 'authenticate' tool first.",
60
- },
61
- ],
62
- };
50
+ return authRequiredError();
63
51
  }
64
52
 
65
- return {
66
- content: [
67
- {
68
- type: 'text',
69
- text: `Error creating folder: ${error.message}`,
70
- },
71
- ],
72
- };
53
+ return toolError(`Error creating folder: ${error.message}`);
73
54
  }
74
55
  }
75
56
 
@@ -153,7 +134,7 @@ async function createMailFolder(accessToken, folderName, parentSpec) {
153
134
  };
154
135
  }
155
136
  } catch (error) {
156
- console.error(`Error creating folder "${folderName}": ${error.message}`);
137
+ log.debug(`Error creating folder "${folderName}": ${error.message}`);
157
138
  throw error;
158
139
  }
159
140
  }