@littlebearapps/outlook-assistant 3.7.2 → 3.7.4

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
@@ -33,7 +33,12 @@ async function handleSearchEmails(args) {
33
33
  };
34
34
  }
35
35
 
36
- const requestedCount = args.count ?? DEFAULT_LIMITS.searchEmails; // Default 10
36
+ // F-17: accept `maxResults` as an alias for `count` in non-delta mode.
37
+ // The schema declares both, but `maxResults` was only consumed by
38
+ // the delta path, so callers passing `maxResults=5` to a normal
39
+ // search saw their override silently ignored.
40
+ const requestedCount =
41
+ args.count ?? args.maxResults ?? DEFAULT_LIMITS.searchEmails;
37
42
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
38
43
  const query = args.query || '';
39
44
  const from = args.from || '';
@@ -122,16 +127,40 @@ async function progressiveSearch(
122
127
  // Track search strategies attempted
123
128
  const searchAttempts = [];
124
129
 
125
- // 0. If raw KQL query provided, use it directly
130
+ // 0. If raw KQL query provided, use it directly. The kqlQuery branch
131
+ // *terminates* — if Graph returns 0 (or throws), we surface that
132
+ // explicitly rather than falling through to combined-search, which
133
+ // would drop the user's filter and return unrelated recent emails
134
+ // with a misleading "combined-search" strategy line. (#169)
126
135
  if (searchTerms.kqlQuery) {
127
136
  try {
128
- console.error(`Attempting raw KQL search: "${searchTerms.kqlQuery}"`);
137
+ // Pass the user's KQL through as-is. The user is responsible for
138
+ // their own phrase quoting (e.g. `subject:"foo bar"`); we do NOT
139
+ // auto-wrap, which previously produced broken nested quotes like
140
+ // `"subject:"foo bar""` on Graph $search and silently returned
141
+ // recent unfiltered messages. (#169 V37-F-1)
142
+ const trimmedKql = searchTerms.kqlQuery.trim();
143
+ const alreadyQuoted =
144
+ trimmedKql.startsWith('"') && trimmedKql.endsWith('"');
145
+ const looksLikeExpression =
146
+ trimmedKql.includes(':') || /\s/.test(trimmedKql);
147
+ // Already-quoted phrases and KQL-looking expressions (field syntax
148
+ // or multi-word) are passed through as-is; only bare single tokens
149
+ // are wrapped so Graph treats them as phrase searches.
150
+ let kqlForSearch;
151
+ if (alreadyQuoted || looksLikeExpression) {
152
+ kqlForSearch = trimmedKql;
153
+ } else {
154
+ kqlForSearch = `"${trimmedKql}"`;
155
+ }
156
+
157
+ console.error(`Attempting raw KQL search: ${kqlForSearch}`);
129
158
  searchAttempts.push('raw-kql');
130
159
 
131
160
  const kqlParams = {
132
161
  $top: Math.min(50, maxCount),
133
162
  $select: selectFields,
134
- $search: `"${searchTerms.kqlQuery}"`,
163
+ $search: kqlForSearch,
135
164
  };
136
165
 
137
166
  const response = await callGraphAPIPaginated(
@@ -141,20 +170,40 @@ async function progressiveSearch(
141
170
  kqlParams,
142
171
  maxCount
143
172
  );
144
- if (response.value && response.value.length > 0) {
145
- console.error(
146
- `Raw KQL search successful: found ${response.value.length} results`
147
- );
148
- response._searchInfo = {
173
+ console.error(
174
+ `Raw KQL search complete: ${response.value?.length || 0} results`
175
+ );
176
+ const matched = response.value?.length || 0;
177
+ response._searchInfo = {
178
+ attemptsCount: searchAttempts.length,
179
+ strategies: searchAttempts,
180
+ originalTerms: searchTerms,
181
+ filterTerms: filterTerms,
182
+ kqlApplied: kqlForSearch,
183
+ // noResults flips on the helpful "Suggestions" block in the
184
+ // formatter — without it, an empty kqlQuery result would render
185
+ // the bare "No emails found matching your search criteria" line
186
+ // with no guidance.
187
+ noResults: matched === 0,
188
+ };
189
+ // Always return — never silently fall through to a path that
190
+ // would ignore kqlQuery and return unrelated emails.
191
+ return response;
192
+ } catch (error) {
193
+ console.error(`Raw KQL search failed: ${error.message}`);
194
+ // Surface the failure rather than masking it with unrelated results.
195
+ searchAttempts.push('raw-kql-error');
196
+ return {
197
+ value: [],
198
+ _searchInfo: {
149
199
  attemptsCount: searchAttempts.length,
150
200
  strategies: searchAttempts,
151
201
  originalTerms: searchTerms,
152
202
  filterTerms: filterTerms,
153
- };
154
- return response;
155
- }
156
- } catch (error) {
157
- console.error(`Raw KQL search failed: ${error.message}`);
203
+ kqlError: error.message,
204
+ noResults: true,
205
+ },
206
+ };
158
207
  }
159
208
  }
160
209
 
@@ -566,18 +615,26 @@ function filterToClientSide(messages, toValue) {
566
615
  * @returns {Array} - Filtered messages matching the query
567
616
  */
568
617
  function filterQueryClientSide(messages, queryText) {
569
- const queryLower = queryText.toLowerCase();
618
+ // F-12: split multi-word queries on whitespace and require ALL words
619
+ // to be present (AND search). Previous behaviour was substring match
620
+ // on the literal phrase, which missed the common case where the
621
+ // user types e.g. "github token" expecting it to find a subject
622
+ // like "[GitHub] Your fine-grained personal access token".
623
+ const queryLower = queryText.toLowerCase().trim();
624
+ if (!queryLower) return messages;
625
+ const words = queryLower.split(/\s+/).filter(Boolean);
626
+
570
627
  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
- );
628
+ const haystack = [
629
+ m.subject,
630
+ m.bodyPreview,
631
+ m.from?.emailAddress?.address,
632
+ m.from?.emailAddress?.name,
633
+ ]
634
+ .filter(Boolean)
635
+ .join(' ')
636
+ .toLowerCase();
637
+ return words.every((w) => haystack.includes(w));
581
638
  });
582
639
  }
583
640
 
package/folder/create.js CHANGED
@@ -43,6 +43,9 @@ async function handleCreateFolder(args) {
43
43
  text: result.message,
44
44
  },
45
45
  ],
46
+ // F-31: surface the folder ID in _meta so callers can chain
47
+ // create→move→stats without an extra `folders list` round-trip.
48
+ ...(result.folderId && { _meta: { folderId: result.folderId } }),
46
49
  };
47
50
  } catch (error) {
48
51
  if (error.message === 'Authentication required') {
@@ -118,7 +121,9 @@ async function createMailFolder(accessToken, folderName, parentFolderName) {
118
121
 
119
122
  return {
120
123
  success: true,
121
- message: `Successfully created folder "${folderName}" ${locationInfo}.`,
124
+ // F-31: include the ID in the human-readable message too so it
125
+ // shows up for AI agents that don't read _meta.
126
+ message: `Successfully created folder "${folderName}" ${locationInfo}.\n\n**ID**: ${response.id}`,
122
127
  folderId: response.id,
123
128
  };
124
129
  } else {
package/folder/index.js CHANGED
@@ -81,6 +81,7 @@ const folderTools = [
81
81
  'Folder name to delete — resolved to ID (action=delete). Cannot delete protected folders (Inbox, Drafts, Sent, etc.)',
82
82
  },
83
83
  },
84
+ additionalProperties: false,
84
85
  required: [],
85
86
  },
86
87
  handler: async (args) => {
@@ -95,8 +96,16 @@ const folderTools = [
95
96
  case 'delete':
96
97
  return handleDeleteFolder(args);
97
98
  case 'list':
98
- default:
99
99
  return handleListFolders(args);
100
+ default:
101
+ return {
102
+ content: [
103
+ {
104
+ type: 'text',
105
+ text: `Unknown action '${action}'. Valid actions: list, create, move, stats, delete.`,
106
+ },
107
+ ],
108
+ };
100
109
  }
101
110
  },
102
111
  },
package/index.js CHANGED
@@ -10,6 +10,7 @@ const {
10
10
  StdioServerTransport,
11
11
  } = require('@modelcontextprotocol/sdk/server/stdio.js');
12
12
  const config = require('./config');
13
+ const { coerceArgsAgainstSchema } = require('./utils/schema-coerce');
13
14
 
14
15
  // Import module tools
15
16
  const { authTools, setToolCount } = require('./auth');
@@ -26,6 +27,19 @@ const { advancedTools } = require('./advanced');
26
27
  console.error(`STARTING ${config.SERVER_NAME.toUpperCase()} MCP SERVER`);
27
28
  console.error(`Test mode is ${config.USE_TEST_MODE ? 'enabled' : 'disabled'}`);
28
29
 
30
+ // F-1 / F-48: warn at startup when safety belts are unset. Mirrors the
31
+ // warning surfaced by `auth action=about`. Visible to operators reading
32
+ // stderr; AI clients reading the JSON-RPC stream are unaffected.
33
+ if (
34
+ !process.env.OUTLOOK_MAX_EMAILS_PER_SESSION &&
35
+ !process.env.OUTLOOK_ALLOWED_RECIPIENTS &&
36
+ !config.USE_TEST_MODE
37
+ ) {
38
+ console.error(
39
+ '⚠ Safety belts not configured. Consider setting OUTLOOK_MAX_EMAILS_PER_SESSION and OUTLOOK_ALLOWED_RECIPIENTS in your .mcp.json env block for safer AI-assisted sending. See `auth action=about` for details.'
40
+ );
41
+ }
42
+
29
43
  // Combine all tools
30
44
  const TOOLS = [
31
45
  ...authTools,
@@ -110,6 +124,25 @@ server.fallbackRequestHandler = async (request) => {
110
124
  const tool = TOOLS.find((t) => t.name === name);
111
125
 
112
126
  if (tool && tool.handler) {
127
+ // Coerce + validate args against the tool's inputSchema before
128
+ // dispatching. Catches array-as-string, boolean-as-string, unknown
129
+ // params, and out-of-enum action values at the MCP boundary so
130
+ // handlers receive properly-typed JS values. (#160, #162)
131
+ if (tool.inputSchema) {
132
+ const coerced = coerceArgsAgainstSchema(args, tool.inputSchema);
133
+ if (coerced.error) {
134
+ return {
135
+ content: [
136
+ {
137
+ type: 'text',
138
+ text: `Invalid arguments for tool '${name}':\n${coerced.error}`,
139
+ },
140
+ ],
141
+ isError: true,
142
+ };
143
+ }
144
+ return await tool.handler(coerced.args);
145
+ }
113
146
  return await tool.handler(args);
114
147
  }
115
148
 
package/llms.txt CHANGED
@@ -22,7 +22,8 @@ Built by [Little Bear Apps](https://littlebearapps.com).
22
22
 
23
23
  ## Key Differentiators
24
24
 
25
- - **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently
25
+ - **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback.
26
+ - **Remote-friendly auth**: Device code flow (default) — no auth server, no port forwarding, no SSH tunnels. State persists across MCP server restarts. Works from Untether, mosh, SSH, and headless environments.
26
27
  - **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
27
28
  - **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
28
29
  - **Batch operations**: Flag, move, export, or categorise multiple emails in a single tool call; search-driven export for batch archiving without collecting IDs
@@ -72,7 +73,12 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
72
73
 
73
74
  - [README](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/README.md): Full documentation including setup, Azure configuration, and usage
74
75
  - [Tools Reference](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/quickrefs/tools-reference.md): All 22 tools with parameters and safety annotations
76
+ - [Connect Outlook to Claude](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/connect-outlook-to-claude.md): Step-by-step setup guide for Claude Desktop / Claude Code
77
+ - [Verify Your Connection](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/verify-your-connection.md): Test and troubleshoot the connection after installation
78
+ - [Azure Setup](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/guides/azure-setup.md): Azure app registration and API permissions walkthrough
79
+ - [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/index.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
75
80
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
76
81
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
77
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history
82
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.7.4 — patch release closing two regressions surfaced by an independent v3.7.3 E2E re-verification: F-24 chokepoint now catches JSON-stringified arrays from MCP transport (#168) and search-emails kqlQuery no longer silently drops on Step 0 fall-through (#169))
83
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.7.5 polish, v3.8.0 task integration & auth, v3.9.0 new Graph APIs)
78
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.7.2",
3
+ "version": "3.7.4",
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",
package/rules/create.js CHANGED
@@ -150,7 +150,10 @@ async function handleCreateRule(args) {
150
150
  );
151
151
 
152
152
  if (response && response.id) {
153
- let text = `Successfully created rule "${name}" with sequence ${ruleSequence}.`;
153
+ // F-43: include the rule ID. update/delete accept ruleName so this
154
+ // is workable, but ID is more reliable when names contain unicode
155
+ // or duplicates exist.
156
+ let text = `Successfully created rule "${name}" with sequence ${ruleSequence}.\n\n**ID**: ${response.id}`;
154
157
  if (allWarnings.length > 0) {
155
158
  text += `\n\nNotes:\n${allWarnings.map((w) => `- ${w}`).join('\n')}`;
156
159
  }
@@ -160,6 +163,7 @@ async function handleCreateRule(args) {
160
163
  }
161
164
  return {
162
165
  content: [{ type: 'text', text }],
166
+ _meta: { ruleId: response.id },
163
167
  };
164
168
  }
165
169
 
package/rules/index.js CHANGED
@@ -209,6 +209,11 @@ const rulesTools = [
209
209
  description:
210
210
  'Rule name (action=create required, action=update to rename)',
211
211
  },
212
+ displayName: {
213
+ type: 'string',
214
+ description:
215
+ "Alias for `name` (matches Graph's own `displayName` field).",
216
+ },
212
217
  dryRun: {
213
218
  type: 'boolean',
214
219
  description:
@@ -378,10 +383,15 @@ const rulesTools = [
378
383
  description: 'ID of existing rule (action=update/delete)',
379
384
  },
380
385
  },
386
+ additionalProperties: false,
381
387
  required: [],
382
388
  },
383
389
  handler: async (args) => {
384
390
  const action = args.action || 'list';
391
+ // F-41: accept Graph's own `displayName` as alias for `name`.
392
+ if (args.displayName && !args.name) {
393
+ args = { ...args, name: args.displayName };
394
+ }
385
395
  switch (action) {
386
396
  case 'create':
387
397
  return handleCreateRule(args);
@@ -392,8 +402,16 @@ const rulesTools = [
392
402
  case 'delete':
393
403
  return handleDeleteRule(args);
394
404
  case 'list':
395
- default:
396
405
  return handleListRules(args);
406
+ default:
407
+ return {
408
+ content: [
409
+ {
410
+ type: 'text',
411
+ text: `Unknown action '${action}'. Valid actions: list, create, update, reorder, delete.`,
412
+ },
413
+ ],
414
+ };
397
415
  }
398
416
  },
399
417
  },
package/rules/update.js CHANGED
@@ -163,11 +163,19 @@ async function handleUpdateRule(args) {
163
163
 
164
164
  const changedFields = Object.keys(patch)
165
165
  .map((k) => {
166
- if (k === 'displayName') return `name → "${patch.displayName}"`;
166
+ if (k === 'displayName') {
167
+ return `name: "${currentRule.displayName}" → "${patch.displayName}"`;
168
+ }
167
169
  if (k === 'isEnabled') {
168
- return patch.isEnabled ? 'enabled' : 'disabled';
170
+ // Show explicit before/after to remove the F-45 ambiguity:
171
+ // previously rendered as bare "enabled"/"disabled" with no
172
+ // indication of direction, so callers couldn't tell whether
173
+ // the rule was enabled or whether the action just succeeded.
174
+ return `isEnabled: ${currentRule.isEnabled} → ${patch.isEnabled}`;
175
+ }
176
+ if (k === 'sequence') {
177
+ return `sequence: ${currentRule.sequence} → ${patch.sequence}`;
169
178
  }
170
- if (k === 'sequence') return `sequence → ${patch.sequence}`;
171
179
  if (k === 'conditions') return 'conditions updated';
172
180
  if (k === 'actions') return 'actions updated';
173
181
  if (k === 'exceptions') return 'exceptions updated';
package/settings/index.js CHANGED
@@ -227,12 +227,27 @@ async function handleSetAutomaticReplies(args) {
227
227
 
228
228
  // Build the settings object
229
229
  const settings = {};
230
+ let requestedStatus = null;
230
231
 
231
232
  // Determine status
232
233
  if (enabled === false) {
233
234
  settings.status = 'disabled';
235
+ requestedStatus = 'disabled';
236
+ // F-6: Graph keeps schedule timestamps when transitioning out of
237
+ // 'scheduled' mode unless they're explicitly cleared, which leaves
238
+ // status stuck at 'scheduled'. Reset both to the unix epoch so the
239
+ // disable actually applies. Graph rejects null here.
240
+ settings.scheduledStartDateTime = {
241
+ dateTime: '1970-01-01T00:00:00.000',
242
+ timeZone: 'UTC',
243
+ };
244
+ settings.scheduledEndDateTime = {
245
+ dateTime: '1970-01-01T00:00:00.000',
246
+ timeZone: 'UTC',
247
+ };
234
248
  } else if (startDateTime && endDateTime) {
235
249
  settings.status = 'scheduled';
250
+ requestedStatus = 'scheduled';
236
251
  settings.scheduledStartDateTime = {
237
252
  dateTime: new Date(startDateTime).toISOString(),
238
253
  timeZone: 'UTC',
@@ -243,6 +258,7 @@ async function handleSetAutomaticReplies(args) {
243
258
  };
244
259
  } else if (enabled === true) {
245
260
  settings.status = 'alwaysEnabled';
261
+ requestedStatus = 'alwaysEnabled';
246
262
  }
247
263
 
248
264
  // Reply messages
@@ -269,6 +285,21 @@ async function handleSetAutomaticReplies(args) {
269
285
  settings.externalAudience = externalAudience;
270
286
  }
271
287
 
288
+ // F-4: refuse to claim "updated" when nothing meaningful changed.
289
+ // Catches the misleading-success case where the caller passed only
290
+ // externalAudience without enabled/scheduled — previously the wrapper
291
+ // announced "Automatic replies updated!" with no actual state change.
292
+ if (Object.keys(settings).length === 0) {
293
+ return {
294
+ content: [
295
+ {
296
+ type: 'text',
297
+ text: 'No automatic-reply settings were provided. To change state, pass `enabled: true|false` or `startDateTime` + `endDateTime`. To update messages or audience, pass `internalReplyMessage`, `externalReplyMessage`, or `externalAudience`.',
298
+ },
299
+ ],
300
+ };
301
+ }
302
+
272
303
  // Apply settings
273
304
  await callGraphAPI(accessToken, 'PATCH', 'me/mailboxSettings', {
274
305
  automaticRepliesSetting: settings,
@@ -282,9 +313,37 @@ async function handleSetAutomaticReplies(args) {
282
313
  );
283
314
 
284
315
  const output = [];
285
- output.push('Automatic replies updated!\n');
316
+ if (requestedStatus) {
317
+ output.push('Automatic replies updated!\n');
318
+ } else {
319
+ // F-4: no status-changing param was provided. Spell out exactly
320
+ // which fields the PATCH carried so the caller doesn't think
321
+ // the status flipped silently.
322
+ const otherFields = Object.keys(settings).join(', ');
323
+ output.push(
324
+ `Updated automatic-reply settings (${otherFields}). No status change applied — pass \`enabled\` or \`startDateTime\`+\`endDateTime\` to change state.\n`
325
+ );
326
+ }
286
327
  output.push(formatAutomaticReplies(updated));
287
328
 
329
+ // F-7: Graph silently coerces alwaysEnabled → disabled on personal
330
+ // Outlook.com accounts (no error returned). Detect divergence
331
+ // between requested and post-PATCH state and surface it to the
332
+ // caller so they can correct the call.
333
+ if (requestedStatus && updated.status !== requestedStatus) {
334
+ let hint = '';
335
+ if (
336
+ requestedStatus === 'alwaysEnabled' &&
337
+ updated.status === 'disabled'
338
+ ) {
339
+ hint =
340
+ ' Personal Outlook.com accounts only support `scheduled` mode — provide `startDateTime` + `endDateTime` instead of `enabled: true` alone.';
341
+ }
342
+ output.push(
343
+ `\n**⚠ Warning**: Requested status \`${requestedStatus}\` but Graph applied \`${updated.status}\`.${hint}`
344
+ );
345
+ }
346
+
288
347
  // Warn if enabling without messages
289
348
  if (
290
349
  updated.status !== 'disabled' &&
@@ -641,6 +700,7 @@ const settingsTools = [
641
700
  "Time zone name, e.g. 'Australia/Melbourne' (action=set-working-hours)",
642
701
  },
643
702
  },
703
+ additionalProperties: false,
644
704
  required: [],
645
705
  },
646
706
  handler: async (args) => {
@@ -651,8 +711,16 @@ const settingsTools = [
651
711
  case 'set-working-hours':
652
712
  return handleSetWorkingHours(args);
653
713
  case 'get':
654
- default:
655
714
  return handleGetMailboxSettings(args);
715
+ default:
716
+ return {
717
+ content: [
718
+ {
719
+ type: 'text',
720
+ text: `Unknown action '${action}'. Valid actions: get, set-auto-replies, set-working-hours.`,
721
+ },
722
+ ],
723
+ };
656
724
  }
657
725
  },
658
726
  },
@@ -261,6 +261,11 @@ function formatEmailContent(
261
261
  body = email.bodyPreview || 'No content';
262
262
  }
263
263
 
264
+ // F-16: strip tracking-pixel zero-width chars before returning. These
265
+ // serve no purpose for AI consumption and can run into the hundreds
266
+ // per message, bloating token usage.
267
+ body = stripZeroWidth(body);
268
+
264
269
  // Truncate if needed (unless full verbosity requested)
265
270
  if (verbosity !== VERBOSITY.FULL) {
266
271
  const truncated = truncateWithMeta(body, DEFAULT_LIMITS.maxBodyTruncation);
@@ -480,6 +485,27 @@ function stripHtml(html) {
480
485
  .trim();
481
486
  }
482
487
 
488
+ /**
489
+ * Strip zero-width characters and their HTML entity equivalents
490
+ * (F-16). Mailers inject hundreds of these to defeat Gmail clipping
491
+ * and threading; they bloat token usage and confuse AI consumers
492
+ * without adding any signal. Removes:
493
+ *
494
+ * - U+200B..U+200F (zero-width space, joiner, non-joiner, RTL/LTR
495
+ * marks)
496
+ * - U+FEFF (BOM)
497
+ * - U+2060 (word joiner)
498
+ * - HTML decimal entities: &#8203;..&#8207;, &#8288;, &#65279;
499
+ * - HTML named entities: &zwj;, &zwnj;, &lrm;, &rlm;
500
+ */
501
+ function stripZeroWidth(text) {
502
+ if (!text) return text;
503
+ return text
504
+ .replace(/[\u200B-\u200F\u2060\uFEFF]+/g, '')
505
+ .replace(/&#(8203|8204|8205|8206|8207|8288|65279);/g, '')
506
+ .replace(/&(zwj|zwnj|lrm|rlm);/g, '');
507
+ }
508
+
483
509
  function escapeCSV(value) {
484
510
  if (value === null || value === undefined) return '';
485
511
  const str = String(value);
@@ -520,5 +546,6 @@ module.exports = {
520
546
  formatRecipients,
521
547
  truncateText,
522
548
  stripHtml,
549
+ stripZeroWidth,
523
550
  escapeCSV,
524
551
  };