@littlebearapps/outlook-assistant 3.11.0 → 3.11.2

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/.env.example CHANGED
@@ -24,6 +24,13 @@ USE_TEST_MODE=false
24
24
  # Optional: Enable immutable IDs (IDs persist through folder moves)
25
25
  # OUTLOOK_IMMUTABLE_IDS=true
26
26
 
27
+ # How many recent messages the client-side search fallback scans before giving
28
+ # up. Personal Outlook.com rejects the server-side recipient filter, so a `to`
29
+ # search is matched locally within this window — on a large archive the default
30
+ # silently excludes older mail. Max 5000. Pair `to` with receivedAfter/
31
+ # receivedBefore rather than raising this if you can.
32
+ # OUTLOOK_SEARCH_SCAN_LIMIT=500
33
+
27
34
  # Optional: Default authentication method (device-code or browser)
28
35
  # device-code: No auth server needed, works remotely/headless
29
36
  # browser: Traditional OAuth redirect via localhost:3333
package/README.md CHANGED
@@ -164,7 +164,7 @@ npx @littlebearapps/outlook-assistant
164
164
  To check which version you have, or to see the available options:
165
165
 
166
166
  ```bash
167
- outlook-assistant --version # prints e.g. 3.11.0
167
+ outlook-assistant --version # prints e.g. 3.11.2
168
168
  outlook-assistant --help # usage, options and key environment variables
169
169
  ```
170
170
 
@@ -366,6 +366,7 @@ USE_TEST_MODE=false
366
366
  | `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone applied to calendar events when callers don't pass one (e.g. `Europe/London`, `America/New_York`). | `Australia/Melbourne` |
367
367
  | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Cap on `send-email` + `draft send` per MCP server lifetime. | unlimited |
368
368
  | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, and rule forwards. | unrestricted |
369
+ | `OUTLOOK_SEARCH_SCAN_LIMIT` | How many recent messages the client-side search fallback scans. Personal accounts match `to` locally within this window, so the default caps how far back a `to` search reaches. Max 5000. | `500` |
369
370
 
370
371
  ### MCP Client Configuration
371
372
 
@@ -523,7 +524,7 @@ USE_TEST_MODE=true npm start
523
524
  | [Getting Started](docs/how-to/getting-started/connect-outlook-to-claude.md) | Install, configure, and authenticate — start here |
524
525
  | [Azure Setup Guide](docs/guides/azure-setup.md) | Azure account creation, app registration, permissions, and secrets |
525
526
  | [How-To Guides](docs/how-to/index.md) | 29 practical guides for email, calendar, contacts, and settings |
526
- | [Roadmap](ROADMAP.md) | Active milestones (v3.11.1, v3.8.x, v3.12.0+) and recent releases |
527
+ | [Roadmap](ROADMAP.md) | Active milestones (v3.11.2, v3.8.x, v3.12.0+) and recent releases |
527
528
  | [Troubleshooting & FAQ](docs/how-to/getting-started/verify-your-connection.md#common-connection-problems) | Common problems, re-authentication, and frequently asked questions |
528
529
  | [Tools Reference](docs/quickrefs/tools-reference.md) | All 22 tools with parameters |
529
530
  | [AI Agent Guide](docs/how-to/ai-agents/using-outlook-assistant-in-agents.md) | Tool selection and workflow patterns for AI agents |
@@ -532,7 +533,8 @@ Full documentation: [docs/](docs/README.md)
532
533
 
533
534
  ## Known Limitations
534
535
 
535
- - **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. `query` mitigates this with progressive fallback (OData filters, boolean filters, then a client-side scan). Field-scoped `$search` (e.g. `subject:"…"`) is rejected outright there; since v3.10.0 `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried, but boolean operators, grouping, wildcards and other field prefixes are not — those still terminate with an explicit no-results rather than a silent broader search. Structured filters (`from`, `subject`, `to`, `receivedAfter`) remain the most direct route. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results.
536
+ - **Personal account search**: Free-text `query` and the raw `searchExpression` (formerly `kqlQuery`) rely on Microsoft's `$search` API, which has limited support on personal Outlook.com accounts. `query` mitigates this with progressive fallback (OData filters, boolean filters, then a client-side scan). Field-scoped `$search` (e.g. `subject:"…"`) is rejected outright there; since v3.10.0 `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried, but boolean operators, grouping, wildcards and other field prefixes are not — those still terminate with an explicit no-results rather than a silent broader search. Structured filters (`from`, `subject`, `to`, `receivedAfter`) remain the most direct route. Cross-folder search (`searchAllFolders: true`) returns a superset of inbox-only results. Note that `query` and `searchExpression` are not interchangeable there: `searchExpression` goes to `$search`, which matches the whole message including the body and ranks by relevance rather than date, while `query` falls back to a subject substring match that never reads bodies.
537
+ - **`to` search depth on personal accounts**: the server-side recipient filter is rejected, so `to` is matched locally over the 500 most recent messages (`OUTLOOK_SEARCH_SCAN_LIMIT`, max 5000). On a large archive that excludes older mail — pair `to` with `receivedAfter`/`receivedBefore`. Since v3.11.1 the response says so whenever the scan was truncated, whether or not it matched.
536
538
  - **Focused Inbox**: Only available on work/school Microsoft 365 accounts.
537
539
  - **Shared mailboxes**: Require `Mail.Read.Shared` permission and a work/school account.
538
540
  - **Meeting room search**: Requires `Place.Read.All` permission with admin consent (work/school accounts only).
@@ -10,6 +10,65 @@ const _config = require('../config'); // Reserved for future use
10
10
  const { callGraphAPI } = require('../utils/graph-api');
11
11
  const { ensureAuthenticated } = require('../auth');
12
12
 
13
+ const MAX_FILENAME_LENGTH = 200;
14
+
15
+ /**
16
+ * Reduce a sender-controlled attachment name to a safe basename.
17
+ * Strips any directory part (either separator), control and reserved
18
+ * characters, and leading dots, then caps the length while keeping the
19
+ * extension. Falls back to "attachment" when nothing usable remains.
20
+ * @param {string} name - Attachment name from Graph metadata
21
+ * @returns {string} - Filename safe to join onto an output directory
22
+ */
23
+ function safeAttachmentFilename(name) {
24
+ const base = String(name || '')
25
+ .split(/[\\/]/)
26
+ .pop()
27
+ // eslint-disable-next-line no-control-regex
28
+ .replace(/[\x00-\x1f\x7f]/g, '')
29
+ .replace(/[<>:"|?*]/g, '_')
30
+ .trim()
31
+ .replace(/^\.+/, '');
32
+
33
+ if (!base) return 'attachment';
34
+ if (base.length <= MAX_FILENAME_LENGTH) return base;
35
+
36
+ const ext = path.extname(base).slice(0, 20);
37
+ return base.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext;
38
+ }
39
+
40
+ /**
41
+ * Write a buffer into outputDir without ever overwriting an existing entry
42
+ * or following a symlink: `wx` fails on any existing path (including a
43
+ * dangling symlink), so collisions get a numbered suffix instead.
44
+ * @param {string} outputDir - Target directory
45
+ * @param {string} filename - Safe basename from safeAttachmentFilename
46
+ * @param {Buffer} buffer - File contents
47
+ * @returns {string} - Absolute path actually written
48
+ */
49
+ function writeUniqueFile(outputDir, filename, buffer) {
50
+ const root = path.resolve(outputDir);
51
+ const ext = path.extname(filename);
52
+ const stem = filename.slice(0, filename.length - ext.length);
53
+
54
+ for (let i = 0; i < 1000; i++) {
55
+ const candidate = path.join(
56
+ root,
57
+ i === 0 ? filename : `${stem}-${i}${ext}`
58
+ );
59
+ if (path.dirname(candidate) !== root) {
60
+ throw new Error('Refusing to write attachment outside outputDir');
61
+ }
62
+ try {
63
+ fs.writeFileSync(candidate, buffer, { flag: 'wx' });
64
+ return candidate;
65
+ } catch (error) {
66
+ if (error.code !== 'EEXIST') throw error;
67
+ }
68
+ }
69
+ throw new Error(`Too many files named ${filename} in ${root}`);
70
+ }
71
+
13
72
  /**
14
73
  * List attachments for a specific email
15
74
  * @param {object} args - Tool arguments
@@ -177,13 +236,18 @@ async function handleDownloadAttachment(args) {
177
236
  // of cwd so attachments don't silently land in the source tree
178
237
  // when the caller forgets to pass outputDir. Auto-create the
179
238
  // target directory.
239
+ // The filename is sender-controlled (GHSA-755c-c45g-69rv): reduce it
240
+ // to a safe basename and never overwrite or follow a symlink.
180
241
  const outputDir = savePath || os.tmpdir();
181
242
  fs.mkdirSync(outputDir, { recursive: true });
182
- const outputPath = path.join(outputDir, filename);
183
243
 
184
244
  // Decode base64 and save to file
185
245
  const buffer = Buffer.from(contentBytes, 'base64');
186
- fs.writeFileSync(outputPath, buffer);
246
+ const outputPath = writeUniqueFile(
247
+ outputDir,
248
+ safeAttachmentFilename(filename),
249
+ buffer
250
+ );
187
251
 
188
252
  const sizeKB = (buffer.length / 1024).toFixed(1);
189
253
 
@@ -15,6 +15,7 @@ const { getEmailFields } = require('../utils/field-presets');
15
15
  const {
16
16
  formatEmailContent,
17
17
  formatEmailsAsCSV,
18
+ stripHtml,
18
19
  VERBOSITY,
19
20
  } = require('../utils/response-formatter');
20
21
  // Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
@@ -542,16 +543,7 @@ async function handleExportConversation(args) {
542
543
  // Body content
543
544
  if (msg.body?.content) {
544
545
  if (msg.body.contentType === 'html') {
545
- // Simple HTML to text conversion
546
- const text = msg.body.content
547
- .replace(/<br\s*\/?>/gi, '\n')
548
- .replace(/<\/p>/gi, '\n\n')
549
- .replace(/<[^>]+>/g, '')
550
- .replace(/&nbsp;/g, ' ')
551
- .replace(/&lt;/g, '<')
552
- .replace(/&gt;/g, '>')
553
- .replace(/&amp;/g, '&');
554
- mdContent.push(text.trim());
546
+ mdContent.push(stripHtml(msg.body.content));
555
547
  } else {
556
548
  mdContent.push(msg.body.content);
557
549
  }
package/email/export.js CHANGED
@@ -78,26 +78,32 @@ async function handleExportEmail(args) {
78
78
  };
79
79
  }
80
80
 
81
- // Generate filename based on email metadata
82
- const timestamp = new Date(email.receivedDateTime)
83
- .toISOString()
84
- .slice(0, 10);
81
+ // Generate filename based on email metadata. The time matters: a
82
+ // date-only name collides across any same-day reply chain, and the old
83
+ // behaviour was to overwrite silently.
84
+ const timestamp = filenameTimestamp(email.receivedDateTime);
85
85
  const safeSubject = sanitizeFilename(email.subject || 'no-subject');
86
86
  const extension = getExtension(format);
87
- const defaultFilename = `${timestamp}_${safeSubject}.${extension}`;
87
+ const defaultBase = `${timestamp}_${safeSubject}`;
88
+
89
+ // Paths claimed while writing this message (main file + attachments).
90
+ const claimedPaths = new Set();
88
91
 
89
92
  // Determine final save path
90
93
  let finalPath;
91
- if (savePath) {
92
- // If savePath is a directory, append filename
93
- if (fs.existsSync(savePath) && fs.statSync(savePath).isDirectory()) {
94
- finalPath = path.join(savePath, defaultFilename);
95
- } else {
96
- finalPath = savePath;
97
- }
94
+ if (
95
+ savePath &&
96
+ !(fs.existsSync(savePath) && fs.statSync(savePath).isDirectory())
97
+ ) {
98
+ // An explicit file path is the caller's to control — honour it exactly,
99
+ // including overwriting, since that is what an explicit path means.
100
+ finalPath = savePath;
98
101
  } else {
99
- // Default to OS temp directory to avoid polluting the working directory
100
- finalPath = path.join(os.tmpdir(), defaultFilename);
102
+ // A directory (or the default temp dir) means we choose the name, so
103
+ // never clobber a file that is already there.
104
+ const dir = savePath || os.tmpdir();
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ finalPath = claimUniquePath(dir, defaultBase, extension, claimedPaths);
101
107
  }
102
108
 
103
109
  // Export based on format
@@ -152,7 +158,8 @@ async function handleExportEmail(args) {
152
158
  attachmentsSaved = await saveAttachments(
153
159
  accessToken,
154
160
  emailId,
155
- path.dirname(finalPath)
161
+ path.dirname(finalPath),
162
+ claimedPaths
156
163
  );
157
164
  }
158
165
 
@@ -372,6 +379,21 @@ async function handleBatchExportEmails(args) {
372
379
  );
373
380
  resultText += `| Total Size | ${(totalBytes / 1024).toFixed(1)} KB |\n`;
374
381
 
382
+ // Requested id -> written path, so a caller can reconcile without
383
+ // listing the directory. A batch that silently lost messages to
384
+ // filename collisions still reported "Successful N / Failed 0", and the
385
+ // loss was only ever caught by counting distinct Message-IDs by hand.
386
+ const manifest = successful.map((r) => ({
387
+ emailId: r.emailId,
388
+ filePath: r.filePath,
389
+ }));
390
+ const disambiguated = manifest.filter((entry) =>
391
+ /_\d+\.[^.]+$/.test(entry.filePath)
392
+ );
393
+ if (disambiguated.length > 0) {
394
+ resultText += `\n> ${disambiguated.length} file name(s) were disambiguated with a numeric suffix — messages sharing a timestamp and subject, or names already present in the output directory. Nothing was overwritten.\n`;
395
+ }
396
+
375
397
  if (failed.length > 0) {
376
398
  resultText += `\n### Failed Exports\n\n`;
377
399
  for (const f of failed.slice(0, 10)) {
@@ -396,6 +418,7 @@ async function handleBatchExportEmails(args) {
396
418
  successful: successful.length,
397
419
  failed: failed.length,
398
420
  totalBytes: totalBytes,
421
+ manifest,
399
422
  },
400
423
  };
401
424
  } catch (error) {
@@ -488,6 +511,8 @@ async function exportWithConcurrency(
488
511
  ) {
489
512
  const results = [];
490
513
  const inProgress = new Set();
514
+ // Shared across the batch so concurrent exports cannot claim the same path.
515
+ const claimedPaths = new Set();
491
516
  let index = 0;
492
517
 
493
518
  while (index < emailIds.length || inProgress.size > 0) {
@@ -499,7 +524,8 @@ async function exportWithConcurrency(
499
524
  emailId,
500
525
  format,
501
526
  outputDir,
502
- includeAttachments
527
+ includeAttachments,
528
+ claimedPaths
503
529
  ).then((result) => {
504
530
  inProgress.delete(promise);
505
531
  results.push(result);
@@ -526,7 +552,8 @@ async function exportSingleForBatch(
526
552
  emailId,
527
553
  format,
528
554
  outputDir,
529
- includeAttachments
555
+ includeAttachments,
556
+ claimedPaths
530
557
  ) {
531
558
  try {
532
559
  const selectFields = getEmailFields('export');
@@ -538,13 +565,15 @@ async function exportSingleForBatch(
538
565
  { $select: selectFields }
539
566
  );
540
567
 
541
- const timestamp = new Date(email.receivedDateTime)
542
- .toISOString()
543
- .slice(0, 10);
568
+ const timestamp = filenameTimestamp(email.receivedDateTime);
544
569
  const safeSubject = sanitizeFilename(email.subject || 'no-subject');
545
570
  const extension = getExtension(format);
546
- const filename = `${timestamp}_${safeSubject}.${extension}`;
547
- const filePath = path.join(outputDir, filename);
571
+ const filePath = claimUniquePath(
572
+ outputDir,
573
+ `${timestamp}_${safeSubject}`,
574
+ extension,
575
+ claimedPaths
576
+ );
548
577
 
549
578
  let content;
550
579
  if (format === EXPORT_FORMATS.MIME || format === EXPORT_FORMATS.EML) {
@@ -562,7 +591,12 @@ async function exportSingleForBatch(
562
591
  // Handle attachments if requested
563
592
  let attachmentCount = 0;
564
593
  if (includeAttachments && email.hasAttachments) {
565
- const saved = await saveAttachments(accessToken, emailId, outputDir);
594
+ const saved = await saveAttachments(
595
+ accessToken,
596
+ emailId,
597
+ outputDir,
598
+ claimedPaths
599
+ );
566
600
  attachmentCount = saved.length;
567
601
  }
568
602
 
@@ -585,7 +619,7 @@ async function exportSingleForBatch(
585
619
  /**
586
620
  * Save email attachments to directory
587
621
  */
588
- async function saveAttachments(accessToken, emailId, outputDir) {
622
+ async function saveAttachments(accessToken, emailId, outputDir, claimedPaths) {
589
623
  const saved = [];
590
624
 
591
625
  try {
@@ -602,9 +636,16 @@ async function saveAttachments(accessToken, emailId, outputDir) {
602
636
  for (const att of response.value) {
603
637
  if (att.contentBytes) {
604
638
  const safeFilename = sanitizeFilename(att.name || 'attachment');
605
- const filePath = path.join(
639
+ // `emailId.substring(0, 8)` was not a disambiguator: Graph message ids
640
+ // within one mailbox share a long common prefix, so every message's
641
+ // `invoice.pdf` resolved to the same path and all but the last were
642
+ // overwritten. Claim a unique path instead.
643
+ const { base, extension } = splitExtension(safeFilename);
644
+ const filePath = claimUniquePath(
606
645
  outputDir,
607
- `${emailId.substring(0, 8)}_${safeFilename}`
646
+ `${emailId.substring(0, 8)}_${base}`,
647
+ extension,
648
+ claimedPaths || new Set()
608
649
  );
609
650
  const buffer = Buffer.from(att.contentBytes, 'base64');
610
651
  fs.writeFileSync(filePath, buffer);
@@ -622,6 +663,63 @@ async function saveAttachments(accessToken, emailId, outputDir) {
622
663
  return saved;
623
664
  }
624
665
 
666
+ /**
667
+ * Format a message timestamp for use in a filename.
668
+ *
669
+ * Date-only was the collision: a same-day reply chain is extremely common, and
670
+ * every message in it normalises to the same `<date>_<subject>` name. Keeping
671
+ * the time disambiguates the realistic case. Mirrors the sanitisation #82
672
+ * applied to the aggregated CSV name.
673
+ *
674
+ * @param {string} isoDateTime - Message receivedDateTime
675
+ * @returns {string} - e.g. `2023-06-15T01-26-00`
676
+ */
677
+ function filenameTimestamp(isoDateTime) {
678
+ const parsed = new Date(isoDateTime);
679
+ if (Number.isNaN(parsed.getTime())) return 'undated';
680
+ return parsed.toISOString().slice(0, 19).replace(/[:.]/g, '-');
681
+ }
682
+
683
+ /**
684
+ * Claim a not-yet-used path in `outputDir`, appending `_2`, `_3`, ... until the
685
+ * name is free both on disk and among the paths already claimed in this batch.
686
+ *
687
+ * Silent overwrite is the dangerous part of the collision defect: the exporter
688
+ * reported `Successful N / Failed 0` while messages vanished. Never overwrite —
689
+ * disambiguate instead, and let the caller reconcile via the manifest.
690
+ *
691
+ * The claim is synchronous, so it is atomic with respect to the event loop and
692
+ * safe under the batch exporter's 4-way concurrency even though the write
693
+ * itself happens after an await.
694
+ *
695
+ * @param {string} outputDir - Target directory
696
+ * @param {string} base - Filename without extension
697
+ * @param {string} extension - Extension without a leading dot
698
+ * @param {Set<string>} claimed - Paths already claimed by this batch
699
+ * @returns {string} - An unused absolute path, now claimed
700
+ */
701
+ function claimUniquePath(outputDir, base, extension, claimed) {
702
+ let candidate = path.join(outputDir, `${base}.${extension}`);
703
+ let suffix = 1;
704
+ while (claimed.has(candidate) || fs.existsSync(candidate)) {
705
+ suffix += 1;
706
+ candidate = path.join(outputDir, `${base}_${suffix}.${extension}`);
707
+ }
708
+ claimed.add(candidate);
709
+ return candidate;
710
+ }
711
+
712
+ /**
713
+ * Split a filename into base and extension for collision-safe claiming.
714
+ * @param {string} name - Sanitised filename, possibly with an extension
715
+ * @returns {{base: string, extension: string}}
716
+ */
717
+ function splitExtension(name) {
718
+ const dot = name.lastIndexOf('.');
719
+ if (dot <= 0) return { base: name, extension: '' };
720
+ return { base: name.slice(0, dot), extension: name.slice(dot + 1) };
721
+ }
722
+
625
723
  /**
626
724
  * Sanitize filename for filesystem
627
725
  */
package/email/index.js CHANGED
@@ -68,12 +68,13 @@ const emailTools = [
68
68
  // Search/list params
69
69
  query: {
70
70
  type: 'string',
71
- description: 'Search query text. Omit for list mode.',
71
+ description:
72
+ 'Search query text. Omit for list mode. On personal Outlook.com accounts Graph `$search` is unavailable, so this falls back to a subject substring match (all words must appear in the subject) — precise, but it does NOT search message bodies. Use `searchExpression` when you need body content.',
72
73
  },
73
74
  searchExpression: {
74
75
  type: 'string',
75
76
  description:
76
- 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there.',
77
+ 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there. RELEVANCE, NOT RECENCY: an untranslated expression is answered by Graph `$search` over the whole message including the body, ranked by relevance and not sorted by date, so top hits can look unrelated to a caller expecting a subject match. `query` is the more predictable choice for a term you expect in a subject line; `searchExpression` is the one that reaches body text.',
77
78
  },
78
79
  kqlQuery: {
79
80
  type: 'string',
@@ -90,7 +91,8 @@ const emailTools = [
90
91
  },
91
92
  to: {
92
93
  type: 'string',
93
- description: 'Filter by recipient email/name',
94
+ description:
95
+ 'Filter by recipient email/name. Personal Outlook.com accounts reject the server-side recipient filter, in which case this is matched locally over the 500 most recent messages only (raise with `OUTLOOK_SEARCH_SCAN_LIMIT`). On a large archive, pair `to` with `receivedAfter`/`receivedBefore` to reach older mail; the response says so when the scan was truncated.',
94
96
  },
95
97
  subject: {
96
98
  type: 'string',
package/email/search.js CHANGED
@@ -79,9 +79,19 @@ async function handleSearchEmails(args) {
79
79
  // this, a `from`+`to` search would drop every row at default verbosity and
80
80
  // return "No emails found" — making `outputVerbosity`, a presentation
81
81
  // parameter, decide which messages are found.
82
+ //
83
+ // A SINGLE term needs the richer preset too when it is `to` or `query`. The
84
+ // date/boolean rung applies no search term server-side, so it narrows by
85
+ // every supplied term locally — and `filterToClientSide` reads
86
+ // `toRecipients` while `filterQueryClientSide` reads `bodyPreview`, neither
87
+ // of which the `list` preset requests. Left lean, those matchers see
88
+ // `undefined` on every row and drop the entire result set.
82
89
  const searchTermCount = [query, from, to, subject].filter(Boolean).length;
90
+ const needsMatcherFields = Boolean(to) || Boolean(query);
83
91
  const selectFields = getEmailFields(
84
- verbosity === VERBOSITY.FULL || searchTermCount > 1 ? 'search' : 'list'
92
+ verbosity === VERBOSITY.FULL || searchTermCount > 1 || needsMatcherFields
93
+ ? 'search'
94
+ : 'list'
85
95
  );
86
96
 
87
97
  try {
@@ -1300,9 +1310,20 @@ function addBooleanFilters(params, filterTerms) {
1300
1310
  }
1301
1311
  }
1302
1312
 
1303
- // Add $filter parameter if we have any filter conditions
1313
+ // AND onto any $filter the caller already built — never replace it.
1314
+ //
1315
+ // The single-term rung sets `$filter` from the search term (e.g.
1316
+ // `toRecipients/any(...)`) and then calls this to add the date/boolean
1317
+ // window. Assigning here silently dropped that term, so a `to` + date-window
1318
+ // search issued a DATE-ONLY request and returned the whole window labelled
1319
+ // `single-term-to` with `appliedTerms: ['to']` — a superset presented as a
1320
+ // filtered result. Affected `from`, `to`, `subject` and `query` alike.
1321
+ //
1322
+ // Every condition either side is a conjunct, so a flat ' and ' join is
1323
+ // sound; there is no top-level `or` that would need parenthesising.
1304
1324
  if (filterConditions.length > 0) {
1305
- params.$filter = filterConditions.join(' and ');
1325
+ const added = filterConditions.join(' and ');
1326
+ params.$filter = params.$filter ? `${params.$filter} and ${added}` : added;
1306
1327
  }
1307
1328
  }
1308
1329
 
@@ -1506,6 +1527,17 @@ function formatSearchResults(response, folder, verbosity, searchAllFolders) {
1506
1527
  } else {
1507
1528
  searchNote = `\n\n_Search strategy: ${strategy}_`;
1508
1529
  }
1530
+
1531
+ // A local scan that filled its budget did not see the whole mailbox, so
1532
+ // these results are a bounded sample rather than the complete set. #231
1533
+ // says so only when the search returns nothing; a truncated scan that
1534
+ // DID match is exactly as incomplete and reads as authoritative. On a
1535
+ // large archive that silently caps historical searches.
1536
+ if (response._searchInfo.truncated) {
1537
+ const scanned = response._searchInfo.candidatesScanned;
1538
+ const limit = response._searchInfo.scanLimit;
1539
+ searchNote += `\n\n> **Partial coverage**: matched locally within the ${scanned} most recent messages, hitting the ${limit} scan limit — older matches were not seen. Narrow with \`receivedAfter\`/\`receivedBefore\` to search further back.`;
1540
+ }
1509
1541
  }
1510
1542
 
1511
1543
  // Format results using shared formatter
package/llms.txt CHANGED
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
79
79
  - [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
80
80
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
81
81
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
82
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.0 — fixes & polish: `--version`/`--help` CLI flags, where previously any argument was ignored and the server booted and hung on stdin (#68); `AADSTS7000215` (the Secret ID vs Secret Value mistake) now explains itself instead of passing Microsoft's raw error through, via one shared AADSTS hint table (#69); token-refresh round trip covered end to end from disk to the `Authorization` header on the next Graph call (#72); all 17 development-dependency advisories cleared, so `npm audit` reports zero at every severity. Preceded by v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset with `searchMetadata.droppedFilters` reporting anything unhonoured (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231) — and v3.9.1, a packaging hotfix restoring `request-handler.js` in the published tarball (#223))
83
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.1 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
82
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2), HTML-to-text entity double-decoding fixed, `npm audit` at 0. Preceded by v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
83
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.2 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
84
84
  - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebearapps/outlook-assistant",
3
- "version": "3.11.0",
3
+ "version": "3.11.2",
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",
@@ -100,8 +100,8 @@
100
100
  "minimatch": ">=3.1.3",
101
101
  "hono": "^4.13.7",
102
102
  "@hono/node-server": "^1.19.17",
103
- "fast-uri": "^3.1.7",
104
- "ip-address": "^10.7.0",
103
+ "fast-uri": "^3.1.8",
104
+ "ip-address": "^10.7.2",
105
105
  "qs": "^6.16.0",
106
106
  "body-parser": "^2.3.0"
107
107
  }
@@ -5,6 +5,38 @@ const https = require('https');
5
5
  const config = require('../config');
6
6
  const mockData = require('./mock-data');
7
7
 
8
+ /**
9
+ * Guard for caller-supplied full URLs (nextLink/deltaLink continuations).
10
+ * The bearer token is attached to every request, so only the configured
11
+ * Graph host over HTTPS on the default port is allowed (GHSA-mqfm-wfjq-jxq2).
12
+ * @param {string} url - Full URL about to be requested
13
+ * @throws {Error} If the URL is malformed or not the Graph host
14
+ */
15
+ function assertGraphUrl(url) {
16
+ const allowed = new URL(config.GRAPH_API_ENDPOINT);
17
+ let target;
18
+ try {
19
+ target = new URL(url);
20
+ } catch {
21
+ target = null;
22
+ }
23
+
24
+ const ok =
25
+ target &&
26
+ target.protocol === 'https:' &&
27
+ target.hostname === allowed.hostname &&
28
+ target.port === '' &&
29
+ target.username === '' &&
30
+ target.password === '';
31
+
32
+ if (!ok) {
33
+ throw new Error(
34
+ 'Refusing to call non-Graph URL: continuation links must be https://' +
35
+ `${allowed.hostname}/ (check the deltaToken or nextLink value)`
36
+ );
37
+ }
38
+ }
39
+
8
40
  /**
9
41
  * Makes a request to the Microsoft Graph API
10
42
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
@@ -36,6 +68,7 @@ async function callGraphAPI(
36
68
  let finalUrl;
37
69
  if (path.startsWith('http://') || path.startsWith('https://')) {
38
70
  // Path is already a full URL (from pagination nextLink)
71
+ assertGraphUrl(path);
39
72
  finalUrl = path;
40
73
  } else {
41
74
  // Build URL from path and queryParams
@@ -467,20 +467,54 @@ function truncateText(text, maxLength) {
467
467
  return `${text.substring(0, maxLength - 3)}...`;
468
468
  }
469
469
 
470
+ const HTML_ENTITIES = {
471
+ nbsp: ' ',
472
+ amp: '&',
473
+ lt: '<',
474
+ gt: '>',
475
+ quot: '"',
476
+ apos: "'",
477
+ '#39': "'",
478
+ };
479
+
480
+ /**
481
+ * Removes tags in one linear scan: `<…>` with no `<` inside is dropped
482
+ * whole, and any other `<` (nested fragments such as `<scr<script>ipt>`,
483
+ * or an unterminated tag) is dropped on its own, so no markup can
484
+ * reassemble. A scanner rather than a repeated regex replace keeps deeply
485
+ * nested input like `<<<…x…>>>` linear.
486
+ * @param {string} text - HTML fragment
487
+ * @returns {string} - Text with no `<` remaining
488
+ */
489
+ function removeTags(text) {
490
+ let out = '';
491
+ let i = 0;
492
+ while (i < text.length) {
493
+ const lt = text.indexOf('<', i);
494
+ if (lt === -1) {
495
+ out += text.slice(i);
496
+ break;
497
+ }
498
+ out += text.slice(i, lt);
499
+ // Scan only to the next '<' or '>', so each character is visited once.
500
+ let j = lt + 1;
501
+ while (j < text.length && text[j] !== '<' && text[j] !== '>') j++;
502
+ i = text[j] === '>' ? j + 1 : lt + 1;
503
+ }
504
+ return out;
505
+ }
506
+
470
507
  /**
471
- * Strips HTML tags (simple implementation)
508
+ * Strips HTML tags (simple implementation), in linear time.
509
+ * Entities are decoded after tag removal, in a single pass, so `&amp;lt;`
510
+ * becomes `&lt;`, not `<`.
472
511
  */
473
512
  function stripHtml(html) {
474
513
  if (!html) return '';
475
- return html
476
- .replace(/<br\s*\/?>/gi, '\n')
477
- .replace(/<\/p>/gi, '\n\n')
478
- .replace(/<[^>]*>/g, '')
479
- .replace(/&nbsp;/g, ' ')
480
- .replace(/&amp;/g, '&')
481
- .replace(/&lt;/g, '<')
482
- .replace(/&gt;/g, '>')
483
- .replace(/&quot;/g, '"')
514
+ return removeTags(
515
+ html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n\n')
516
+ )
517
+ .replace(/&(nbsp|amp|lt|gt|quot|apos|#39);/g, (_, e) => HTML_ENTITIES[e])
484
518
  .replace(/\n{3,}/g, '\n\n')
485
519
  .trim();
486
520
  }