@littlebearapps/outlook-assistant 3.8.2 → 3.9.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.
package/email/search.js CHANGED
@@ -14,6 +14,22 @@ const {
14
14
  } = require('../utils/response-formatter');
15
15
  const { getEmailFields } = require('../utils/field-presets');
16
16
 
17
+ // Upper bound on how many recent messages the client-side fallback scans
18
+ // before giving up. Deliberately DECOUPLED from the requested result count so
19
+ // that broadening the scope (searchAllFolders → me/messages) doesn't shrink
20
+ // coverage — a maxCount*5 window spread across every folder used to drop inbox
21
+ // matches that an inbox-only window retained. Surfaced as `truncated` in
22
+ // searchMetadata when the budget is exhausted. Override with
23
+ // OUTLOOK_SEARCH_SCAN_LIMIT. (#169 V37-F-2)
24
+ const _parsedScanLimit = Number.parseInt(
25
+ process.env.OUTLOOK_SEARCH_SCAN_LIMIT || '',
26
+ 10
27
+ );
28
+ const CLIENT_SCAN_LIMIT =
29
+ Number.isSafeInteger(_parsedScanLimit) && _parsedScanLimit > 0
30
+ ? Math.min(_parsedScanLimit, 5000)
31
+ : 500;
32
+
17
33
  /**
18
34
  * Search emails handler
19
35
  * @param {object} args - Tool arguments
@@ -40,7 +56,7 @@ async function handleSearchEmails(args) {
40
56
  const requestedCount =
41
57
  args.count ?? args.maxResults ?? DEFAULT_LIMITS.searchEmails;
42
58
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
43
- const query = args.query || '';
59
+ const query = (args.query || '').trim();
44
60
  const from = args.from || '';
45
61
  const to = args.to || '';
46
62
  const subject = args.subject || '';
@@ -49,7 +65,11 @@ async function handleSearchEmails(args) {
49
65
  const receivedAfter = args.receivedAfter || '';
50
66
  const receivedBefore = args.receivedBefore || '';
51
67
  const searchAllFolders = args.searchAllFolders || false;
52
- const kqlQuery = args.kqlQuery || ''; // Raw KQL for advanced users
68
+ // `searchExpression` is the accurate name — it's a Microsoft Graph $search
69
+ // expression, not full KQL. `kqlQuery` is retained as a deprecated alias.
70
+ // Trim so a whitespace-only value doesn't send `$search: '""'`. (#169)
71
+ const kqlQuery =
72
+ (args.searchExpression || '').trim() || (args.kqlQuery || '').trim();
53
73
 
54
74
  // Select fields based on verbosity
55
75
  const selectFields = getEmailFields(
@@ -80,7 +100,9 @@ async function handleSearchEmails(args) {
80
100
  selectFields
81
101
  );
82
102
 
83
- return formatSearchResults(response, folder, verbosity);
103
+ // Label the scope accurately — a cross-folder search is not "inbox". (#169)
104
+ const scopeLabel = searchAllFolders ? 'all folders' : folder;
105
+ return formatSearchResults(response, scopeLabel, verbosity);
84
106
  } catch (error) {
85
107
  // Handle authentication errors
86
108
  if (error.message === 'Authentication required') {
@@ -126,6 +148,9 @@ async function progressiveSearch(
126
148
  ) {
127
149
  // Track search strategies attempted
128
150
  const searchAttempts = [];
151
+ // Populated by the client-side fallback with its scan coverage so the final
152
+ // no-results response can still disclose whether the scan was bounded. (#169)
153
+ const scanState = {};
129
154
 
130
155
  // 0. If raw KQL query provided, use it directly. The kqlQuery branch
131
156
  // *terminates* — if Graph returns 0 (or throws), we surface that
@@ -260,151 +285,102 @@ async function progressiveSearch(
260
285
  const searchPriority = ['from', 'to', 'subject', 'query'];
261
286
 
262
287
  for (const term of searchPriority) {
263
- if (searchTerms[term]) {
264
- try {
265
- console.error(
266
- `Attempting search with only ${term}: "${searchTerms[term]}"`
267
- );
268
- searchAttempts.push(`single-term-${term}`);
288
+ if (!searchTerms[term]) {
289
+ continue;
290
+ }
269
291
 
270
- const simplifiedParams = {
271
- $top: Math.min(50, maxCount),
272
- $select: selectFields,
273
- };
292
+ // 2a. Server-side single-term attempt (isolated try/catch — a failure
293
+ // here just falls through to the one client-side fallback below).
294
+ try {
295
+ console.error(
296
+ `Attempting search with only ${term}: "${searchTerms[term]}"`
297
+ );
298
+ searchAttempts.push(`single-term-${term}`);
274
299
 
275
- // Use $filter for from/to/subject (more reliable on personal accounts),
276
- // $search for free-text query only.
277
- // NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
278
- if (term === 'from') {
279
- simplifiedParams.$filter = buildFromFilter(searchTerms[term]);
280
- } else if (term === 'to') {
281
- simplifiedParams.$filter = buildToFilter(searchTerms[term]);
282
- } else if (term === 'subject') {
283
- // Use $filter with contains() — $search silently fails on personal MS accounts
284
- simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
285
- } else if (term === 'query') {
286
- // On personal accounts, $search fails with 503. Use $filter with
287
- // contains(subject) as a best-effort fallback for free-text queries.
288
- simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
289
- }
300
+ const simplifiedParams = {
301
+ $top: Math.min(50, maxCount),
302
+ $select: selectFields,
303
+ };
290
304
 
291
- // Add boolean filters if applicable
292
- addBooleanFilters(simplifiedParams, filterTerms);
305
+ // Use $filter for from/to/subject (more reliable on personal accounts),
306
+ // $search for free-text query only.
307
+ // NOTE: $filter and $orderby cannot be used together on mailbox - Graph API limitation
308
+ if (term === 'from') {
309
+ simplifiedParams.$filter = buildFromFilter(searchTerms[term]);
310
+ } else if (term === 'to') {
311
+ simplifiedParams.$filter = buildToFilter(searchTerms[term]);
312
+ } else if (term === 'subject') {
313
+ // Use $filter with contains() — $search silently fails on personal MS accounts
314
+ simplifiedParams.$filter = `contains(subject, '${searchTerms[term].replace(/'/g, "''")}')`;
315
+ } else if (term === 'query') {
316
+ // On personal accounts, $search fails with 503. Use $filter with
317
+ // contains(subject) as a best-effort fallback for free-text queries.
318
+ // Split multi-word queries and AND a contains(subject) per word so a
319
+ // query like "github token" matches a subject where the words are
320
+ // non-contiguous ("[GitHub] ... personal access token"); single
321
+ // words behave exactly as before. (#169)
322
+ const queryWords = searchTerms[term]
323
+ .trim()
324
+ .split(/\s+/)
325
+ .filter(Boolean);
326
+ simplifiedParams.$filter = queryWords
327
+ .map((w) => `contains(subject, '${w.replace(/'/g, "''")}')`)
328
+ .join(' and ');
329
+ }
330
+
331
+ // Add boolean filters if applicable
332
+ addBooleanFilters(simplifiedParams, filterTerms);
293
333
 
294
- const response = await callGraphAPIPaginated(
334
+ const response = await callGraphAPIPaginated(
335
+ accessToken,
336
+ 'GET',
337
+ endpoint,
338
+ simplifiedParams,
339
+ maxCount
340
+ );
341
+ if (response.value && response.value.length > 0) {
342
+ console.error(
343
+ `Search with ${term} successful: found ${response.value.length} results`
344
+ );
345
+ response._searchInfo = {
346
+ attemptsCount: searchAttempts.length,
347
+ strategies: searchAttempts,
348
+ originalTerms: searchTerms,
349
+ filterTerms: filterTerms,
350
+ };
351
+ return response;
352
+ }
353
+ } catch (error) {
354
+ console.error(`Search with ${term} failed: ${error.message}`);
355
+ // Fall through to the client-side fallback below.
356
+ }
357
+
358
+ // 2b. Client-side fallback — runs EXACTLY ONCE per term whether the
359
+ // server-side attempt returned zero results OR threw. Only 'to' and
360
+ // 'query' have a local matcher. Keeping this outside the server-side
361
+ // try/catch prevents the double-scan/double-label a throw inside a
362
+ // success-path fallback would otherwise cause. (#169)
363
+ if (term === 'to' || term === 'query') {
364
+ console.error(
365
+ `${term} unsatisfied server-side, trying client-side filtering`
366
+ );
367
+ searchAttempts.push(`client-side-${term}`);
368
+ try {
369
+ const fallback = await runClientSideFallback(
295
370
  accessToken,
296
- 'GET',
297
371
  endpoint,
298
- simplifiedParams,
299
- maxCount
372
+ maxCount,
373
+ searchAttempts,
374
+ searchTerms,
375
+ filterTerms,
376
+ term,
377
+ scanState
378
+ );
379
+ if (fallback) return fallback;
380
+ } catch (fallbackError) {
381
+ console.error(
382
+ `Client-side ${term} fallback also failed: ${fallbackError.message}`
300
383
  );
301
- if (response.value && response.value.length > 0) {
302
- console.error(
303
- `Search with ${term} successful: found ${response.value.length} results`
304
- );
305
- response._searchInfo = {
306
- attemptsCount: searchAttempts.length,
307
- strategies: searchAttempts,
308
- originalTerms: searchTerms,
309
- filterTerms: filterTerms,
310
- };
311
- return response;
312
- }
313
-
314
- // Client-side fallback for 'to' filter — toRecipients/any() lambda
315
- // returns 0 results on personal accounts even when emails exist
316
- if (term === 'to') {
317
- console.error(
318
- 'to filter returned 0 results, trying client-side filtering'
319
- );
320
- searchAttempts.push('client-side-to');
321
- const messages = await fetchForClientSideFilter(
322
- accessToken,
323
- endpoint,
324
- maxCount
325
- );
326
- const matched = filterToClientSide(messages, searchTerms[term]);
327
- if (matched.length > 0) {
328
- console.error(
329
- `Client-side to filter matched ${matched.length} of ${messages.length} messages`
330
- );
331
- return { value: matched.slice(0, maxCount) };
332
- }
333
- }
334
-
335
- // Client-side fallback for 'query' — search bodyPreview, subject, from
336
- if (term === 'query') {
337
- console.error(
338
- 'query contains(subject) returned 0 results, trying client-side body search'
339
- );
340
- searchAttempts.push('client-side-query');
341
- const messages = await fetchForClientSideFilter(
342
- accessToken,
343
- endpoint,
344
- maxCount
345
- );
346
- const matched = filterQueryClientSide(messages, searchTerms[term]);
347
- if (matched.length > 0) {
348
- console.error(
349
- `Client-side query matched ${matched.length} of ${messages.length} messages`
350
- );
351
- return { value: matched.slice(0, maxCount) };
352
- }
353
- }
354
- } catch (error) {
355
- console.error(`Search with ${term} failed: ${error.message}`);
356
-
357
- // Client-side fallback for 'to' when API throws (e.g. InefficientFilter)
358
- if (term === 'to') {
359
- try {
360
- console.error(
361
- 'to filter threw error, trying client-side filtering'
362
- );
363
- searchAttempts.push('client-side-to');
364
- const messages = await fetchForClientSideFilter(
365
- accessToken,
366
- endpoint,
367
- maxCount
368
- );
369
- const matched = filterToClientSide(messages, searchTerms[term]);
370
- if (matched.length > 0) {
371
- console.error(
372
- `Client-side to filter matched ${matched.length} of ${messages.length} messages`
373
- );
374
- return { value: matched.slice(0, maxCount) };
375
- }
376
- } catch (fallbackError) {
377
- console.error(
378
- `Client-side to fallback also failed: ${fallbackError.message}`
379
- );
380
- }
381
- }
382
-
383
- // Client-side fallback for 'query' when API throws
384
- if (term === 'query') {
385
- try {
386
- console.error(
387
- 'query filter threw error, trying client-side body search'
388
- );
389
- searchAttempts.push('client-side-query');
390
- const messages = await fetchForClientSideFilter(
391
- accessToken,
392
- endpoint,
393
- maxCount
394
- );
395
- const matched = filterQueryClientSide(messages, searchTerms[term]);
396
- if (matched.length > 0) {
397
- console.error(
398
- `Client-side query matched ${matched.length} of ${messages.length} messages`
399
- );
400
- return { value: matched.slice(0, maxCount) };
401
- }
402
- } catch (fallbackError) {
403
- console.error(
404
- `Client-side query fallback also failed: ${fallbackError.message}`
405
- );
406
- }
407
- }
408
384
  }
409
385
  }
410
386
  }
@@ -506,6 +482,13 @@ async function progressiveSearch(
506
482
  originalTerms: searchTerms,
507
483
  filterTerms: filterTerms,
508
484
  noResults: true,
485
+ // Disclose scan coverage if a client-side fallback ran but matched
486
+ // nothing — otherwise a bounded scan reads as a definitive "none". (#169)
487
+ ...(scanState.candidatesScanned !== undefined && {
488
+ candidatesScanned: scanState.candidatesScanned,
489
+ scanLimit: scanState.scanLimit,
490
+ truncated: scanState.truncated,
491
+ }),
509
492
  },
510
493
  };
511
494
  }
@@ -639,17 +622,22 @@ function filterQueryClientSide(messages, queryText) {
639
622
  }
640
623
 
641
624
  /**
642
- * Fetch recent messages for client-side filtering fallback.
643
- * Uses the 'search' field preset which includes toRecipients and bodyPreview.
625
+ * Fetch a window of recent messages for the client-side filtering fallback.
626
+ * Uses the 'search' field preset (includes toRecipients and bodyPreview).
627
+ *
628
+ * Scan depth is bounded by `scanLimit` and decoupled from the requested result
629
+ * count (see CLIENT_SCAN_LIMIT). Returns scan metadata so callers can surface
630
+ * whether coverage was truncated. (#169 V37-F-2)
631
+ *
644
632
  * @param {string} accessToken - Access token
645
633
  * @param {string} endpoint - API endpoint
646
- * @param {number} maxCount - Maximum results to fetch
647
- * @returns {Promise<Array>} - Array of message objects
634
+ * @param {number} scanLimit - Max messages to scan
635
+ * @returns {Promise<{messages: Array, candidatesScanned: number, truncated: boolean}>}
648
636
  */
649
- async function fetchForClientSideFilter(accessToken, endpoint, maxCount) {
637
+ async function fetchRecentCandidates(accessToken, endpoint, scanLimit) {
650
638
  const searchFields = getEmailFields('search');
651
639
  const params = {
652
- $top: Math.min(200, maxCount * 5),
640
+ $top: Math.min(50, scanLimit),
653
641
  $select: searchFields,
654
642
  $orderby: 'receivedDateTime desc',
655
643
  };
@@ -658,9 +646,113 @@ async function fetchForClientSideFilter(accessToken, endpoint, maxCount) {
658
646
  'GET',
659
647
  endpoint,
660
648
  params,
661
- Math.min(200, maxCount * 5)
649
+ scanLimit
650
+ );
651
+ const messages = response.value || [];
652
+ return {
653
+ messages,
654
+ candidatesScanned: messages.length,
655
+ // Filled the scan budget → older unscanned messages may also match.
656
+ truncated: messages.length >= scanLimit,
657
+ };
658
+ }
659
+
660
+ /**
661
+ * Re-apply active boolean/date filters to a client-side result set so the
662
+ * local fallback honours the same constraints the server-side $filter path
663
+ * enforces (hasAttachments, unreadOnly, receivedAfter/Before). The 'search'
664
+ * field preset includes hasAttachments/isRead/receivedDateTime. (#169)
665
+ * @param {Array} messages - Messages already matched by the term filter
666
+ * @param {object} filterTerms - Active boolean/date filters
667
+ * @returns {Array} - Messages that also satisfy the boolean/date filters
668
+ */
669
+ function applyBooleanDateFilters(messages, filterTerms) {
670
+ const after = filterTerms.receivedAfter
671
+ ? Date.parse(filterTerms.receivedAfter)
672
+ : null;
673
+ const before = filterTerms.receivedBefore
674
+ ? Date.parse(filterTerms.receivedBefore)
675
+ : null;
676
+ return messages.filter((m) => {
677
+ if (filterTerms.hasAttachments === true && m.hasAttachments !== true) {
678
+ return false;
679
+ }
680
+ if (filterTerms.unreadOnly === true && m.isRead !== false) {
681
+ return false;
682
+ }
683
+ if (after !== null && !Number.isNaN(after)) {
684
+ const rec = Date.parse(m.receivedDateTime);
685
+ if (Number.isNaN(rec) || rec < after) return false;
686
+ }
687
+ if (before !== null && !Number.isNaN(before)) {
688
+ const rec = Date.parse(m.receivedDateTime);
689
+ if (Number.isNaN(rec) || rec > before) return false;
690
+ }
691
+ return true;
692
+ });
693
+ }
694
+
695
+ /**
696
+ * Run a client-side fallback scan for a 'to' or 'query' term: fetch a bounded
697
+ * window of recent messages, filter locally, and (on a match) return a result
698
+ * carrying full `_searchInfo` including scan metadata. Returns null when nothing
699
+ * matches so the caller can continue its strategy ladder. (#169)
700
+ *
701
+ * @param {string} accessToken - Access token
702
+ * @param {string} endpoint - API endpoint
703
+ * @param {number} maxCount - Requested result count
704
+ * @param {string[]} searchAttempts - Accumulated strategy labels
705
+ * @param {object} searchTerms - Search terms
706
+ * @param {object} filterTerms - Filter terms
707
+ * @param {'to'|'query'} kind - Which term to filter on
708
+ * @returns {Promise<object|null>}
709
+ */
710
+ async function runClientSideFallback(
711
+ accessToken,
712
+ endpoint,
713
+ maxCount,
714
+ searchAttempts,
715
+ searchTerms,
716
+ filterTerms,
717
+ kind,
718
+ scanState
719
+ ) {
720
+ const { messages, candidatesScanned, truncated } =
721
+ await fetchRecentCandidates(accessToken, endpoint, CLIENT_SCAN_LIMIT);
722
+ // Record scan coverage even when nothing matches, so the eventual
723
+ // no-results response can still disclose that the scan was bounded. (#169)
724
+ if (scanState) {
725
+ scanState.candidatesScanned = candidatesScanned;
726
+ scanState.scanLimit = CLIENT_SCAN_LIMIT;
727
+ scanState.truncated = truncated;
728
+ }
729
+ // Apply the term matcher, then RE-APPLY any active boolean/date filters —
730
+ // the server-side path enforces these via $filter, so the local fallback
731
+ // must too, otherwise e.g. `unreadOnly:true` would leak read mail while
732
+ // searchMetadata still claims filterApplied. (#169)
733
+ const termMatched =
734
+ kind === 'to'
735
+ ? filterToClientSide(messages, searchTerms.to)
736
+ : filterQueryClientSide(messages, searchTerms.query);
737
+ const matched = applyBooleanDateFilters(termMatched, filterTerms);
738
+ if (matched.length === 0) {
739
+ return null;
740
+ }
741
+ console.error(
742
+ `Client-side ${kind} matched ${matched.length} of ${messages.length} scanned messages`
662
743
  );
663
- return response.value || [];
744
+ return {
745
+ value: matched.slice(0, maxCount),
746
+ _searchInfo: {
747
+ attemptsCount: searchAttempts.length,
748
+ strategies: searchAttempts,
749
+ originalTerms: searchTerms,
750
+ filterTerms,
751
+ candidatesScanned,
752
+ scanLimit: CLIENT_SCAN_LIMIT,
753
+ truncated,
754
+ },
755
+ };
664
756
  }
665
757
 
666
758
  /**
@@ -822,6 +914,13 @@ function formatSearchResults(response, folder, verbosity) {
822
914
  finalStrategy: finalStrategy,
823
915
  filterApplied: !response._searchInfo.noResults,
824
916
  originalFilters: response._searchInfo.originalTerms,
917
+ // Surface client-side scan coverage so callers can tell when a fallback
918
+ // result may be incomplete (older matches beyond the scan budget). (#169)
919
+ ...(response._searchInfo.candidatesScanned !== undefined && {
920
+ candidatesScanned: response._searchInfo.candidatesScanned,
921
+ scanLimit: response._searchInfo.scanLimit,
922
+ truncated: response._searchInfo.truncated,
923
+ }),
825
924
  };
826
925
  }
827
926
 
@@ -830,9 +929,11 @@ function formatSearchResults(response, folder, verbosity) {
830
929
  // Actionable guidance when filters were specified but matched nothing
831
930
  if (response._searchInfo?.noResults) {
832
931
  const filters = response._searchInfo.originalTerms || {};
932
+ // Relabel internal keys to the caller-facing param names. (#169)
933
+ const FILTER_LABELS = { kqlQuery: 'searchExpression' };
833
934
  const activeFilters = Object.entries(filters)
834
935
  .filter(([, v]) => v)
835
- .map(([k]) => k);
936
+ .map(([k]) => FILTER_LABELS[k] || k);
836
937
  const filterDesc =
837
938
  activeFilters.length > 0
838
939
  ? ` (filters: ${activeFilters.join(', ')})`
@@ -844,7 +945,7 @@ function formatSearchResults(response, folder, verbosity) {
844
945
  '- Try `searchAllFolders: true` to search across all folders including Archive\n' +
845
946
  '- Specify the correct folder if emails have been moved (use `folders` tool to list folders)\n' +
846
947
  '- Use `from` filter instead of `to` (more reliable on personal accounts)\n' +
847
- '- Use `kqlQuery` with `searchAllFolders: true` for cross-folder search';
948
+ '- Use `searchExpression` with `searchAllFolders: true` for cross-folder search';
848
949
 
849
950
  return {
850
951
  content: [{ type: 'text', text }],
package/folder/create.js CHANGED
@@ -3,7 +3,7 @@
3
3
  */
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
- const { getFolderIdByName } = require('../email/folder-utils');
6
+ const { resolveFolder, listChildFolders } = require('./resolve');
7
7
 
8
8
  /**
9
9
  * Create folder handler
@@ -11,8 +11,9 @@ const { getFolderIdByName } = require('../email/folder-utils');
11
11
  * @returns {object} - MCP response
12
12
  */
13
13
  async function handleCreateFolder(args) {
14
- const folderName = args.name;
14
+ const folderName = (args.name || '').trim();
15
15
  const parentFolder = args.parentFolder || '';
16
+ const parentFolderId = args.parentFolderId || '';
16
17
 
17
18
  if (!folderName) {
18
19
  return {
@@ -30,11 +31,10 @@ async function handleCreateFolder(args) {
30
31
  const accessToken = await ensureAuthenticated();
31
32
 
32
33
  // Create folder with appropriate parent
33
- const result = await createMailFolder(
34
- accessToken,
35
- folderName,
36
- parentFolder
37
- );
34
+ const result = await createMailFolder(accessToken, folderName, {
35
+ name: parentFolder,
36
+ id: parentFolderId,
37
+ });
38
38
 
39
39
  return {
40
40
  content: [
@@ -74,34 +74,46 @@ async function handleCreateFolder(args) {
74
74
  * Create a new mail folder
75
75
  * @param {string} accessToken - Access token
76
76
  * @param {string} folderName - Name of the folder to create
77
- * @param {string} parentFolderName - Name of the parent folder (optional)
77
+ * @param {{name?: string, id?: string}} parentSpec - Parent folder name/path or ID
78
78
  * @returns {Promise<object>} - Result object with status and message
79
79
  */
80
- async function createMailFolder(accessToken, folderName, parentFolderName) {
80
+ async function createMailFolder(accessToken, folderName, parentSpec) {
81
81
  try {
82
- // Check if a folder with this name already exists
83
- const existingFolder = await getFolderIdByName(accessToken, folderName);
84
- if (existingFolder) {
85
- return {
86
- success: false,
87
- message: `A folder named "${folderName}" already exists.`,
88
- };
89
- }
90
-
91
- // If parent folder specified, find its ID
92
- let endpoint = 'me/mailFolders';
93
- if (parentFolderName) {
94
- const parentId = await getFolderIdByName(accessToken, parentFolderName);
95
- if (!parentId) {
82
+ // Resolve the parent folder if one was specified (supports "Parent/Child"
83
+ // paths and explicit IDs). Leaf name (folderName) is created, not
84
+ // resolved. (#216)
85
+ let parent = null;
86
+ if (parentSpec.name || parentSpec.id) {
87
+ try {
88
+ parent = await resolveFolder(accessToken, parentSpec);
89
+ } catch (resolveError) {
96
90
  return {
97
91
  success: false,
98
- message: `Parent folder "${parentFolderName}" not found. Please specify a valid parent folder or leave it blank to create at the root level.`,
92
+ message: `Parent folder could not be resolved: ${resolveError.message}`,
99
93
  };
100
94
  }
95
+ }
101
96
 
102
- endpoint = `me/mailFolders/${parentId}/childFolders`;
97
+ // Duplicate check scoped to the TARGET parent (or the root), not the whole
98
+ // mailbox — a name may legitimately exist under a different parent. (#216)
99
+ const siblings = await listChildFolders(
100
+ accessToken,
101
+ parent ? parent.id : null
102
+ );
103
+ const lower = folderName.toLowerCase();
104
+ if (siblings.some((f) => f.displayName.toLowerCase() === lower)) {
105
+ return {
106
+ success: false,
107
+ message: `A folder named "${folderName}" already exists ${
108
+ parent ? `under "${parent.path}"` : 'at the root level'
109
+ }.`,
110
+ };
103
111
  }
104
112
 
113
+ const endpoint = parent
114
+ ? `me/mailFolders/${parent.id}/childFolders`
115
+ : 'me/mailFolders';
116
+
105
117
  // Create the folder
106
118
  const folderData = {
107
119
  displayName: folderName,
@@ -115,8 +127,8 @@ async function createMailFolder(accessToken, folderName, parentFolderName) {
115
127
  );
116
128
 
117
129
  if (response && response.id) {
118
- const locationInfo = parentFolderName
119
- ? `inside "${parentFolderName}"`
130
+ const locationInfo = parent
131
+ ? `inside "${parent.path}"`
120
132
  : 'at the root level';
121
133
 
122
134
  return {