@littlebearapps/outlook-assistant 3.14.0 → 3.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/email/draft.js CHANGED
@@ -8,6 +8,7 @@ const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const {
10
10
  checkRateLimit,
11
+ releaseRateLimit,
11
12
  checkRecipientAllowlist,
12
13
  findBlockedRecipients,
13
14
  getRecipientAllowlist,
@@ -86,7 +87,7 @@ function buildMessageObject(args) {
86
87
  /**
87
88
  * Format a draft response with key details
88
89
  * @param {object} draft - Graph API message response
89
- * @param {string} actionLabel - Human-readable action (e.g. "created", "updated")
90
+ * @param {string} actionLabel - Heading, e.g. "Draft created" or "Reply draft created"
90
91
  * @returns {object} - MCP response
91
92
  */
92
93
  function formatDraftResponse(draft, actionLabel) {
@@ -94,7 +95,7 @@ function formatDraftResponse(draft, actionLabel) {
94
95
  .map((r) => r.emailAddress?.address)
95
96
  .join(', ');
96
97
 
97
- let text = `Draft ${actionLabel}.\n\n`;
98
+ let text = `${actionLabel}.\n\n`;
98
99
  text += `**ID**: \`${draft.id}\`\n`;
99
100
  if (draft.subject) text += `**Subject**: ${draft.subject}\n`;
100
101
  if (to) text += `**To**: ${to}\n`;
@@ -220,7 +221,7 @@ async function handleCreateDraft(args) {
220
221
  const tipsText = tipsResult.content[0]?.text || '';
221
222
 
222
223
  if (dryRun) {
223
- const preview = formatDryRunPreview({ message, saveToSentItems: true });
224
+ const preview = formatDryRunPreview({ message, isDraft: true });
224
225
  return {
225
226
  content: [
226
227
  {
@@ -238,7 +239,7 @@ async function handleCreateDraft(args) {
238
239
 
239
240
  // Dry-run mode: preview without saving
240
241
  if (dryRun) {
241
- const preview = formatDryRunPreview({ message, saveToSentItems: true });
242
+ const preview = formatDryRunPreview({ message, isDraft: true });
242
243
  return {
243
244
  content: [
244
245
  {
@@ -264,7 +265,7 @@ async function handleCreateDraft(args) {
264
265
  'me/messages',
265
266
  message
266
267
  );
267
- const response = formatDraftResponse(draft, 'created');
268
+ const response = formatDraftResponse(draft, 'Draft created');
268
269
  if (tipsResult) {
269
270
  response.content[0].text += `\n---\n\n${tipsResult.content[0]?.text || ''}`;
270
271
  response._meta.mailTips = tipsResult._meta;
@@ -312,7 +313,7 @@ async function handleUpdateDraft(args) {
312
313
  `me/messages/${id}`,
313
314
  message
314
315
  );
315
- return formatDraftResponse(draft, 'updated');
316
+ return formatDraftResponse(draft, 'Draft updated');
316
317
  } catch (error) {
317
318
  return handleError('updating draft', error);
318
319
  }
@@ -426,14 +427,17 @@ async function handleReplyDraft(args, endpoint) {
426
427
 
427
428
  const actionName = endpoint === 'createReplyAll' ? 'reply-all' : 'reply';
428
429
 
429
- // Counted before the draft is created: a reply that the allowlist refuses
430
- // below has still written (and removed) a draft.
430
+ // Taken before the draft is created, and given back only when nothing is
431
+ // definitely left behind (#299): Graph rejected the create with a 4xx, or
432
+ // the allowlist refused the reply and its draft was deleted again. A
433
+ // timeout, 5xx or failed delete may leave a draft, so the slot stays used.
431
434
  const rateLimitError = checkRateLimit('draft');
432
435
  if (rateLimitError) return rateLimitError;
433
436
 
437
+ let draft;
434
438
  try {
435
439
  const accessToken = await ensureAuthenticated();
436
- const draft = await callGraphAPI(
440
+ draft = await callGraphAPI(
437
441
  accessToken,
438
442
  'POST',
439
443
  `me/messages/${id}/${endpoint}`,
@@ -443,10 +447,24 @@ async function handleReplyDraft(args, endpoint) {
443
447
  // Graph fills in the recipients from the original message, so they can
444
448
  // only be checked once the draft exists.
445
449
  const refusal = await refuseBlockedReply(accessToken, draft, actionName);
446
- if (refusal) return refusal;
450
+ if (refusal) {
451
+ if (refusal.draftDeleted) releaseRateLimit('draft');
452
+ return refusal.error;
453
+ }
447
454
 
448
- return formatDraftResponse(draft, `${actionName} draft created`);
455
+ return formatDraftResponse(
456
+ draft,
457
+ `${actionName.charAt(0).toUpperCase()}${actionName.slice(1)} draft created`
458
+ );
449
459
  } catch (error) {
460
+ // Only Graph's own rejection, read from the start of the error (the
461
+ // body after it is server text). 408 means it may have gone through.
462
+ const rejected = /^API call failed with status (4\d\d):/.exec(
463
+ error.message || ''
464
+ );
465
+ if (!draft && rejected && rejected[1] !== '408') {
466
+ releaseRateLimit('draft');
467
+ }
450
468
  return handleError(`creating ${actionName} draft`, error);
451
469
  }
452
470
  }
@@ -458,7 +476,8 @@ async function handleReplyDraft(args, endpoint) {
458
476
  * @param {string} accessToken - Graph access token
459
477
  * @param {object} draft - The draft Graph returned from createReply/createReplyAll
460
478
  * @param {string} actionName - 'reply' or 'reply-all'
461
- * @returns {Promise<object|null>} A tool error, or null to keep the draft
479
+ * @returns {Promise<{error: object, draftDeleted: boolean}|null>} The
480
+ * refusal and whether its draft was deleted, or null to keep the draft
462
481
  */
463
482
  async function refuseBlockedReply(accessToken, draft, actionName) {
464
483
  if (!getRecipientAllowlist()) return null;
@@ -490,18 +509,22 @@ async function refuseBlockedReply(accessToken, draft, actionName) {
490
509
  try {
491
510
  await callGraphAPI(accessToken, 'DELETE', `me/messages/${draft.id}`);
492
511
  } catch (error) {
493
- return toolError(
512
+ const stillThere = toolError(
494
513
  `${reason} The draft Graph created could not be deleted (${error.message}), so it is still in Drafts with ID \`${draft.id}\`. Do not send it.`,
495
514
  {
496
515
  nextStep: `Delete it with draft action=delete id=${draft.id} (or in Outlook). ${nextStep}`,
497
516
  }
498
517
  );
518
+ return { error: stillThere, draftDeleted: false };
499
519
  }
500
520
 
501
- return toolError(
502
- `${reason} The draft Graph created was deleted, so nothing was kept.`,
503
- { nextStep }
504
- );
521
+ return {
522
+ error: toolError(
523
+ `${reason} The draft Graph created was deleted, so nothing was kept.`,
524
+ { nextStep }
525
+ ),
526
+ draftDeleted: true,
527
+ };
505
528
  }
506
529
 
507
530
  /**
@@ -556,7 +579,7 @@ async function handleForwardDraft(args) {
556
579
  `me/messages/${id}/createForward`,
557
580
  requestBody
558
581
  );
559
- return formatDraftResponse(draft, 'forward draft created');
582
+ return formatDraftResponse(draft, 'Forward draft created');
560
583
  } catch (error) {
561
584
  return handleError('creating forward draft', error);
562
585
  }
package/email/export.js CHANGED
@@ -67,8 +67,8 @@ async function handleExportEmail(args) {
67
67
  }
68
68
 
69
69
  // Where to write, checked before anything is fetched. F-27: `outputDir`
70
- // is always a directory. `savePath` names a directory if one exists
71
- // there, otherwise the file to write. With no path, the system temp
70
+ // is always a directory. `savePath` names a directory if it ends in a
71
+ // separator or one exists there, otherwise the file to write. With no path, the system temp
72
72
  // directory is used. Writes go to the resolved path, never the raw one.
73
73
  let explicitFile = null;
74
74
  let requestedFile = null; // savePath as given, for messages
@@ -77,6 +77,10 @@ async function handleExportEmail(args) {
77
77
  try {
78
78
  if (args.outputDir) {
79
79
  targetDir = confineOutputPath(args.outputDir);
80
+ } else if (args.savePath && /[\\/]$/.test(args.savePath)) {
81
+ // A trailing separator names a directory even when it doesn't exist
82
+ // yet; resolving the path drops it, so decide here (#301).
83
+ targetDir = confineOutputPath(args.savePath);
80
84
  } else if (args.savePath) {
81
85
  const target = confineOutputTarget(args.savePath);
82
86
  const resolved = target.path;
package/email/index.js CHANGED
@@ -260,7 +260,7 @@ const emailTools = [
260
260
  {
261
261
  name: 'send-email',
262
262
  description:
263
- 'Compose and send an email immediately (destructive: sends external comms). Returns a confirmation. Safety controls: `dryRun: true` returns the composed message for review without sending; `checkRecipients: true` runs `get-mail-tips` first and returns its warnings. If the tips show an out-of-office reply, a full mailbox, a delivery restriction or external recipients, the send is refused until repeated with `acknowledgeWarnings: true`. Personal Outlook.com accounts return no tips, and no warnings is not proof of delivery. Subject to session rate limits (`OUTLOOK_MAX_EMAILS_PER_SESSION` env) and recipient allowlist (`OUTLOOK_ALLOWED_RECIPIENTS` env) when configured — calls outside the allowlist fail before any Graph request. For a review-before-send workflow, use `draft` (action=`create` → `update` → `send`); a draft can be checked in Outlook before it goes. Comma-separated recipient strings or arrays both accepted.',
263
+ 'Compose and send an email immediately (destructive: sends external comms). Returns a confirmation. Safety controls: `dryRun: true` returns the composed message for review without sending; `checkRecipients: true` runs `get-mail-tips` first and returns its warnings. If the tips show an out-of-office reply, a full mailbox, a delivery restriction or external recipients, the send is refused until repeated with `acknowledgeWarnings: true`. Personal Outlook.com accounts return no tips, and no warnings is not proof of delivery. Subject to the session limit (`OUTLOOK_MAX_SEND_EMAIL_PER_SESSION`, else `OUTLOOK_MAX_EMAILS_PER_SESSION`; 0 refuses every send) and recipient allowlist (`OUTLOOK_ALLOWED_RECIPIENTS`) when configured; both refuse before any Graph request. For a review-before-send workflow, use `draft` (action=`create` → `update` → `send`); a draft can be checked in Outlook before it goes. Comma-separated recipient strings or arrays both accepted.',
264
264
  ...toolMetadata('send-email', 'Send Email'),
265
265
  inputSchema: {
266
266
  type: 'object',
@@ -319,7 +319,7 @@ const emailTools = [
319
319
  {
320
320
  name: 'draft',
321
321
  description:
322
- "Draft lifecycle for review-before-send workflows (destructive: covers `send` and `delete`). action=`create` saves a new draft and returns its id (`dryRun: true` previews without saving; `checkRecipients: true` runs mail-tips first). action=`update` patches a draft by `id` (only fields passed change). action=`send` sends a draft and shares the rate limit with `send-email`. action=`delete` deletes a draft into Recoverable Items (Outlook can restore it for a limited time, depending on the account). update/send/delete refuse any `id` that is not an unsent draft. action=`reply`/`reply-all` creates a reply draft from a message `id` (`comment` prepends text; not with `body`). action=`forward` creates a forward draft (needs `id` and `to`). The recipient allowlist (OUTLOOK_ALLOWED_RECIPIENTS) applies to create/update/forward, to the draft's current to/cc/bcc on send, and to reply/reply-all, whose draft is deleted if a recipient is not allowed. Returns the draft on create/update/reply/forward; a status on send/delete.",
322
+ "Draft lifecycle for review-before-send workflows (destructive: covers `send` and `delete`). action=`create` saves a new draft and returns its id (`dryRun: true` previews without saving; `checkRecipients: true` runs mail-tips first). action=`update` patches a draft by `id` (only fields passed change). action=`send` sends a draft and counts against the `send-email` session limit (0 refuses every send). action=`delete` moves a draft to Recoverable Items (restorable for a limited time). update/send/delete refuse any `id` that is not an unsent draft. action=`reply`/`reply-all` creates a reply draft from a message `id` (`comment` prepends text; not with `body`). action=`forward` creates a forward draft (needs `id` and `to`). The recipient allowlist (OUTLOOK_ALLOWED_RECIPIENTS) applies to create/update/forward, to the draft's current to/cc/bcc on send, and to reply/reply-all, whose draft is deleted if a recipient is not allowed. Returns the draft on create/update/reply/forward; a status on send/delete.",
323
323
  ...toolMetadata('draft', 'Draft Operations'),
324
324
  inputSchema: {
325
325
  type: 'object',
@@ -540,7 +540,7 @@ const emailTools = [
540
540
  {
541
541
  name: 'export',
542
542
  description:
543
- 'Export emails to files. target=`message` (default) exports one email by `id` (mime/eml/markdown/json/csv) to `savePath`: a directory gets a new, unique file name; a file path is created new and an existing file is replaced only with `overwrite: true`. target=`messages` batch-exports `emailIds`, or matches for `searchQuery`/`query`, into `outputDir` (markdown/json/csv), at most 100 messages per call. target=`conversation` exports a thread (up to 1000 messages) by `conversationId` into `outputDir` (eml/mbox/markdown/json/html/csv; `order: "reverse"` for newest first). target=`mime` returns raw RFC-822 MIME for `id` (`headersOnly`, `base64`, `maxSize`, default 1MB). Files are written only inside the system temp directory (the default), ~/Downloads, ~/Documents or OUTLOOK_EXPORT_DIR, never to dot-prefixed names. Pass `sharedMailbox` (alias `email`) when the ids come from a shared mailbox. `includeAttachments` defaults to true for one message, false for batch.',
543
+ 'Export emails to files. target=`message` (default) exports one email by `id` (mime/eml/markdown/json/csv) to `savePath`: a directory (or a path ending in `/`, created if missing) gets a new, unique file name; a file path is created new and an existing file is replaced only with `overwrite: true`. target=`messages` batch-exports `emailIds`, or matches for `searchQuery`/`query`, into `outputDir`, at most 100 messages per call. target=`conversation` exports a thread (up to 1000 messages) by `conversationId` into `outputDir` (eml/mbox/markdown/json/html/csv; `order: "reverse"` for newest first). target=`mime` returns raw RFC-822 MIME for `id` (`headersOnly`, `base64`, `maxSize`, default 1MB). Files are written only inside the system temp directory (the default), ~/Downloads, ~/Documents or OUTLOOK_EXPORT_DIR, never to dot-prefixed names. Pass `sharedMailbox` (alias `email`) when the ids come from a shared mailbox. `includeAttachments` defaults to true for one message, false for batch.',
544
544
  ...toolMetadata('export', 'Export Emails'),
545
545
  inputSchema: {
546
546
  type: 'object',
@@ -559,7 +559,7 @@ const emailTools = [
559
559
  type: 'string',
560
560
  enum: ['mime', 'eml', 'markdown', 'json', 'mbox', 'html', 'csv'],
561
561
  description:
562
- 'Export format. Valid values vary by target: target=message accepts mime/eml/markdown/json/csv (mbox and html are conversation-only). target=conversation accepts eml/mbox/markdown/json/html/csv. target=messages (batch) accepts markdown/json/csv. mime is an alias for eml (same RFC822 bytes, .eml extension on disk).',
562
+ 'Export format. Valid values vary by target: target=message accepts mime/eml/markdown/json/csv (mbox and html are conversation-only). target=conversation accepts eml/mbox/markdown/json/html/csv. target=messages (batch) accepts mime/eml/markdown/json (one file per message) or csv (one file). mime is an alias for eml (same RFC822 bytes, .eml extension on disk).',
563
563
  },
564
564
  savePath: {
565
565
  type: 'string',
package/email/mime.js CHANGED
@@ -10,6 +10,22 @@ const { buildMailboxPrefix } = require('../utils/mailbox');
10
10
  const { toolError, authRequiredError } = require('../utils/tool-error');
11
11
  const { log } = require('../utils/logger');
12
12
 
13
+ /**
14
+ * The longest prefix of `text` that fits in `maxBytes` of UTF-8 without
15
+ * splitting a character (which would render as U+FFFD).
16
+ * @param {string} text
17
+ * @param {number} maxBytes
18
+ * @returns {string}
19
+ */
20
+ function utf8Prefix(text, maxBytes) {
21
+ const bytes = Buffer.from(text, 'utf8');
22
+ if (bytes.length <= maxBytes) return text;
23
+ let end = maxBytes;
24
+ // Step back over continuation bytes (10xxxxxx) to a character start.
25
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
26
+ return bytes.subarray(0, end).toString('utf8');
27
+ }
28
+
13
29
  /**
14
30
  * Parse MIME headers from raw content
15
31
  * @param {string} mimeContent - Raw MIME content
@@ -123,7 +139,13 @@ async function handleGetMimeContent(args) {
123
139
  // Check size limit
124
140
  if (maxSize > 0 && stats.bytes > maxSize) {
125
141
  if (headersOnly) {
126
- // Return just headers if over limit
142
+ // Return just headers if over limit, themselves capped at
143
+ // maxSize (#306): a large header block used to come back whole.
144
+ const headerBytes = Buffer.byteLength(parsed.headerSection, 'utf8');
145
+ const headerText =
146
+ headerBytes > maxSize
147
+ ? `${utf8Prefix(parsed.headerSection, maxSize)}\n… (headers cut at maxSize ${maxSize} bytes of ${headerBytes})`
148
+ : parsed.headerSection;
127
149
  return {
128
150
  content: [
129
151
  {
@@ -132,7 +154,7 @@ async function handleGetMimeContent(args) {
132
154
  `# MIME Content (Headers Only - Content Truncated)\n\n` +
133
155
  `**Size**: ${stats.formattedSize} (exceeds ${maxSize} byte limit)\n` +
134
156
  `**Lines**: ${stats.lines}\n\n` +
135
- `## MIME Headers\n\n\`\`\`\n${parsed.headerSection}\n\`\`\``,
157
+ `## MIME Headers\n\n\`\`\`\n${headerText}\n\`\`\``,
136
158
  },
137
159
  ],
138
160
  _meta: {
@@ -250,6 +272,7 @@ async function handleGetMimeContent(args) {
250
272
 
251
273
  module.exports = {
252
274
  handleGetMimeContent,
275
+ utf8Prefix,
253
276
  parseMimeHeaders,
254
277
  getMimeStats,
255
278
  };
package/email/search.js CHANGED
@@ -1344,7 +1344,7 @@ function buildNoResultsSuggestions(searchInfo, searchAllFolders) {
1344
1344
  );
1345
1345
  } else {
1346
1346
  suggestions.push(
1347
- 'Try `searchAllFolders: true` to search across all folders including Archive'
1347
+ 'Try `searchAllFolders: true` to search across all folders, including Archive and Junk Email (mail from a new sender often lands in Junk)'
1348
1348
  );
1349
1349
  suggestions.push(
1350
1350
  'Specify the correct folder if emails have been moved (use the `folders` tool to list folders)'
package/folder/index.js CHANGED
@@ -77,7 +77,8 @@ const folderTools = [
77
77
  },
78
78
  sourceFolder: {
79
79
  type: 'string',
80
- description: 'Source folder name, default is inbox (action=move)',
80
+ description:
81
+ 'Ignored: action=move moves each email by ID from wherever it is. Accepted for older callers.',
81
82
  },
82
83
  // stats params
83
84
  folder: {
package/folder/stats.js CHANGED
@@ -180,8 +180,10 @@ function formatFolderStats(folder, dateRange, verbosity) {
180
180
  text += `| Date Range | ${oldest} to ${newest} |\n`;
181
181
  }
182
182
 
183
- if (totalItems > 100) {
184
- text += `\n_Hint: Use list-emails-delta for efficient incremental sync of large folders._`;
183
+ // List mode returns at most 50 with no page cursor, so anything bigger
184
+ // needs delta sync to read in full.
185
+ if (totalItems > 50) {
186
+ text += `\n_Hint: Use \`search-emails\` with \`deltaMode: true\` for efficient incremental sync of large folders._`;
185
187
  }
186
188
 
187
189
  return { text, meta };
@@ -210,7 +212,10 @@ function formatFolderStats(folder, dateRange, verbosity) {
210
212
  text += `|---------|-------|\n`;
211
213
  text += `| Page Size | ${pageSize} emails |\n`;
212
214
  text += `| Total Pages | ${totalPages} |\n`;
213
- text += `| Estimated API Calls | ${totalPages} (list-emails) |\n`;
215
+ text +=
216
+ totalItems > 50
217
+ ? `| Estimated API Calls | ${Math.ceil(totalItems / 100)} (\`search-emails\` \`deltaMode: true\`, 100 per page; list mode stops at 50) |\n`
218
+ : `| Estimated API Calls | 1 (\`search-emails\` list mode, \`count\` up to 50) |\n`;
214
219
 
215
220
  if (dateRange) {
216
221
  const newestDate = new Date(dateRange.newest);
@@ -233,12 +238,12 @@ function formatFolderStats(folder, dateRange, verbosity) {
233
238
  text += `\n## Recommendations\n\n`;
234
239
 
235
240
  if (totalItems > 1000) {
236
- text += `- **Large folder**: Use \`list-emails-delta\` for incremental sync\n`;
241
+ text += `- **Large folder**: Use \`search-emails\` with \`deltaMode: true\` for incremental sync\n`;
237
242
  text += `- **Use date filters**: \`receivedAfter\` and \`receivedBefore\` to narrow scope\n`;
238
- } else if (totalItems > 100) {
239
- text += `- **Medium folder**: Consider using \`list-emails-delta\` for efficient updates\n`;
243
+ } else if (totalItems > 50) {
244
+ text += `- **Medium folder**: Consider \`search-emails\` with \`deltaMode: true\` for efficient updates\n`;
240
245
  } else {
241
- text += `- **Small folder**: \`list-emails\` with default pagination is efficient\n`;
246
+ text += `- **Small folder**: \`search-emails\` in list mode (raise \`count\` up to 50) is enough\n`;
242
247
  }
243
248
 
244
249
  if (unreadItems > 50) {
package/index.js CHANGED
@@ -42,9 +42,12 @@ Key environment variables:
42
42
  send or delete anything (reads and sign-in still work)
43
43
  OUTLOOK_ALLOWED_RECIPIENTS Comma-separated recipient allowlist
44
44
  OUTLOOK_MAX_EMAILS_PER_SESSION Default cap per session for every rate-limited tool
45
- (send-email, draft, manage-rules); 0 or unset = no cap
45
+ (send-email, draft, create-event, manage-rules).
46
+ Unset or empty = no cap. 0 BLOCKS those tools; so does
47
+ any value that isn't a whole number (fails closed)
46
48
  OUTLOOK_MAX_<TOOL>_PER_SESSION Per-tool cap overriding the default, tool name in upper
47
49
  case with _ for -, e.g. OUTLOOK_MAX_SEND_EMAIL_PER_SESSION
50
+ (also covers draft action=send); 0 blocks that tool
48
51
  OUTLOOK_DEFAULT_TIMEZONE IANA timezone for event times (default Australia/Melbourne)
49
52
  OUTLOOK_IMMUTABLE_IDS Set to "true" for message IDs that survive folder moves
50
53
  OUTLOOK_SEARCH_SCAN_LIMIT Local search fallback window (default 500, max 5000)
@@ -88,6 +91,11 @@ const { createServer } = require('./server');
88
91
  const { setToolCount } = require('./auth');
89
92
  const { TOOLS } = require('./tools');
90
93
  const { isDebugEnabled } = require('./utils/logger');
94
+ const {
95
+ blockedTools,
96
+ resolveSessionLimit,
97
+ RATE_LIMITED_TOOLS,
98
+ } = require('./utils/safety');
91
99
 
92
100
  // Log startup information
93
101
  console.error(
@@ -103,8 +111,11 @@ if (isDebugEnabled()) {
103
111
  // F-1 / F-48: warn at startup when safety belts are unset. Mirrors the
104
112
  // warning surfaced by `auth action=about`. Visible to operators reading
105
113
  // stderr; AI clients reading the JSON-RPC stream are unaffected.
114
+ const sessionLimitSet = Object.keys(RATE_LIMITED_TOOLS).some(
115
+ (tool) => resolveSessionLimit(tool).limit !== null
116
+ );
106
117
  if (
107
- !process.env.OUTLOOK_MAX_EMAILS_PER_SESSION &&
118
+ !sessionLimitSet &&
108
119
  !process.env.OUTLOOK_ALLOWED_RECIPIENTS &&
109
120
  !config.USE_TEST_MODE
110
121
  ) {
@@ -112,6 +123,13 @@ if (
112
123
  '⚠ 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.'
113
124
  );
114
125
  }
126
+ // #302: say plainly when a session limit of 0 switches a tool off.
127
+ const blocked = blockedTools();
128
+ if (blocked.length > 0) {
129
+ console.error(
130
+ `Session limits block ${blocked.join(', ')} (0 or an unreadable value). Unset the setting for no limit. See \`auth action=about\`.`
131
+ );
132
+ }
115
133
 
116
134
  // Set dynamic tool count for auth about handler
117
135
  setToolCount(TOOLS.length);
package/llms-install.md CHANGED
@@ -81,7 +81,7 @@ Add these to the same `env` block if needed:
81
81
  |----------|---------|
82
82
  | `OUTLOOK_AUTH_AUDIENCE` | `consumers` for Azure apps registered as personal-accounts-only (fixes `AADSTS9002331`); `organizations` or a tenant GUID for work-only apps. Default `common` |
83
83
  | `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone for calendar times (default `Australia/Melbourne`) |
84
- | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Default per-session cap, counted separately per tool, for `send-email` (including `draft` send), `draft` create/update/reply/reply-all/forward, `manage-rules` and `create-event` (override one tool with `OUTLOOK_MAX_<TOOL>_PER_SESSION`, e.g. `OUTLOOK_MAX_SEND_EMAIL_PER_SESSION`) |
84
+ | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Default per-session cap, counted separately per tool, for `send-email` (including `draft` send), `draft` create/update/reply/reply-all/forward, `manage-rules` and `create-event` (override one tool with `OUTLOOK_MAX_<TOOL>_PER_SESSION`, e.g. `OUTLOOK_MAX_SEND_EMAIL_PER_SESSION`). Unset = no limit; `0` blocks the tool |
85
85
  | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of recipient domains/addresses for sends, drafts, rule forwards and event attendees |
86
86
  | `OUTLOOK_READ_ONLY` | `true` refuses every tool call that would change something (sending, drafts, moves, deletes, rules, settings, file writes), dry runs included; reads and sign-in still work |
87
87
  | `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support, work/school accounts only: `read` or `true` (read and organise). Also add `Mail.Read.Shared` (and `Mail.ReadWrite.Shared` for `true`) in Azure, restart, then run `auth` with `action=authenticate` and `force=true` |
package/llms.txt CHANGED
@@ -26,7 +26,7 @@ Built by [Little Bear Apps](https://littlebearapps.com).
26
26
  - **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, and `_meta.searchMetadata` reports which strategy answered plus any filter that could not be honoured (`droppedFilters`), so a partially-applied search can never pass as a complete one. Field-scoped `$search` expressions (`from:`/`to:`/`subject:`), which personal accounts reject outright, are translated to equivalent OData filters and retried. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
27
27
  - **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.
28
28
  - **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
29
- - **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
29
+ - **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling; every page is labelled initial or incremental (`_meta.syncType`)
30
30
  - **Batch operations**: Flag, move, export, or categorise multiple emails in a single tool call; search-driven export for batch archiving without collecting IDs
31
31
  - **Pre-send intelligence**: Check recipients for out-of-office, mailbox full, delivery restrictions, and moderation before sending — no other Outlook MCP server offers this
32
32
  - **Compound automation**: Rules + categories + nested folders (addressable by path or ID) + Focused Inbox for complete inbox management in one conversation
@@ -34,15 +34,15 @@ Built by [Little Bear Apps](https://littlebearapps.com).
34
34
  ## Safety & Token Efficiency
35
35
 
36
36
  - **MCP safety annotations** on all 22 tools, derived from a risk class per tool and action (read, reversible, outward, destructive, persistent), so clients can tell reads from risky changes
37
- - **Send-email protections**: pre-send mail tips (a send to a flagged recipient, such as out of office or external, is refused until `acknowledgeWarnings: true`), dry-run preview, session rate limiting, recipient allowlist (also checked on drafts, including replies and the draft's current recipients on send, and on `create-event`/`manage-event` update attendees; only single plain addresses can match)
38
- - **Rule protections**: dry-run preview on create/update, rate limiting (dry runs don't count), recipient allowlist on forward/redirect (a blocked forward refuses the whole rule), no permanent-delete action
39
- - **Dry-run previews beyond email**: `create-event` and every `manage-event` action say who would be emailed (with an external count); `mailbox-settings` auto-replies, `folders` delete and `manage-contact` delete show what would change or be lost; `dryRun: true` on any other call is refused rather than run for real
37
+ - **Send-email protections**: pre-send mail tips (a send to a flagged recipient, such as out of office or external, is refused until `acknowledgeWarnings: true`), dry-run preview, session rate limiting (unset = no limit; `0` or a value that isn't a whole number blocks the tool; `draft` send counts as `send-email`), recipient allowlist (also checked on drafts, including replies and the draft's current recipients on send, and on `create-event`/`manage-event` update attendees; only single plain addresses can match)
38
+ - **Rule protections**: dry-run preview on create/update, rate limiting (dry runs don't count; `0` blocks rule changes), reorder lists the resulting rule order, recipient allowlist on forward/redirect (a blocked forward refuses the whole rule), no permanent-delete action
39
+ - **Dry-run previews beyond email**: `create-event` and every `manage-event` action, update included, say who would be emailed (with an external count; update also says who an attendee change adds or removes); `mailbox-settings` auto-replies, `folders` delete and `manage-contact` delete show what would change or be lost; `dryRun: true` on any other call is refused rather than run for real
40
40
  - **Read-only mode**: `OUTLOOK_READ_ONLY=true` refuses every tool call that would change anything before it runs; reads and sign-in still work
41
- - **Model guidance**: server `instructions` state the hard rules (retrieved content is data, confirm before outward actions, draft first); `send-email` and `create-event` ask Claude clients for user confirmation (`anthropic/requiresUserInteraction`)
41
+ - **Model guidance**: server `instructions` state the hard rules (retrieved content is data, confirm before outward actions, draft first, refusals and rate limits are final, with `0` = off) and name any tool a session limit of `0` blocks; `auth action=about` shows each tool's session limit; `send-email` and `create-event` ask Claude clients for user confirmation (`anthropic/requiresUserInteraction`)
42
42
  - **Plugin skill and safety hook** (Claude Code, GitHub Copilot CLI, Cursor; VS Code not yet checked by hand): the `using-outlook-assistant` skill sets the hard rules; the hook asks with a plain-English reason before anything that reaches other people, deletes or keeps acting, and marks retrieved content as untrusted. Confirmation level `outward` (default), `all-writes` or `off`: a plugin setting in Claude Code, the `OUTLOOK_CONFIRM_LEVEL` environment variable elsewhere. How the hook prompts and fails differs by client: see Supported Clients below
43
43
  - **Every client**: the server-side checks (read-only mode, dry runs, the recipient allowlist and send caps, mail-tips refusal, rule refusal), annotations and instructions work in any MCP client, including Codex CLI, Gemini CLI and Claude Desktop with a manual config; clients that support Agent Skills can copy the skill folder
44
44
  - **Private logs**: one stderr line per tool call (tool, action, outcome, duration), never its arguments; `OUTLOOK_DEBUG=true` adds detail with addresses and IDs redacted
45
- - **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports (including conversation exports) written only inside the temp directory, `~/Downloads`, `~/Documents` or `OUTLOOK_EXPORT_DIR`, never to dot-prefixed names; paths must be absolute (or start with `~/`) and symlinks aren't followed; existing files are never replaced unless `export` is called with `overwrite: true` (and never a symlink, hard-linked file or dotfile); files are created `0600` and new folders `0700`; a partly written file is removed if a write fails
45
+ - **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports (including conversation exports) written only inside the temp directory, `~/Downloads`, `~/Documents` or `OUTLOOK_EXPORT_DIR`, never to dot-prefixed names; paths must be absolute (or start with `~/`) and symlinks aren't followed; an `export` `savePath` ending in `/` is a folder, created if missing; existing files are never replaced unless `export` is called with `overwrite: true` (and never a symlink, hard-linked file or dotfile); files are created `0600` and new folders `0700`; a partly written file is removed if a write fails
46
46
  - **Throttling-aware Graph client**: `429` (and `503`/`504` for non-POST requests) retried honouring `Retry-After`, a per-attempt inactivity timeout (`OUTLOOK_REQUEST_TIMEOUT_MS`, default 60000 ms) and at most 4 requests in flight, so bulk operations neither hang nor throttle themselves
47
47
  - **Shared mailboxes off by default**: `.Shared` scopes are requested only when `OUTLOOK_SHARED_MAILBOX` is set (`read` keeps shared access read-only)
48
48
  - **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
@@ -73,9 +73,9 @@ Built by [Little Bear Apps](https://littlebearapps.com).
73
73
  - **Calendar (3 tools)**: `list-events` (`startAfter`, `startBefore`, `subject` filters), `create-event` (`dryRun`; retry-safe `transactionId`), `manage-event` (update, decline, cancel, delete; `dryRun` on all)
74
74
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
75
75
  - **Folders (1 tool)**: `folders` — list, create, move, stats, delete; folders addressable by nested path (`Parent/Child`) or ID
76
- - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
76
+ - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete (12 conditions including `hasAttachments`, 9 actions, `except*` exceptions)
77
77
  - **Categories (3 tools)**: `manage-category`, `apply-category`, `manage-focused-inbox`
78
- - **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies, set working hours
78
+ - **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies (`dryRun`; schedules shown as UTC plus a labelled local time), set working hours
79
79
  - **Advanced (2 tools)**: `access-shared-mailbox` (messages, `listFolders`, `folderId`, nested folder paths), `find-meeting-rooms`
80
80
 
81
81
  ## Documentation
@@ -92,6 +92,6 @@ Built by [Little Bear Apps](https://littlebearapps.com).
92
92
  - [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/>)
93
93
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
94
94
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
95
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.14.0 — security and safety release: read-only mode, server instructions, more dry-run previews, mail-tips refusal, the plugin skill and a safety hook for Claude Code, GitHub Copilot and Cursor, and fixes for three advisories covering the recipient allowlist, export file writes and dry runs)
96
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.14.0 safety skill, hooks and MCP hardening; v3.15.0 structured outputs and paging; v4.0.0 MCP 2026-07-28 and server-side confirmation; the patch-release fix queue; v3.8.x carry-over; v3.16.0+ new Graph APIs)
95
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.14.1 — security and safety release: read-only mode, server instructions, more dry-run previews, mail-tips refusal, the plugin skill and a safety hook for Claude Code, GitHub Copilot and Cursor, and fixes for three advisories covering the recipient allowlist, export file writes and dry runs)
96
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.14.1 live-test fixes; v3.15.0 structured outputs and paging; v4.0.0 MCP 2026-07-28 and server-side confirmation; the patch-release fix queue; v3.8.x carry-over; v3.16.0+ new Graph APIs)
97
97
  - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy (report vulnerabilities privately via GitHub private vulnerability reporting; acknowledged within 7 days), 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.14.0",
3
+ "version": "3.14.1",
4
4
  "mcpName": "io.github.littlebearapps/outlook-assistant",
5
5
  "description": "Outlook Assistant — MCP server with 22 tools for email, calendar, contacts, and settings via Microsoft Graph API",
6
6
  "main": "index.js",
package/rules/create.js CHANGED
@@ -104,7 +104,7 @@ async function handleCreateRule(args) {
104
104
  // Dry-run: preview without creating
105
105
  if (dryRun) {
106
106
  const preview = formatRuleDryRunPreview(rule);
107
- let text = `DRY RUN — Rule preview (not created):\n\n${preview}`;
107
+ let text = `Rule preview (not created):\n\n${preview}`;
108
108
  if (allWarnings.length > 0) {
109
109
  text += `\n\nWarnings:\n${allWarnings.map((w) => `- ${w}`).join('\n')}`;
110
110
  }
package/rules/index.js CHANGED
@@ -106,11 +106,32 @@ async function handleEditRuleSequence(args) {
106
106
  { sequence }
107
107
  );
108
108
 
109
+ // Exchange may renumber other rules to make room, and deleting a rule
110
+ // later doesn't shift them back (#307), so show the resulting order.
111
+ let order = '';
112
+ try {
113
+ const after = await getInboxRules(accessToken);
114
+ const before = new Map(rules.map((r) => [r.id, r.sequence]));
115
+ const lines = [...after]
116
+ .sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
117
+ .map((r) => `${r.sequence}: ${r.displayName}`);
118
+ const moved = after.filter(
119
+ (r) => r.id !== rule.id && before.get(r.id) !== r.sequence
120
+ );
121
+ const movedNote =
122
+ moved.length > 0
123
+ ? `\n\nRenumbered by Exchange: ${moved.map((r) => `"${r.displayName}" ${before.get(r.id)} → ${r.sequence}`).join('; ')}. Tell the user; deleting a rule later doesn't shift them back.`
124
+ : '\n\nNo other rule was renumbered.';
125
+ order = `\n\nRule order now (sequence: name):\n${lines.join('\n')}${movedNote}`;
126
+ } catch (_error) {
127
+ // The reorder succeeded; the order listing is a courtesy.
128
+ }
129
+
109
130
  return {
110
131
  content: [
111
132
  {
112
133
  type: 'text',
113
- text: `Successfully updated the sequence of rule "${ruleName}" to ${sequence}.`,
134
+ text: `Successfully updated the sequence of rule "${ruleName}" to ${sequence}.${order}`,
114
135
  },
115
136
  ],
116
137
  };
@@ -128,7 +149,7 @@ const rulesTools = [
128
149
  {
129
150
  name: 'manage-rules',
130
151
  description:
131
- 'Server-side inbox rules (destructive: covers `delete`; `dryRun` previews create/update). Rules run on the Exchange server whichever client is open. action=`list` (default) returns rules with id/name/sequence; `includeDetails: true` adds conditions/actions/exceptions. action=`create` builds a rule from condition params (12, e.g. fromAddresses, containsSubject, bodyContains, hasAttachments, importance, sentTo, sensitivity), action params (9, e.g. moveToFolder/copyToFolder — folder name, nested path like `Triage/Delete`, or ID — forwardTo, redirectTo, assignCategories, markAsRead, delete) and optional `except*` exceptions. action=`update` patches fields by `ruleId` or `ruleName`. action=`reorder` changes priority via `sequence` (lower runs first). action=`delete` removes a rule. The recipient allowlist applies to forwardTo/redirectTo. There is deliberately no `permanentDelete` action (too dangerous for AI use; use the Outlook UI). Subject to session rate limits (`OUTLOOK_MAX_MANAGE_RULES_PER_SESSION`).',
152
+ 'Server-side inbox rules (destructive: covers `delete`; `dryRun` previews create/update). Rules run on the Exchange server whichever client is open. action=`list` (default) returns rules with id/name/sequence; `includeDetails: true` adds conditions/actions/exceptions. action=`create` builds a rule from condition params (12, e.g. fromAddresses, containsSubject, bodyContains, hasAttachments, importance, sensitivity), action params (9, e.g. moveToFolder/copyToFolder — folder name, nested path like `Triage/Delete`, or ID — forwardTo, redirectTo, assignCategories, markAsRead, delete) and optional `except*` exceptions. action=`update` patches fields by `ruleId` or `ruleName`. action=`reorder` changes priority via `sequence` (lower runs first). action=`delete` removes a rule. The recipient allowlist applies to forwardTo/redirectTo. There is no `permanentDelete` action. Changes count against the session limit (`OUTLOOK_MAX_MANAGE_RULES_PER_SESSION`, else `OUTLOOK_MAX_EMAILS_PER_SESSION`); 0 refuses them.',
132
153
  ...toolMetadata('manage-rules', 'Inbox Rules'),
133
154
  inputSchema: {
134
155
  type: 'object',
package/rules/list.js CHANGED
@@ -153,7 +153,7 @@ function formatRuleConditions(rule) {
153
153
  }
154
154
 
155
155
  // Booleans
156
- if (c.hasAttachment === true) conditions.push('Has attachment');
156
+ if (c.hasAttachments === true) conditions.push('Has attachment');
157
157
  if (c.sentToMe === true) conditions.push('Sent to me');
158
158
  if (c.sentOnlyToMe === true) conditions.push('Sent only to me');
159
159
  if (c.sentCcMe === true) conditions.push('I am in CC');
@@ -269,7 +269,7 @@ function formatRuleExceptions(rule) {
269
269
  if (e.recipientContains?.length > 0) {
270
270
  parts.push(`Recipient contains: "${e.recipientContains.join('", "')}"`);
271
271
  }
272
- if (e.hasAttachment === true) parts.push('Has attachment');
272
+ if (e.hasAttachments === true) parts.push('Has attachment');
273
273
  if (e.importance) parts.push(`Importance: ${e.importance}`);
274
274
  if (e.sensitivity) parts.push(`Sensitivity: ${e.sensitivity}`);
275
275
  if (e.sentToMe === true) parts.push('Sent to me');
@@ -76,7 +76,7 @@ function buildConditions(args) {
76
76
  }
77
77
 
78
78
  // Boolean conditions
79
- if (args.hasAttachments === true) conditions.hasAttachment = true;
79
+ if (args.hasAttachments === true) conditions.hasAttachments = true;
80
80
  if (args.sentToMe === true) conditions.sentToMe = true;
81
81
  if (args.sentOnlyToMe === true) conditions.sentOnlyToMe = true;
82
82
  if (args.sentCcMe === true) conditions.sentCcMe = true;
@@ -303,7 +303,7 @@ function buildExceptions(args) {
303
303
  exceptions.bodyContains = parseCommaSeparated(args.exceptBodyContains);
304
304
  }
305
305
  if (args.exceptHasAttachments === true) {
306
- exceptions.hasAttachment = true;
306
+ exceptions.hasAttachments = true;
307
307
  }
308
308
 
309
309
  return { exceptions, warnings };
package/rules/update.js CHANGED
@@ -118,7 +118,7 @@ async function handleUpdateRule(args) {
118
118
  const currentPreview = formatRuleDryRunPreview(currentRule);
119
119
  const updatedPreview = formatRuleDryRunPreview(updatedRule);
120
120
 
121
- let text = `DRY RUN — Update preview for "${currentRule.displayName}" (not applied):\n\n`;
121
+ let text = `Update preview for "${currentRule.displayName}" (not applied):\n\n`;
122
122
  text += `CURRENT:\n${currentPreview}\n\n`;
123
123
  text += `AFTER UPDATE:\n${updatedPreview}`;
124
124
  if (allWarnings.length > 0) {
package/server.js CHANGED
@@ -9,6 +9,7 @@ const config = require('./config');
9
9
  const { createRequestHandler } = require('./request-handler');
10
10
  const { TOOLS } = require('./tools');
11
11
  const { serverInstructions } = require('./utils/server-instructions');
12
+ const { blockedTools } = require('./utils/safety');
12
13
 
13
14
  /**
14
15
  * @param {Array<object>} [tools] - tool definitions (default: the registry)
@@ -23,8 +24,11 @@ function createServer(tools = TOOLS) {
23
24
  // -32601 rather than empty stubs. (#276)
24
25
  capabilities: { tools: { listChanged: false } },
25
26
  // Model-facing safety rules and usage tips, sent in the initialize
26
- // result (#271).
27
- instructions: serverInstructions({ readOnly: config.READ_ONLY }),
27
+ // result (#271), naming any tool a session limit of 0 blocks (#302).
28
+ instructions: serverInstructions({
29
+ readOnly: config.READ_ONLY,
30
+ blockedTools: blockedTools(),
31
+ }),
28
32
  }
29
33
  );
30
34