@littlebearapps/outlook-assistant 3.7.2 → 3.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/contacts/index.js CHANGED
@@ -76,13 +76,10 @@ function formatContact(contact, verbosity = 'standard') {
76
76
  lines.push(`**Phone**: ${phones.join(' | ')}`);
77
77
  }
78
78
 
79
- // Company info
80
- if (contact.companyName || contact.jobTitle) {
81
- const company = [contact.jobTitle, contact.companyName]
82
- .filter(Boolean)
83
- .join(' at ');
84
- lines.push(`**Company**: ${company}`);
85
- }
79
+ // Job title and company info — F-40: previously squashed into a
80
+ // single 'Company' label which mislabeled jobTitle-only contacts.
81
+ if (contact.jobTitle) lines.push(`**Job Title**: ${contact.jobTitle}`);
82
+ if (contact.companyName) lines.push(`**Company**: ${contact.companyName}`);
86
83
  }
87
84
 
88
85
  // Full verbosity extras
@@ -130,6 +127,8 @@ async function handleListContacts(args) {
130
127
  const verbosity = args.outputVerbosity || 'standard';
131
128
  const folder = args.folder || null; // null = default contacts folder
132
129
 
130
+ const skip = args.skip || 0;
131
+
133
132
  try {
134
133
  const accessToken = await ensureAuthenticated();
135
134
 
@@ -142,7 +141,9 @@ async function handleListContacts(args) {
142
141
  $select: fields.join(','),
143
142
  $top: count,
144
143
  $orderby: 'displayName',
144
+ $count: 'true', // Surface true total so callers know if more pages exist
145
145
  };
146
+ if (skip > 0) queryParams.$skip = skip;
146
147
 
147
148
  const response = await callGraphAPI(
148
149
  accessToken,
@@ -152,10 +153,29 @@ async function handleListContacts(args) {
152
153
  queryParams
153
154
  );
154
155
  const contacts = response.value || [];
156
+ const totalAvailable = response['@odata.count'];
157
+ const hasMore = Boolean(response['@odata.nextLink']);
155
158
 
156
159
  const output = [];
157
160
  output.push(`# Contacts\n`);
158
- output.push(`**Total**: ${contacts.length}`);
161
+ if (typeof totalAvailable === 'number') {
162
+ output.push(
163
+ `**Showing**: ${contacts.length} of ${totalAvailable}${skip > 0 ? ` (offset ${skip})` : ''}`
164
+ );
165
+ } else {
166
+ output.push(`**Showing**: ${contacts.length}`);
167
+ }
168
+ // F-22: surface pagination cue when more results exist so callers
169
+ // know to ask for the next page instead of assuming "Total: 50"
170
+ // is the entire address book.
171
+ if (
172
+ hasMore ||
173
+ (totalAvailable && contacts.length + skip < totalAvailable)
174
+ ) {
175
+ output.push(
176
+ `**More available**: pass \`skip: ${skip + contacts.length}\` to fetch the next page (or \`count\` to raise the page size up to 100).`
177
+ );
178
+ }
159
179
  output.push('');
160
180
 
161
181
  contacts.forEach((contact) => {
@@ -164,7 +184,13 @@ async function handleListContacts(args) {
164
184
 
165
185
  return {
166
186
  content: [{ type: 'text', text: output.join('\n') }],
167
- _meta: { count: contacts.length },
187
+ _meta: {
188
+ count: contacts.length,
189
+ ...(typeof totalAvailable === 'number' && {
190
+ totalAvailable,
191
+ }),
192
+ hasMore,
193
+ },
168
194
  };
169
195
  } catch (error) {
170
196
  if (error.message === 'Authentication required') {
@@ -340,13 +366,32 @@ async function handleGetContact(args) {
340
366
  * Create contact handler
341
367
  */
342
368
  async function handleCreateContact(args) {
343
- const { displayName, email, mobilePhone, companyName, jobTitle, notes } =
344
- args;
345
-
346
- if (!displayName && !email) {
369
+ const {
370
+ displayName,
371
+ firstName,
372
+ lastName,
373
+ email,
374
+ emails,
375
+ mobilePhone,
376
+ companyName,
377
+ jobTitle,
378
+ notes,
379
+ } = args;
380
+
381
+ // F-39: derive displayName from firstName/lastName if not provided.
382
+ const resolvedDisplayName =
383
+ displayName ||
384
+ [firstName, lastName].filter(Boolean).join(' ') ||
385
+ email ||
386
+ (Array.isArray(emails) && emails[0]);
387
+
388
+ if (!resolvedDisplayName && !email && !(emails && emails.length > 0)) {
347
389
  return {
348
390
  content: [
349
- { type: 'text', text: 'At least displayName or email is required.' },
391
+ {
392
+ type: 'text',
393
+ text: 'At least displayName, firstName/lastName, email, or emails is required.',
394
+ },
350
395
  ],
351
396
  };
352
397
  }
@@ -356,24 +401,39 @@ async function handleCreateContact(args) {
356
401
 
357
402
  const contactData = {};
358
403
 
359
- if (displayName) contactData.displayName = displayName;
360
- if (email) {
361
- contactData.emailAddresses = [
362
- { address: email, name: displayName || email },
363
- ];
404
+ if (resolvedDisplayName) contactData.displayName = resolvedDisplayName;
405
+
406
+ // F-39: prefer explicit givenName/surname when supplied; otherwise
407
+ // derive from displayName if it's a multi-word string.
408
+ if (firstName) contactData.givenName = firstName;
409
+ if (lastName) contactData.surname = lastName;
410
+ if (
411
+ !contactData.givenName &&
412
+ !contactData.surname &&
413
+ displayName &&
414
+ displayName.includes(' ')
415
+ ) {
416
+ const parts = displayName.split(' ');
417
+ contactData.givenName = parts[0];
418
+ contactData.surname = parts.slice(1).join(' ');
419
+ }
420
+
421
+ // Email: accept either `email` (single) or `emails` (array).
422
+ const allEmails = [];
423
+ if (email) allEmails.push(email);
424
+ if (Array.isArray(emails)) allEmails.push(...emails);
425
+ if (allEmails.length > 0) {
426
+ contactData.emailAddresses = allEmails.map((addr) => ({
427
+ address: addr,
428
+ name: resolvedDisplayName || addr,
429
+ }));
364
430
  }
431
+
365
432
  if (mobilePhone) contactData.mobilePhone = mobilePhone;
366
433
  if (companyName) contactData.companyName = companyName;
367
434
  if (jobTitle) contactData.jobTitle = jobTitle;
368
435
  if (notes) contactData.personalNotes = notes;
369
436
 
370
- // Parse name into given/surname if provided
371
- if (displayName && displayName.includes(' ')) {
372
- const parts = displayName.split(' ');
373
- contactData.givenName = parts[0];
374
- contactData.surname = parts.slice(1).join(' ');
375
- }
376
-
377
437
  const contact = await callGraphAPI(
378
438
  accessToken,
379
439
  'POST',
@@ -640,6 +700,11 @@ const contactsTools = [
640
700
  description:
641
701
  'Number of results (action=list default: 50, action=search default: 25)',
642
702
  },
703
+ skip: {
704
+ type: 'integer',
705
+ description:
706
+ 'Pagination offset for action=list (default: 0). Use the value suggested by the previous page response.',
707
+ },
643
708
  folder: {
644
709
  type: 'string',
645
710
  description: 'Contact folder ID (action=list)',
@@ -666,10 +731,26 @@ const contactsTools = [
666
731
  type: 'string',
667
732
  description: 'Full name (action=create/update)',
668
733
  },
734
+ firstName: {
735
+ type: 'string',
736
+ description:
737
+ 'Given name (action=create/update). Maps to Graph `givenName`. If displayName not provided, will be combined with lastName.',
738
+ },
739
+ lastName: {
740
+ type: 'string',
741
+ description:
742
+ 'Surname (action=create/update). Maps to Graph `surname`.',
743
+ },
669
744
  email: {
670
745
  type: 'string',
671
746
  description: 'Primary email address (action=create/update)',
672
747
  },
748
+ emails: {
749
+ type: 'array',
750
+ items: { type: 'string' },
751
+ description:
752
+ 'Multiple email addresses (action=create/update). First entry is primary.',
753
+ },
673
754
  mobilePhone: {
674
755
  type: 'string',
675
756
  description: 'Mobile phone number (action=create/update)',
@@ -687,6 +768,7 @@ const contactsTools = [
687
768
  description: 'Personal notes (action=create/update)',
688
769
  },
689
770
  },
771
+ additionalProperties: false,
690
772
  required: [],
691
773
  },
692
774
  handler: async (args) => {
@@ -703,8 +785,16 @@ const contactsTools = [
703
785
  case 'delete':
704
786
  return handleDeleteContact(args);
705
787
  case 'list':
706
- default:
707
788
  return handleListContacts(args);
789
+ default:
790
+ return {
791
+ content: [
792
+ {
793
+ type: 'text',
794
+ text: `Unknown action '${action}'. Valid actions: list, search, get, create, update, delete.`,
795
+ },
796
+ ],
797
+ };
708
798
  }
709
799
  },
710
800
  },
@@ -729,6 +819,7 @@ const contactsTools = [
729
819
  description: 'Maximum results to return (default: 25, max: 50)',
730
820
  },
731
821
  },
822
+ additionalProperties: false,
732
823
  required: ['query'],
733
824
  },
734
825
  handler: handleSearchPeople,
@@ -4,6 +4,7 @@
4
4
  */
5
5
  const _https = require('https'); // Reserved for future use
6
6
  const fs = require('fs');
7
+ const os = require('os');
7
8
  const path = require('path');
8
9
  const _config = require('../config'); // Reserved for future use
9
10
  const { callGraphAPI } = require('../utils/graph-api');
@@ -110,7 +111,12 @@ async function handleListAttachments(args) {
110
111
  * @returns {object} - MCP response with download result
111
112
  */
112
113
  async function handleDownloadAttachment(args) {
113
- const { messageId, attachmentId, savePath } = args;
114
+ // F-19: accept both `outputDir` (canonical) and `savePath` (legacy
115
+ // alias). Previously the silent-ignore-unknown-param behaviour
116
+ // dropped `outputDir` and fell through to cwd, polluting the source
117
+ // tree with downloaded files.
118
+ const { messageId, attachmentId } = args;
119
+ const savePath = args.outputDir || args.savePath;
114
120
 
115
121
  if (!messageId || !attachmentId) {
116
122
  return {
@@ -167,8 +173,12 @@ async function handleDownloadAttachment(args) {
167
173
  };
168
174
  }
169
175
 
170
- // Determine save location
171
- const outputDir = savePath || process.cwd();
176
+ // Determine save location. F-19: default to os.tmpdir() instead
177
+ // of cwd so attachments don't silently land in the source tree
178
+ // when the caller forgets to pass outputDir. Auto-create the
179
+ // target directory.
180
+ const outputDir = savePath || os.tmpdir();
181
+ fs.mkdirSync(outputDir, { recursive: true });
172
182
  const outputPath = path.join(outputDir, filename);
173
183
 
174
184
  // Decode base64 and save to file
@@ -372,7 +372,10 @@ async function handleGetConversation(args) {
372
372
  async function handleExportConversation(args) {
373
373
  const conversationId = args.conversationId;
374
374
  const format = (args.format || 'markdown').toLowerCase();
375
- const outputDir = args.outputDir;
375
+ // F-29: default outputDir to os.tmpdir() to match target=message
376
+ // behaviour. Previously conversation export rejected calls without
377
+ // outputDir, inconsistent with the other export targets.
378
+ const outputDir = args.outputDir || require('os').tmpdir();
376
379
  const _includeAttachments = args.includeAttachments !== false;
377
380
  const order = args.order || 'chronological';
378
381
 
@@ -382,12 +385,6 @@ async function handleExportConversation(args) {
382
385
  };
383
386
  }
384
387
 
385
- if (!outputDir) {
386
- return {
387
- content: [{ type: 'text', text: 'Output directory is required.' }],
388
- };
389
- }
390
-
391
388
  const validFormats = ['eml', 'mbox', 'markdown', 'json', 'html', 'csv'];
392
389
  if (!validFormats.includes(format)) {
393
390
  return {
package/email/delta.js CHANGED
@@ -90,6 +90,11 @@ async function handleListEmailsDelta(args) {
90
90
  const isInitialSync = !deltaToken;
91
91
  const hasMoreChanges = Boolean(nextLink);
92
92
  const newDeltaToken = deltaLink || nextLink;
93
+ // F-15: nextLink is a continuation token (more pages of the same
94
+ // sync), not a delta token. The real delta token only emits once
95
+ // the initial sync finishes paging. Distinguish them in output so
96
+ // callers know what they're storing.
97
+ const tokenIsContinuation = !deltaLink && Boolean(nextLink);
93
98
 
94
99
  // Format output based on verbosity
95
100
  let resultText;
@@ -101,7 +106,10 @@ async function handleListEmailsDelta(args) {
101
106
  resultText += `| Type | ${isInitialSync ? 'Initial' : 'Incremental'} |\n`;
102
107
  resultText += `| More | ${hasMoreChanges ? 'Yes' : 'No'} |\n`;
103
108
  if (newDeltaToken) {
104
- resultText += `\n**Delta Token** (save for next call):\n\`\`\`\n${newDeltaToken}\n\`\`\`\n`;
109
+ const label = tokenIsContinuation
110
+ ? 'Continuation Token (more pages — call again to keep paging)'
111
+ : 'Delta Token (save for next sync call)';
112
+ resultText += `\n**${label}**:\n\`\`\`\n${newDeltaToken}\n\`\`\`\n`;
105
113
  }
106
114
  } else {
107
115
  resultText = `## Delta Sync ${isInitialSync ? '(Initial)' : '(Incremental)'}\n\n`;
@@ -142,14 +150,19 @@ async function handleListEmailsDelta(args) {
142
150
 
143
151
  // Pagination info
144
152
  if (hasMoreChanges) {
145
- resultText += `\n### More Changes Available\n`;
146
- resultText += `Use the deltaToken below to fetch next page.\n`;
153
+ resultText += `\n### More Pages Available\n`;
154
+ resultText += `This page returned a continuation token. Call \`search-emails deltaMode=true deltaToken=<token>\` again to fetch the next page. The real delta token only emits once paging completes.\n`;
147
155
  }
148
156
 
149
- // Delta token
157
+ // Token (delta or continuation)
150
158
  if (newDeltaToken) {
151
- resultText += `\n### Delta Token\n`;
152
- resultText += `**Save this token for next sync call:**\n\`\`\`\n${newDeltaToken}\n\`\`\`\n`;
159
+ if (tokenIsContinuation) {
160
+ resultText += `\n### Continuation Token\n`;
161
+ resultText += `**More pages remain. Pass this back to keep paging:**\n\`\`\`\n${newDeltaToken}\n\`\`\`\n`;
162
+ } else {
163
+ resultText += `\n### Delta Token\n`;
164
+ resultText += `**Save this token for next sync call:**\n\`\`\`\n${newDeltaToken}\n\`\`\`\n`;
165
+ }
153
166
  }
154
167
  }
155
168
 
@@ -167,6 +180,7 @@ async function handleListEmailsDelta(args) {
167
180
  hasMoreChanges: hasMoreChanges,
168
181
  changesSummary: changesSummary,
169
182
  deltaToken: newDeltaToken,
183
+ tokenType: tokenIsContinuation ? 'continuation' : 'delta',
170
184
  },
171
185
  };
172
186
  } catch (error) {
package/email/export.js CHANGED
@@ -37,7 +37,10 @@ const EXPORT_FORMATS = {
37
37
  async function handleExportEmail(args) {
38
38
  const emailId = args.id;
39
39
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
40
- const savePath = args.savePath;
40
+ // F-27: accept `outputDir` (canonical) and `savePath` (legacy alias).
41
+ // Previously single-message exports ignored outputDir entirely and
42
+ // hardcoded os.tmpdir(), inconsistent with target=messages.
43
+ const savePath = args.outputDir || args.savePath;
41
44
  const includeAttachments = args.includeAttachments !== false;
42
45
 
43
46
  if (!emailId) {
@@ -116,17 +119,31 @@ async function handleExportEmail(args) {
116
119
  } else if (format === EXPORT_FORMATS.CSV) {
117
120
  // CSV export - email metadata
118
121
  content = formatEmailsAsCSV(email);
122
+ } else if (format === 'mbox' || format === 'html') {
123
+ // F-26: clarify that mbox/html are conversation-only formats so
124
+ // callers don't infer the format itself is unsupported.
125
+ return {
126
+ content: [
127
+ {
128
+ type: 'text',
129
+ text: `Format '${format}' is only supported for target=conversation. For target=message use one of: ${Object.values(EXPORT_FORMATS).join(', ')}.`,
130
+ },
131
+ ],
132
+ };
119
133
  } else {
120
134
  return {
121
135
  content: [
122
136
  {
123
137
  type: 'text',
124
- text: `Unknown format: ${format}. Supported: ${Object.values(EXPORT_FORMATS).join(', ')}`,
138
+ text: `Unknown format: ${format}. Supported for target=message: ${Object.values(EXPORT_FORMATS).join(', ')}.`,
125
139
  },
126
140
  ],
127
141
  };
128
142
  }
129
143
 
144
+ // Auto-create the parent directory so callers don't have to pre-mkdir.
145
+ fs.mkdirSync(path.dirname(finalPath), { recursive: true });
146
+
130
147
  // Save main file
131
148
  fs.writeFileSync(finalPath, content, 'utf8');
132
149
 
@@ -207,7 +224,13 @@ async function handleExportEmail(args) {
207
224
  */
208
225
  async function handleBatchExportEmails(args) {
209
226
  const emailIds = args.emailIds || [];
210
- const searchQuery = args.searchQuery || {};
227
+ // F-28: accept `query` as a top-level string alias for
228
+ // `searchQuery: { subject }`. Lets callers use the same `query`
229
+ // word they already know from search-emails.
230
+ const searchQuery = { ...(args.searchQuery || {}) };
231
+ if (args.query && !searchQuery.subject) {
232
+ searchQuery.subject = args.query;
233
+ }
211
234
  const format = (args.format || EXPORT_FORMATS.MARKDOWN).toLowerCase();
212
235
  const outputDir = args.outputDir;
213
236
  const includeAttachments = args.includeAttachments === true; // Default false for batch
package/email/index.js CHANGED
@@ -137,6 +137,7 @@ const emailTools = [
137
137
  'Include email headers for each message (conversationId only)',
138
138
  },
139
139
  },
140
+ additionalProperties: false,
140
141
  required: [],
141
142
  },
142
143
  handler: async (args) => {
@@ -223,6 +224,7 @@ const emailTools = [
223
224
  'Return raw JSON instead of Markdown (headersMode only, default: false)',
224
225
  },
225
226
  },
227
+ additionalProperties: false,
226
228
  required: ['id'],
227
229
  },
228
230
  handler: async (args) => {
@@ -286,6 +288,7 @@ const emailTools = [
286
288
  'Check recipients for out-of-office, mailbox full, delivery restrictions before sending (default: false). Combine with dryRun=true for pre-send review.',
287
289
  },
288
290
  },
291
+ additionalProperties: false,
289
292
  required: ['to', 'subject', 'body'],
290
293
  },
291
294
  handler: handleSendEmail,
@@ -364,6 +367,7 @@ const emailTools = [
364
367
  'Check recipients for out-of-office, delivery restrictions before saving (action=create, default: false)',
365
368
  },
366
369
  },
370
+ additionalProperties: false,
367
371
  required: ['action'],
368
372
  },
369
373
  handler: handleDraft,
@@ -408,6 +412,7 @@ const emailTools = [
408
412
  description: 'Start date/time for follow-up, ISO 8601 (action=flag)',
409
413
  },
410
414
  },
415
+ additionalProperties: false,
411
416
  required: ['action'],
412
417
  },
413
418
  handler: async (args) => {
@@ -473,12 +478,18 @@ const emailTools = [
473
478
  type: 'string',
474
479
  description: 'Attachment ID (action=view/download, required)',
475
480
  },
481
+ outputDir: {
482
+ type: 'string',
483
+ description:
484
+ 'Directory to save file (action=download, default: system tmpdir). Auto-created if missing.',
485
+ },
476
486
  savePath: {
477
487
  type: 'string',
478
488
  description:
479
- 'Directory to save file (action=download, default: current directory)',
489
+ 'DEPRECATED alias for `outputDir`. Will be removed in v3.8.0.',
480
490
  },
481
491
  },
492
+ additionalProperties: false,
482
493
  required: ['messageId'],
483
494
  },
484
495
  handler: async (args) => {
@@ -489,8 +500,16 @@ const emailTools = [
489
500
  case 'download':
490
501
  return handleDownloadAttachment(args);
491
502
  case 'list':
492
- default:
493
503
  return handleListAttachments(args);
504
+ default:
505
+ return {
506
+ content: [
507
+ {
508
+ type: 'text',
509
+ text: `Unknown action '${action}'. Valid actions: list, view, download.`,
510
+ },
511
+ ],
512
+ };
494
513
  }
495
514
  },
496
515
  },
@@ -521,7 +540,7 @@ const emailTools = [
521
540
  type: 'string',
522
541
  enum: ['mime', 'eml', 'markdown', 'json', 'mbox', 'html', 'csv'],
523
542
  description:
524
- 'Export format (target=message: mime/eml/markdown/json/csv, target=conversation: eml/mbox/markdown/json/html/csv)',
543
+ 'Export format. Valid values vary by target: target=message accepts mime/eml/markdown/json/csv (mbox and html are conversation-only). target=conversation accepts eml/mbox/markdown/json/html/csv. target=messages (batch) accepts markdown/json/csv. mime is an alias for eml (same RFC822 bytes, .eml extension on disk).',
525
544
  },
526
545
  savePath: {
527
546
  type: 'string',
@@ -551,6 +570,11 @@ const emailTools = [
551
570
  description:
552
571
  'Search query to find emails (target=messages, alternative to emailIds)',
553
572
  },
573
+ query: {
574
+ type: 'string',
575
+ description:
576
+ 'Free-text search shortcut (target=messages). Equivalent to passing searchQuery: { subject: <query> }. Convenience alias for callers used to search-emails.',
577
+ },
554
578
  outputDir: {
555
579
  type: 'string',
556
580
  description:
@@ -581,6 +605,7 @@ const emailTools = [
581
605
  description: 'Max content size in bytes (target=mime, default: 1MB)',
582
606
  },
583
607
  },
608
+ additionalProperties: false,
584
609
  required: [],
585
610
  },
586
611
  handler: async (args) => {
@@ -593,8 +618,16 @@ const emailTools = [
593
618
  case 'mime':
594
619
  return handleGetMimeContent(args);
595
620
  case 'message':
596
- default:
597
621
  return handleExportEmail(args);
622
+ default:
623
+ return {
624
+ content: [
625
+ {
626
+ type: 'text',
627
+ text: `Unknown export target '${target}'. Valid targets: message, messages, conversation, mime.`,
628
+ },
629
+ ],
630
+ };
598
631
  }
599
632
  },
600
633
  },
@@ -630,6 +663,7 @@ const emailTools = [
630
663
  'Comma-separated tip types to request (default: all). Options: automaticReplies, mailboxFullStatus, customMailTip, externalMemberCount, totalMemberCount, maxMessageSize, deliveryRestriction, moderationStatus, recipientScope, recipientSuggestions',
631
664
  },
632
665
  },
666
+ additionalProperties: false,
633
667
  required: ['recipients'],
634
668
  },
635
669
  handler: handleGetMailTips,
package/email/list.js CHANGED
@@ -44,7 +44,12 @@ function getFieldPresetForVerbosity(verbosity) {
44
44
  */
45
45
  async function handleListEmails(args) {
46
46
  const folder = args.folder || 'inbox';
47
- const requestedCount = args.count || DEFAULT_LIMITS.listEmails; // Default 25 (was 10)
47
+ // F-17: accept `maxResults` as an alias for `count` here too. The
48
+ // search-mode handler already does this; list-mode used `args.count`
49
+ // only, so callers passing `maxResults=5` to a non-search list call
50
+ // saw their override silently ignored.
51
+ const requestedCount =
52
+ args.count ?? args.maxResults ?? DEFAULT_LIMITS.listEmails;
48
53
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
49
54
 
50
55
  try {
@@ -203,7 +203,7 @@ async function handleGetMailTips(args) {
203
203
  content: [
204
204
  {
205
205
  type: 'text',
206
- text: 'No mail tips returned for the specified recipients.',
206
+ text: 'No mail tips returned. Mail Tips is M365-only — personal Outlook.com accounts return empty responses, so recipient validation is unavailable on this account.',
207
207
  },
208
208
  ],
209
209
  };
@@ -211,15 +211,39 @@ async function handleGetMailTips(args) {
211
211
 
212
212
  const { formatted, warningCount } = formatMailTips(mailTips);
213
213
 
214
+ // F-23: Detect a "fully empty" tips response — every recipient
215
+ // returned with no actionable fields. Personal Outlook.com
216
+ // accounts surface this as a successful empty response rather
217
+ // than a feature-unsupported error, leading to false confidence
218
+ // when callers see "No issues detected".
219
+ const allEmpty = mailTips.every((tip) => {
220
+ const hasContent =
221
+ tip.recipientNotFound ||
222
+ tip.mailboxFull ||
223
+ tip.deliveryRestricted ||
224
+ tip.isModerated ||
225
+ tip.automaticReplies?.message ||
226
+ tip.maxMessageSize ||
227
+ tip.totalMemberCount ||
228
+ tip.customMailTip;
229
+ return !hasContent;
230
+ });
231
+
214
232
  let header = `# Mail Tips\n\n`;
215
233
  header += `**Recipients checked**: ${mailTips.length}\n`;
216
- header += `**Warnings**: ${warningCount}\n\n`;
234
+ header += `**Warnings**: ${warningCount}\n`;
235
+ if (allEmpty && warningCount === 0) {
236
+ header +=
237
+ '\n**Note**: Graph returned no actionable mail tips for any recipient. This usually means Mail Tips is not supported on the connected account (M365-only feature) — "No issues detected" below means "no warnings flagged by Graph", NOT "validated as deliverable".\n';
238
+ }
239
+ header += '\n';
217
240
 
218
241
  return {
219
242
  content: [{ type: 'text', text: header + formatted }],
220
243
  _meta: {
221
244
  recipientCount: mailTips.length,
222
245
  warningCount,
246
+ allEmpty,
223
247
  },
224
248
  };
225
249
  } catch (error) {