@littlebearapps/outlook-assistant 3.7.0 → 3.7.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.
Files changed (2) hide show
  1. package/email/search.js +278 -23
  2. package/package.json +1 -1
package/email/search.js CHANGED
@@ -145,6 +145,12 @@ async function progressiveSearch(
145
145
  console.error(
146
146
  `Raw KQL search successful: found ${response.value.length} results`
147
147
  );
148
+ response._searchInfo = {
149
+ attemptsCount: searchAttempts.length,
150
+ strategies: searchAttempts,
151
+ originalTerms: searchTerms,
152
+ filterTerms: filterTerms,
153
+ };
148
154
  return response;
149
155
  }
150
156
  } catch (error) {
@@ -188,6 +194,12 @@ async function progressiveSearch(
188
194
  console.error(
189
195
  `Combined search successful: found ${response.value.length} results`
190
196
  );
197
+ response._searchInfo = {
198
+ attemptsCount: searchAttempts.length,
199
+ strategies: searchAttempts,
200
+ originalTerms: searchTerms,
201
+ filterTerms: filterTerms,
202
+ };
191
203
  return response;
192
204
  }
193
205
  } catch (error) {
@@ -241,10 +253,109 @@ async function progressiveSearch(
241
253
  console.error(
242
254
  `Search with ${term} successful: found ${response.value.length} results`
243
255
  );
256
+ response._searchInfo = {
257
+ attemptsCount: searchAttempts.length,
258
+ strategies: searchAttempts,
259
+ originalTerms: searchTerms,
260
+ filterTerms: filterTerms,
261
+ };
244
262
  return response;
245
263
  }
264
+
265
+ // Client-side fallback for 'to' filter — toRecipients/any() lambda
266
+ // returns 0 results on personal accounts even when emails exist
267
+ if (term === 'to') {
268
+ console.error(
269
+ 'to filter returned 0 results, trying client-side filtering'
270
+ );
271
+ searchAttempts.push('client-side-to');
272
+ const messages = await fetchForClientSideFilter(
273
+ accessToken,
274
+ endpoint,
275
+ maxCount
276
+ );
277
+ const matched = filterToClientSide(messages, searchTerms[term]);
278
+ if (matched.length > 0) {
279
+ console.error(
280
+ `Client-side to filter matched ${matched.length} of ${messages.length} messages`
281
+ );
282
+ return { value: matched.slice(0, maxCount) };
283
+ }
284
+ }
285
+
286
+ // Client-side fallback for 'query' — search bodyPreview, subject, from
287
+ if (term === 'query') {
288
+ console.error(
289
+ 'query contains(subject) returned 0 results, trying client-side body search'
290
+ );
291
+ searchAttempts.push('client-side-query');
292
+ const messages = await fetchForClientSideFilter(
293
+ accessToken,
294
+ endpoint,
295
+ maxCount
296
+ );
297
+ const matched = filterQueryClientSide(messages, searchTerms[term]);
298
+ if (matched.length > 0) {
299
+ console.error(
300
+ `Client-side query matched ${matched.length} of ${messages.length} messages`
301
+ );
302
+ return { value: matched.slice(0, maxCount) };
303
+ }
304
+ }
246
305
  } catch (error) {
247
306
  console.error(`Search with ${term} failed: ${error.message}`);
307
+
308
+ // Client-side fallback for 'to' when API throws (e.g. InefficientFilter)
309
+ if (term === 'to') {
310
+ try {
311
+ console.error(
312
+ 'to filter threw error, trying client-side filtering'
313
+ );
314
+ searchAttempts.push('client-side-to');
315
+ const messages = await fetchForClientSideFilter(
316
+ accessToken,
317
+ endpoint,
318
+ maxCount
319
+ );
320
+ const matched = filterToClientSide(messages, searchTerms[term]);
321
+ if (matched.length > 0) {
322
+ console.error(
323
+ `Client-side to filter matched ${matched.length} of ${messages.length} messages`
324
+ );
325
+ return { value: matched.slice(0, maxCount) };
326
+ }
327
+ } catch (fallbackError) {
328
+ console.error(
329
+ `Client-side to fallback also failed: ${fallbackError.message}`
330
+ );
331
+ }
332
+ }
333
+
334
+ // Client-side fallback for 'query' when API throws
335
+ if (term === 'query') {
336
+ try {
337
+ console.error(
338
+ 'query filter threw error, 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
+ } catch (fallbackError) {
354
+ console.error(
355
+ `Client-side query fallback also failed: ${fallbackError.message}`
356
+ );
357
+ }
358
+ }
248
359
  }
249
360
  }
250
361
  }
@@ -282,6 +393,12 @@ async function progressiveSearch(
282
393
  console.error(
283
394
  `Boolean filter search found ${response.value?.length || 0} results`
284
395
  );
396
+ response._searchInfo = {
397
+ attemptsCount: searchAttempts.length,
398
+ strategies: searchAttempts,
399
+ originalTerms: searchTerms,
400
+ filterTerms: filterTerms,
401
+ };
285
402
  return response;
286
403
  } catch (error) {
287
404
  console.error(`Boolean filter search failed: ${error.message}`);
@@ -301,6 +418,12 @@ async function progressiveSearch(
301
418
  retryParams,
302
419
  maxCount
303
420
  );
421
+ response._searchInfo = {
422
+ attemptsCount: searchAttempts.length,
423
+ strategies: searchAttempts,
424
+ originalTerms: searchTerms,
425
+ filterTerms: filterTerms,
426
+ };
304
427
  return response;
305
428
  } catch (retryError) {
306
429
  console.error(
@@ -311,8 +434,35 @@ async function progressiveSearch(
311
434
  }
312
435
  }
313
436
 
314
- // 4. Final fallback: just get recent emails with pagination
315
- console.error('All search strategies failed, falling back to recent emails');
437
+ // 4. Final fallback
438
+ // If the user specified search filters, return 0 results with guidance
439
+ // instead of silently returning unfiltered recent emails.
440
+ const hasAnyFilters =
441
+ searchTerms.query ||
442
+ searchTerms.from ||
443
+ searchTerms.to ||
444
+ searchTerms.subject ||
445
+ searchTerms.kqlQuery;
446
+
447
+ if (hasAnyFilters) {
448
+ console.error(
449
+ 'All search strategies exhausted with filters active — returning 0 results'
450
+ );
451
+ searchAttempts.push('no-results');
452
+ return {
453
+ value: [],
454
+ _searchInfo: {
455
+ attemptsCount: searchAttempts.length,
456
+ strategies: searchAttempts,
457
+ originalTerms: searchTerms,
458
+ filterTerms: filterTerms,
459
+ noResults: true,
460
+ },
461
+ };
462
+ }
463
+
464
+ // No search filters specified — return recent emails (list mode)
465
+ console.error('No search filters specified, returning recent emails');
316
466
  searchAttempts.push('recent-emails');
317
467
 
318
468
  const basicParams = {
@@ -328,11 +478,8 @@ async function progressiveSearch(
328
478
  basicParams,
329
479
  maxCount
330
480
  );
331
- console.error(
332
- `Fallback to recent emails found ${response.value?.length || 0} results`
333
- );
481
+ console.error(`Recent emails: found ${response.value?.length || 0} results`);
334
482
 
335
- // Add a note to the response about the search attempts
336
483
  response._searchInfo = {
337
484
  attemptsCount: searchAttempts.length,
338
485
  strategies: searchAttempts,
@@ -391,6 +538,74 @@ function buildToFilter(val) {
391
538
  return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
392
539
  }
393
540
 
541
+ /**
542
+ * Client-side filter for toRecipients — used when the OData toRecipients/any()
543
+ * lambda expression fails on personal accounts (InefficientFilter).
544
+ * Mirrors the pattern from conversations.js lines 138-147.
545
+ * @param {Array} messages - Array of message objects with toRecipients
546
+ * @param {string} toValue - The to filter value (email, domain, or name)
547
+ * @returns {Array} - Filtered messages where at least one recipient matches
548
+ */
549
+ function filterToClientSide(messages, toValue) {
550
+ const toLower = toValue.toLowerCase();
551
+ return messages.filter((m) =>
552
+ (m.toRecipients || []).some((r) => {
553
+ const addr = (r.emailAddress?.address || '').toLowerCase();
554
+ const name = (r.emailAddress?.name || '').toLowerCase();
555
+ return addr.includes(toLower) || name.includes(toLower);
556
+ })
557
+ );
558
+ }
559
+
560
+ /**
561
+ * Client-side filter for free-text query — used when $search and
562
+ * contains(subject) both fail on personal accounts.
563
+ * Searches subject, bodyPreview, from address, and from name.
564
+ * @param {Array} messages - Array of message objects
565
+ * @param {string} queryText - The query text to search for
566
+ * @returns {Array} - Filtered messages matching the query
567
+ */
568
+ function filterQueryClientSide(messages, queryText) {
569
+ const queryLower = queryText.toLowerCase();
570
+ return messages.filter((m) => {
571
+ const subject = (m.subject || '').toLowerCase();
572
+ const body = (m.bodyPreview || '').toLowerCase();
573
+ const fromAddr = (m.from?.emailAddress?.address || '').toLowerCase();
574
+ const fromName = (m.from?.emailAddress?.name || '').toLowerCase();
575
+ return (
576
+ subject.includes(queryLower) ||
577
+ body.includes(queryLower) ||
578
+ fromAddr.includes(queryLower) ||
579
+ fromName.includes(queryLower)
580
+ );
581
+ });
582
+ }
583
+
584
+ /**
585
+ * Fetch recent messages for client-side filtering fallback.
586
+ * Uses the 'search' field preset which includes toRecipients and bodyPreview.
587
+ * @param {string} accessToken - Access token
588
+ * @param {string} endpoint - API endpoint
589
+ * @param {number} maxCount - Maximum results to fetch
590
+ * @returns {Promise<Array>} - Array of message objects
591
+ */
592
+ async function fetchForClientSideFilter(accessToken, endpoint, maxCount) {
593
+ const searchFields = getEmailFields('search');
594
+ const params = {
595
+ $top: Math.min(200, maxCount * 5),
596
+ $select: searchFields,
597
+ $orderby: 'receivedDateTime desc',
598
+ };
599
+ const response = await callGraphAPIPaginated(
600
+ accessToken,
601
+ 'GET',
602
+ endpoint,
603
+ params,
604
+ Math.min(200, maxCount * 5)
605
+ );
606
+ return response.value || [];
607
+ }
608
+
394
609
  /**
395
610
  * Build search parameters from search terms and filter terms
396
611
  * Uses $filter for email addresses (more reliable than $search)
@@ -531,37 +746,75 @@ function addBooleanFilters(params, filterTerms) {
531
746
  * @returns {object} - MCP response object
532
747
  */
533
748
  function formatSearchResults(response, folder, verbosity) {
749
+ // Build metadata
750
+ const meta = {
751
+ returned: (response.value || []).length,
752
+ totalAvailable: response['@odata.count'] || null,
753
+ hasMore: Boolean(response['@odata.nextLink']),
754
+ verbosity: verbosity,
755
+ };
756
+
757
+ // Add searchMetadata to _meta when available (for programmatic fallback detection)
758
+ if (response._searchInfo) {
759
+ const finalStrategy =
760
+ response._searchInfo.strategies[
761
+ response._searchInfo.strategies.length - 1
762
+ ];
763
+ meta.searchMetadata = {
764
+ strategiesAttempted: response._searchInfo.strategies,
765
+ finalStrategy: finalStrategy,
766
+ filterApplied: !response._searchInfo.noResults,
767
+ originalFilters: response._searchInfo.originalTerms,
768
+ };
769
+ }
770
+
771
+ // Handle 0 results
534
772
  if (!response.value || response.value.length === 0) {
773
+ // Actionable guidance when filters were specified but matched nothing
774
+ if (response._searchInfo?.noResults) {
775
+ const filters = response._searchInfo.originalTerms || {};
776
+ const activeFilters = Object.entries(filters)
777
+ .filter(([, v]) => v)
778
+ .map(([k]) => k);
779
+ const filterDesc =
780
+ activeFilters.length > 0
781
+ ? ` (filters: ${activeFilters.join(', ')})`
782
+ : '';
783
+
784
+ const text =
785
+ `No emails found matching your filters in "${folder}"${filterDesc}.\n\n` +
786
+ '**Suggestions:**\n' +
787
+ '- Try `searchAllFolders: true` to search across all folders including Archive\n' +
788
+ '- Specify the correct folder if emails have been moved (use `folders` tool to list folders)\n' +
789
+ '- Use `from` filter instead of `to` (more reliable on personal accounts)\n' +
790
+ '- Use `kqlQuery` with `searchAllFolders: true` for cross-folder search';
791
+
792
+ return {
793
+ content: [{ type: 'text', text }],
794
+ _meta: meta,
795
+ };
796
+ }
797
+
535
798
  return {
536
799
  content: [
537
800
  {
538
801
  type: 'text',
539
- text: `No emails found matching your search criteria.`,
802
+ text: 'No emails found matching your search criteria.',
540
803
  },
541
804
  ],
805
+ _meta: meta,
542
806
  };
543
807
  }
544
808
 
545
- // Build metadata
546
- const meta = {
547
- returned: response.value.length,
548
- totalAvailable: response['@odata.count'] || null,
549
- hasMore: Boolean(response['@odata.nextLink']),
550
- verbosity: verbosity,
551
- };
552
-
553
- // Add search strategy info if available (for debugging)
809
+ // Add search strategy note for transparency
554
810
  let searchNote = '';
555
811
  if (response._searchInfo) {
556
- const strategy =
557
- response._searchInfo.strategies[
558
- response._searchInfo.strategies.length - 1
559
- ];
812
+ const strategy = meta.searchMetadata.finalStrategy;
560
813
  if (strategy === 'recent-emails') {
561
814
  searchNote =
562
- '\n\n**Note**: Your search query could not be applied — showing recent emails instead. ' +
563
- 'On personal Microsoft accounts, free-text `query` search may not work. ' +
564
- 'Try using `subject`, `from`, `to`, or `kqlQuery` parameters for more reliable filtering.';
815
+ '\n\n**Note**: No search filters were applied — showing recent emails.';
816
+ } else if (strategy.startsWith('client-side-')) {
817
+ searchNote = `\n\n_Search strategy: ${strategy} (filtered locally due to personal account API limitations)_`;
565
818
  } else {
566
819
  searchNote = `\n\n_Search strategy: ${strategy}_`;
567
820
  }
@@ -699,4 +952,6 @@ module.exports = {
699
952
  buildFromFilter,
700
953
  buildToFilter,
701
954
  classifyEmailFilter,
955
+ filterToClientSide,
956
+ filterQueryClientSide,
702
957
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
5
  "description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",