@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/README.md +35 -18
- package/advanced/index.js +26 -3
- package/auth/tools.js +54 -1
- package/calendar/index.js +27 -1
- package/categories/index.js +67 -28
- package/contacts/index.js +118 -27
- package/email/attachments.js +13 -3
- package/email/conversations.js +4 -7
- package/email/delta.js +20 -6
- package/email/export.js +26 -3
- package/email/index.js +38 -4
- package/email/list.js +6 -1
- package/email/mail-tips.js +26 -2
- package/email/search.js +82 -25
- package/folder/create.js +6 -1
- package/folder/index.js +10 -1
- package/index.js +33 -0
- package/llms.txt +8 -2
- package/package.json +1 -1
- package/rules/create.js +5 -1
- package/rules/index.js +19 -1
- package/rules/update.js +11 -3
- package/settings/index.js +70 -2
- package/utils/response-formatter.js +27 -0
- package/utils/schema-coerce.js +248 -0
package/email/search.js
CHANGED
|
@@ -33,7 +33,12 @@ async function handleSearchEmails(args) {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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')
|
|
166
|
+
if (k === 'displayName') {
|
|
167
|
+
return `name: "${currentRule.displayName}" → "${patch.displayName}"`;
|
|
168
|
+
}
|
|
167
169
|
if (k === 'isEnabled') {
|
|
168
|
-
|
|
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
|
-
|
|
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: ​..‏, ⁠, 
|
|
499
|
+
* - HTML named entities: ‍, ‌, ‎, ‏
|
|
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
|
};
|