@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/README.md +5 -5
- package/advanced/index.js +3 -1
- package/calendar/index.js +1 -1
- package/calendar/list.js +114 -22
- package/categories/index.js +2 -1
- package/contacts/index.js +3 -1
- package/email/folder-utils.js +13 -13
- package/email/index.js +20 -7
- package/email/search.js +252 -151
- package/folder/create.js +39 -27
- package/folder/delete.js +26 -36
- package/folder/index.js +19 -7
- package/folder/list.js +51 -66
- package/folder/move.js +17 -17
- package/folder/resolve.js +315 -0
- package/folder/stats.js +12 -75
- package/llms.txt +5 -5
- package/package.json +6 -2
- package/request-handler.js +144 -0
- package/utils/graph-api.js +11 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
|
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}
|
|
647
|
-
* @returns {Promise<Array
|
|
634
|
+
* @param {number} scanLimit - Max messages to scan
|
|
635
|
+
* @returns {Promise<{messages: Array, candidatesScanned: number, truncated: boolean}>}
|
|
648
636
|
*/
|
|
649
|
-
async function
|
|
637
|
+
async function fetchRecentCandidates(accessToken, endpoint, scanLimit) {
|
|
650
638
|
const searchFields = getEmailFields('search');
|
|
651
639
|
const params = {
|
|
652
|
-
$top: Math.min(
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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 {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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}
|
|
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,
|
|
80
|
+
async function createMailFolder(accessToken, folderName, parentSpec) {
|
|
81
81
|
try {
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
92
|
+
message: `Parent folder could not be resolved: ${resolveError.message}`,
|
|
99
93
|
};
|
|
100
94
|
}
|
|
95
|
+
}
|
|
101
96
|
|
|
102
|
-
|
|
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 =
|
|
119
|
-
? `inside "${
|
|
130
|
+
const locationInfo = parent
|
|
131
|
+
? `inside "${parent.path}"`
|
|
120
132
|
: 'at the root level';
|
|
121
133
|
|
|
122
134
|
return {
|