@littlebearapps/outlook-assistant 3.6.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.
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) {
@@ -166,7 +172,7 @@ async function progressiveSearch(
166
172
  ) {
167
173
  // Skip directly to boolean-only filter (step 3) — combined search is redundant
168
174
  console.error('Only boolean filters provided, skipping combined search');
169
- } else
175
+ } else {
170
176
  try {
171
177
  const params = buildSearchParams(
172
178
  searchTerms,
@@ -188,11 +194,18 @@ 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) {
194
206
  console.error(`Combined search failed: ${error.message}`);
195
207
  }
208
+ }
196
209
 
197
210
  // 2. Try each search term individually, starting with most specific
198
211
  const searchPriority = ['from', 'to', 'subject', 'query'];
@@ -240,10 +253,109 @@ async function progressiveSearch(
240
253
  console.error(
241
254
  `Search with ${term} successful: found ${response.value.length} results`
242
255
  );
256
+ response._searchInfo = {
257
+ attemptsCount: searchAttempts.length,
258
+ strategies: searchAttempts,
259
+ originalTerms: searchTerms,
260
+ filterTerms: filterTerms,
261
+ };
243
262
  return response;
244
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
+ }
245
305
  } catch (error) {
246
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
+ }
247
359
  }
248
360
  }
249
361
  }
@@ -281,6 +393,12 @@ async function progressiveSearch(
281
393
  console.error(
282
394
  `Boolean filter search found ${response.value?.length || 0} results`
283
395
  );
396
+ response._searchInfo = {
397
+ attemptsCount: searchAttempts.length,
398
+ strategies: searchAttempts,
399
+ originalTerms: searchTerms,
400
+ filterTerms: filterTerms,
401
+ };
284
402
  return response;
285
403
  } catch (error) {
286
404
  console.error(`Boolean filter search failed: ${error.message}`);
@@ -300,6 +418,12 @@ async function progressiveSearch(
300
418
  retryParams,
301
419
  maxCount
302
420
  );
421
+ response._searchInfo = {
422
+ attemptsCount: searchAttempts.length,
423
+ strategies: searchAttempts,
424
+ originalTerms: searchTerms,
425
+ filterTerms: filterTerms,
426
+ };
303
427
  return response;
304
428
  } catch (retryError) {
305
429
  console.error(
@@ -310,8 +434,35 @@ async function progressiveSearch(
310
434
  }
311
435
  }
312
436
 
313
- // 4. Final fallback: just get recent emails with pagination
314
- 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');
315
466
  searchAttempts.push('recent-emails');
316
467
 
317
468
  const basicParams = {
@@ -327,11 +478,8 @@ async function progressiveSearch(
327
478
  basicParams,
328
479
  maxCount
329
480
  );
330
- console.error(
331
- `Fallback to recent emails found ${response.value?.length || 0} results`
332
- );
481
+ console.error(`Recent emails: found ${response.value?.length || 0} results`);
333
482
 
334
- // Add a note to the response about the search attempts
335
483
  response._searchInfo = {
336
484
  attemptsCount: searchAttempts.length,
337
485
  strategies: searchAttempts,
@@ -390,6 +538,74 @@ function buildToFilter(val) {
390
538
  return `toRecipients/any(r: contains(r/emailAddress/name, '${val}'))`;
391
539
  }
392
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
+
393
609
  /**
394
610
  * Build search parameters from search terms and filter terms
395
611
  * Uses $filter for email addresses (more reliable than $search)
@@ -530,37 +746,75 @@ function addBooleanFilters(params, filterTerms) {
530
746
  * @returns {object} - MCP response object
531
747
  */
532
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
533
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
+
534
798
  return {
535
799
  content: [
536
800
  {
537
801
  type: 'text',
538
- text: `No emails found matching your search criteria.`,
802
+ text: 'No emails found matching your search criteria.',
539
803
  },
540
804
  ],
805
+ _meta: meta,
541
806
  };
542
807
  }
543
808
 
544
- // Build metadata
545
- const meta = {
546
- returned: response.value.length,
547
- totalAvailable: response['@odata.count'] || null,
548
- hasMore: !!response['@odata.nextLink'],
549
- verbosity: verbosity,
550
- };
551
-
552
- // Add search strategy info if available (for debugging)
809
+ // Add search strategy note for transparency
553
810
  let searchNote = '';
554
811
  if (response._searchInfo) {
555
- const strategy =
556
- response._searchInfo.strategies[
557
- response._searchInfo.strategies.length - 1
558
- ];
812
+ const strategy = meta.searchMetadata.finalStrategy;
559
813
  if (strategy === 'recent-emails') {
560
814
  searchNote =
561
- '\n\n**Note**: Your search query could not be applied — showing recent emails instead. ' +
562
- 'On personal Microsoft accounts, free-text `query` search may not work. ' +
563
- '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)_`;
564
818
  } else {
565
819
  searchNote = `\n\n_Search strategy: ${strategy}_`;
566
820
  }
@@ -698,4 +952,6 @@ module.exports = {
698
952
  buildFromFilter,
699
953
  buildToFilter,
700
954
  classifyEmailFilter,
955
+ filterToClientSide,
956
+ filterQueryClientSide,
701
957
  };
package/email/send.js CHANGED
@@ -138,7 +138,7 @@ async function handleSendEmail(args) {
138
138
  content: [
139
139
  {
140
140
  type: 'text',
141
- text: tipsText + '\n\n---\n\n' + preview.content[0].text,
141
+ text: `${tipsText}\n\n---\n\n${preview.content[0].text}`,
142
142
  },
143
143
  ],
144
144
  _meta: { mailTips: tipsResult._meta },
package/folder/list.js CHANGED
@@ -272,7 +272,7 @@ function formatFolderHierarchy(folders, includeItemCounts) {
272
272
  // Add children
273
273
  const childLines = folder.children
274
274
  .map((childId) => formatSubtree(childId, level + 1))
275
- .filter((line) => line.length > 0)
275
+ .filter((childLine) => childLine.length > 0)
276
276
  .join('\n');
277
277
 
278
278
  return childLines.length > 0 ? `${line}\n${childLines}` : line;
package/llms.txt CHANGED
@@ -33,6 +33,7 @@ Built by [Little Bear Apps](https://littlebearapps.com).
33
33
 
34
34
  - **MCP safety annotations** on all 22 tools — AI clients auto-approve reads and prompt for destructive operations
35
35
  - **Send-email protections**: pre-send mail tips, dry-run preview, session rate limiting, recipient allowlist
36
+ - **Rule protections**: dry-run preview on create/update, rate limiting, recipient allowlist on forward/redirect, no permanent-delete action
36
37
  - **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
37
38
  - These safeguards reduce risk but are not foolproof — always review actions before approving
38
39
 
@@ -62,7 +63,7 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
62
63
  - **Calendar (3 tools)**: `list-events`, `create-event`, `manage-event`
63
64
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
64
65
  - **Folders (1 tool)**: `folders` — list, create, move, stats
65
- - **Rules (1 tool)**: `manage-rules` — list, create, reorder
66
+ - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
66
67
  - **Categories (3 tools)**: `manage-category`, `apply-category`, `manage-focused-inbox`
67
68
  - **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies, set working hours
68
69
  - **Advanced (2 tools)**: `access-shared-mailbox`, `find-meeting-rooms`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.6.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",