@littlebearapps/outlook-assistant 3.7.1 → 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/token-storage.js +18 -6
- package/auth/tools.js +121 -4
- 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/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
|
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP boundary schema coercion + validation.
|
|
3
|
+
*
|
|
4
|
+
* Some MCP clients deliver array/boolean/number params as strings (the
|
|
5
|
+
* JSON-RPC marshalling differs across clients). Handlers were written
|
|
6
|
+
* assuming JS-typed values, so these arrive broken — arrays iterate
|
|
7
|
+
* character-by-character, `=== true` fails on `'true'`, etc.
|
|
8
|
+
*
|
|
9
|
+
* This module walks each tool's `inputSchema.properties` once at the
|
|
10
|
+
* MCP entry point and coerces incoming values into the declared types.
|
|
11
|
+
* It also enforces `additionalProperties: false`, top-level enum
|
|
12
|
+
* constraints, and `required`. Anything that fails coercion or
|
|
13
|
+
* validation gets surfaced as an MCP error response before the
|
|
14
|
+
* handler is invoked.
|
|
15
|
+
*
|
|
16
|
+
* Tracks GH #160 (param-shape mismatches) and #162 (unknown-action
|
|
17
|
+
* fallthrough — enums caught here instead of in switch defaults).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
class CoercionError extends Error {
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'CoercionError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Coerce a single value against a JSON schema fragment. Mutates nothing.
|
|
29
|
+
* Throws CoercionError on type mismatch that can't be resolved.
|
|
30
|
+
*/
|
|
31
|
+
function coerceValue(value, schema, path) {
|
|
32
|
+
if (value === undefined || value === null) return value;
|
|
33
|
+
if (!schema || !schema.type) return value;
|
|
34
|
+
|
|
35
|
+
const type = schema.type;
|
|
36
|
+
|
|
37
|
+
if (type === 'array') {
|
|
38
|
+
let arr = value;
|
|
39
|
+
if (typeof arr === 'string') {
|
|
40
|
+
try {
|
|
41
|
+
arr = JSON.parse(arr);
|
|
42
|
+
} catch (_e) {
|
|
43
|
+
throw new CoercionError(
|
|
44
|
+
`${path}: expected array, got non-JSON string "${truncate(value)}"`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(arr)) {
|
|
49
|
+
throw new CoercionError(
|
|
50
|
+
`${path}: expected array, got ${describeType(arr)}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (schema.items) {
|
|
54
|
+
return arr.map((item, i) =>
|
|
55
|
+
coerceValue(item, schema.items, `${path}[${i}]`)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return arr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (type === 'boolean') {
|
|
62
|
+
if (typeof value === 'boolean') return value;
|
|
63
|
+
if (value === 'true' || value === 1 || value === '1') return true;
|
|
64
|
+
if (value === 'false' || value === 0 || value === '0') return false;
|
|
65
|
+
throw new CoercionError(
|
|
66
|
+
`${path}: expected boolean, got ${describeType(value)} (${truncate(JSON.stringify(value))})`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (type === 'integer') {
|
|
71
|
+
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
|
72
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
73
|
+
const n = Number(value);
|
|
74
|
+
if (Number.isInteger(n)) return n;
|
|
75
|
+
}
|
|
76
|
+
throw new CoercionError(
|
|
77
|
+
`${path}: expected integer, got ${describeType(value)} (${truncate(JSON.stringify(value))})`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (type === 'number') {
|
|
82
|
+
if (typeof value === 'number') return value;
|
|
83
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
84
|
+
const n = Number(value);
|
|
85
|
+
if (!Number.isNaN(n)) return n;
|
|
86
|
+
}
|
|
87
|
+
throw new CoercionError(
|
|
88
|
+
`${path}: expected number, got ${describeType(value)} (${truncate(JSON.stringify(value))})`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (type === 'object') {
|
|
93
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
94
|
+
throw new CoercionError(
|
|
95
|
+
`${path}: expected object, got ${describeType(value)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!schema.properties) return value;
|
|
99
|
+
const result = { ...value };
|
|
100
|
+
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
101
|
+
if (key in value) {
|
|
102
|
+
result[key] = coerceValue(value[key], propSchema, `${path}.${key}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (type === 'string') {
|
|
109
|
+
// F-24: reject arrays passed to string-typed params with a clear
|
|
110
|
+
// hint. The chokepoint pattern coerces arrays *into* arrays
|
|
111
|
+
// (F-25/F-33/F-36) but quietly let arrays slip through to string
|
|
112
|
+
// params, where they got JSON-stringified and rejected by Graph
|
|
113
|
+
// with a confusing 400. Tools whose schema declares a comma-
|
|
114
|
+
// separated string for `to`/`cc`/etc. now surface a friendly
|
|
115
|
+
// MCP-layer error before the call ever leaves the process.
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
throw new CoercionError(
|
|
118
|
+
`${path}: expected comma-separated string, got array — pass "a@example.com,b@example.com" instead of ["a@example.com","b@example.com"]`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
// F-24 part 2 (#168): some MCP clients JSON-stringify array literals
|
|
122
|
+
// before transmission when the schema declares type:string, so the
|
|
123
|
+
// array arrives here as the literal string '["a@x.com","b@x.com"]'
|
|
124
|
+
// (brackets and quotes intact). Array.isArray returns false; the
|
|
125
|
+
// value would otherwise pass through and Graph would reject the
|
|
126
|
+
// literal-bracket address with a confusing 400.
|
|
127
|
+
if (typeof value === 'string') {
|
|
128
|
+
const trimmed = value.trim();
|
|
129
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
130
|
+
let parsed;
|
|
131
|
+
try {
|
|
132
|
+
parsed = JSON.parse(trimmed);
|
|
133
|
+
} catch (_e) {
|
|
134
|
+
parsed = undefined;
|
|
135
|
+
}
|
|
136
|
+
if (Array.isArray(parsed)) {
|
|
137
|
+
const hint = parsed.every((p) => typeof p === 'string')
|
|
138
|
+
? `"${parsed.join(',')}"`
|
|
139
|
+
: 'a comma-separated string';
|
|
140
|
+
throw new CoercionError(
|
|
141
|
+
`${path}: expected comma-separated string, got JSON-encoded array — pass ${hint} instead of ${trimmed}`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// unknown type: pass through
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Coerce + validate args against a tool's inputSchema. Returns
|
|
155
|
+
* { args: <coerced> } on success, or
|
|
156
|
+
* { error: '<message>' } on failure.
|
|
157
|
+
*
|
|
158
|
+
* Validates (in this order, all errors collected):
|
|
159
|
+
* 1. additionalProperties: false (rejects unknown params)
|
|
160
|
+
* 2. type coercion for each declared property (string→array/boolean/number)
|
|
161
|
+
* 3. required (rejects missing required params)
|
|
162
|
+
* 4. enum on top-level properties (rejects out-of-enum values)
|
|
163
|
+
*/
|
|
164
|
+
function coerceArgsAgainstSchema(args, inputSchema) {
|
|
165
|
+
if (!inputSchema || !inputSchema.properties) return { args: args || {} };
|
|
166
|
+
const safeArgs = args || {};
|
|
167
|
+
const errors = [];
|
|
168
|
+
|
|
169
|
+
// 1. additionalProperties: false
|
|
170
|
+
if (inputSchema.additionalProperties === false) {
|
|
171
|
+
const known = new Set(Object.keys(inputSchema.properties));
|
|
172
|
+
const unknown = Object.keys(safeArgs).filter((k) => !known.has(k));
|
|
173
|
+
if (unknown.length > 0) {
|
|
174
|
+
const validList = [...known].sort().join(', ');
|
|
175
|
+
errors.push(
|
|
176
|
+
`Unknown parameter${unknown.length > 1 ? 's' : ''}: ${unknown
|
|
177
|
+
.map((k) => `'${k}'`)
|
|
178
|
+
.join(', ')}. Valid parameters: ${validList}.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 2. coerce each known property
|
|
184
|
+
const coerced = { ...safeArgs };
|
|
185
|
+
for (const [key, propSchema] of Object.entries(inputSchema.properties)) {
|
|
186
|
+
if (!(key in safeArgs)) continue;
|
|
187
|
+
try {
|
|
188
|
+
coerced[key] = coerceValue(safeArgs[key], propSchema, key);
|
|
189
|
+
} catch (e) {
|
|
190
|
+
if (e instanceof CoercionError) {
|
|
191
|
+
errors.push(e.message);
|
|
192
|
+
} else {
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 3. required
|
|
199
|
+
if (Array.isArray(inputSchema.required)) {
|
|
200
|
+
for (const key of inputSchema.required) {
|
|
201
|
+
if (
|
|
202
|
+
!(key in safeArgs) ||
|
|
203
|
+
safeArgs[key] === undefined ||
|
|
204
|
+
safeArgs[key] === null ||
|
|
205
|
+
safeArgs[key] === ''
|
|
206
|
+
) {
|
|
207
|
+
errors.push(`Required parameter '${key}' is missing.`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 4. top-level enum constraints
|
|
213
|
+
for (const [key, propSchema] of Object.entries(inputSchema.properties)) {
|
|
214
|
+
if (!(key in coerced)) continue;
|
|
215
|
+
if (!Array.isArray(propSchema.enum)) continue;
|
|
216
|
+
const v = coerced[key];
|
|
217
|
+
if (v === undefined || v === null) continue;
|
|
218
|
+
if (!propSchema.enum.includes(v)) {
|
|
219
|
+
errors.push(
|
|
220
|
+
`Parameter '${key}': value '${v}' not in allowed values [${propSchema.enum
|
|
221
|
+
.map((x) => `'${x}'`)
|
|
222
|
+
.join(', ')}].`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (errors.length > 0) {
|
|
228
|
+
return { error: errors.join('\n') };
|
|
229
|
+
}
|
|
230
|
+
return { args: coerced };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function describeType(value) {
|
|
234
|
+
if (value === null) return 'null';
|
|
235
|
+
if (Array.isArray(value)) return 'array';
|
|
236
|
+
return typeof value;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function truncate(s, n = 60) {
|
|
240
|
+
if (typeof s !== 'string') s = String(s);
|
|
241
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
coerceArgsAgainstSchema,
|
|
246
|
+
coerceValue,
|
|
247
|
+
CoercionError,
|
|
248
|
+
};
|