@littlebearapps/outlook-assistant 3.12.0 → 3.12.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/delta.js CHANGED
@@ -63,19 +63,39 @@ function mailboxesConflict(tokenMailbox, prefix) {
63
63
  return isAddress(token) && isAddress(target);
64
64
  }
65
65
 
66
+ const DEFAULT_PAGE_SIZE = 100;
67
+ const MAX_PAGE_SIZE = 200;
68
+
69
+ /**
70
+ * Turn the caller's `maxResults` into a delta page size: an integer from 1 to
71
+ * 200. Missing or non-numeric values give the default (100); fractions are
72
+ * floored; zero and negatives become 1; anything over 200 becomes 200.
73
+ * @param {*} value - Raw `maxResults` argument
74
+ * @returns {number} - Page size to request
75
+ */
76
+ function clampPageSize(value) {
77
+ const n =
78
+ typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
79
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
80
+ return DEFAULT_PAGE_SIZE;
81
+ }
82
+ return Math.min(Math.max(Math.floor(n), 1), MAX_PAGE_SIZE);
83
+ }
84
+
66
85
  /**
67
86
  * List emails delta handler - incremental sync
68
87
  * @param {object} args - Tool arguments
69
88
  * @param {string} [args.folder] - Folder to sync (default: inbox)
70
89
  * @param {string} [args.deltaToken] - Token from previous delta call (omit for initial sync)
71
- * @param {number} [args.maxResults] - Max results per page (default: 100)
90
+ * @param {number} [args.maxResults] - Page size, 1-200 (default: 100). Sent as
91
+ * `Prefer: odata.maxpagesize` on every page; `$top` would cap the whole sync.
72
92
  * @param {string} [args.outputVerbosity] - Output detail level
73
93
  * @returns {object} - MCP response with emails, deltaToken, and change summary
74
94
  */
75
95
  async function handleListEmailsDelta(args) {
76
96
  const folder = args.folder || 'inbox';
77
97
  const deltaToken = args.deltaToken;
78
- const maxResults = Math.min(args.maxResults || 100, 200);
98
+ const maxResults = clampPageSize(args.maxResults);
79
99
  const verbosity = args.outputVerbosity || 'standard';
80
100
  // Optional: scope the delta sync to a shared/delegated mailbox rather than
81
101
  // the signed-in account. Accepts a custom/localized folder name or path.
@@ -123,19 +143,19 @@ async function handleListEmailsDelta(args) {
123
143
  : { name: folder, mailbox: sharedMailbox }
124
144
  );
125
145
  endpoint = `${prefix}/mailFolders/${resolved.id}/messages/delta`;
126
- queryParams = {
127
- $select: getEmailFields('delta'),
128
- $top: maxResults.toString(),
129
- };
146
+ // No `$top`: on messages/delta it caps the whole sync, not the page.
147
+ queryParams = { $select: getEmailFields('delta') };
130
148
  }
131
149
 
132
- // Fetch delta results
150
+ // Fetch delta results. Graph honours the page size only on requests that
151
+ // carry the Prefer header, so continuation calls must send it too.
133
152
  const response = await callGraphAPI(
134
153
  accessToken,
135
154
  'GET',
136
155
  endpoint,
137
156
  null,
138
- deltaToken ? {} : queryParams
157
+ queryParams,
158
+ { Prefer: `odata.maxpagesize=${maxResults}` }
139
159
  );
140
160
 
141
161
  // Process results
@@ -237,7 +257,7 @@ async function handleListEmailsDelta(args) {
237
257
  // Pagination info
238
258
  if (hasMoreChanges) {
239
259
  resultText += `\n### More Pages Available\n`;
240
- resultText += `This page returned a continuation token. Call \`search-emails deltaMode=true deltaToken=<token>\` again to fetch the next page. The real delta token only emits once paging completes.\n`;
260
+ resultText += `This page returned a continuation token. Call \`search-emails deltaMode=true deltaToken=<token>\` again to fetch the next page, and pass the same \`maxResults\` on every page to keep the page size. The real delta token only emits once paging completes.\n`;
241
261
  }
242
262
 
243
263
  // Token (delta or continuation)
package/email/draft.js CHANGED
@@ -94,6 +94,51 @@ function formatDraftResponse(draft, actionLabel) {
94
94
  };
95
95
  }
96
96
 
97
+ /**
98
+ * Raised when update/send/delete is pointed at something that is not an
99
+ * unsent draft. Carries the user-facing message; handleError turns it into
100
+ * a tool error without the generic "Error ..." prefix.
101
+ */
102
+ class DraftGuardError extends Error {}
103
+
104
+ /**
105
+ * Refuse to mutate anything that is not an unsent draft. Graph's PATCH,
106
+ * DELETE and /send accept any message id, so without this check a received
107
+ * or sent message could be edited, deleted or re-sent (#246).
108
+ * @param {string} accessToken - Graph access token
109
+ * @param {string} id - Message id the caller passed as the draft id
110
+ * @param {string} action - The draft action being guarded (for the message)
111
+ * @throws {DraftGuardError} If the id is not a draft or does not exist
112
+ */
113
+ async function assertIsDraft(accessToken, id, action) {
114
+ let message;
115
+ try {
116
+ message = await callGraphAPI(
117
+ accessToken,
118
+ 'GET',
119
+ `me/messages/${id}`,
120
+ null,
121
+ {
122
+ $select: 'id,isDraft,subject',
123
+ }
124
+ );
125
+ } catch (error) {
126
+ if (/status 404\b|ErrorItemNotFound/.test(error.message)) {
127
+ throw new DraftGuardError(
128
+ `Draft not found: \`${id}\`. It may already have been sent or deleted, or the ID is wrong.`
129
+ );
130
+ }
131
+ throw error;
132
+ }
133
+
134
+ if (message?.isDraft !== true) {
135
+ const subject = message?.subject ? ` ("${message.subject}")` : '';
136
+ throw new DraftGuardError(
137
+ `Message \`${id}\`${subject} is not a draft, so draft action=${action} refused it and nothing was changed. update/send/delete only act on unsent drafts.`
138
+ );
139
+ }
140
+ }
141
+
97
142
  /**
98
143
  * Draft handler — routes to action-specific logic
99
144
  * @param {object} args - Tool arguments
@@ -242,12 +287,14 @@ async function handleUpdateDraft(args) {
242
287
  if (allowlistError) return allowlistError;
243
288
  }
244
289
 
245
- // Rate limit check
246
- const rateLimitError = checkRateLimit('draft');
247
- if (rateLimitError) return rateLimitError;
248
-
249
290
  try {
250
291
  const accessToken = await ensureAuthenticated();
292
+ // Refusals must not consume a rate-limit slot, so check before counting
293
+ await assertIsDraft(accessToken, id, 'update');
294
+
295
+ const rateLimitError = checkRateLimit('draft');
296
+ if (rateLimitError) return rateLimitError;
297
+
251
298
  const draft = await callGraphAPI(
252
299
  accessToken,
253
300
  'PATCH',
@@ -272,12 +319,14 @@ async function handleSendDraft(args) {
272
319
  };
273
320
  }
274
321
 
275
- // Rate limit via send-email counter (shares limit with direct sends)
276
- const rateLimitError = checkRateLimit('send-email');
277
- if (rateLimitError) return rateLimitError;
278
-
279
322
  try {
280
323
  const accessToken = await ensureAuthenticated();
324
+ await assertIsDraft(accessToken, id, 'send');
325
+
326
+ // Rate limit via send-email counter (shares limit with direct sends)
327
+ const rateLimitError = checkRateLimit('send-email');
328
+ if (rateLimitError) return rateLimitError;
329
+
281
330
  await callGraphAPI(accessToken, 'POST', `me/messages/${id}/send`);
282
331
  return {
283
332
  content: [
@@ -308,12 +357,13 @@ async function handleDeleteDraft(args) {
308
357
 
309
358
  try {
310
359
  const accessToken = await ensureAuthenticated();
360
+ await assertIsDraft(accessToken, id, 'delete');
311
361
  await callGraphAPI(accessToken, 'DELETE', `me/messages/${id}`);
312
362
  return {
313
363
  content: [
314
364
  {
315
365
  type: 'text',
316
- text: `Draft \`${id}\` deleted.`,
366
+ text: `Draft \`${id}\` deleted. It skips Deleted Items and goes to Recoverable Items, where Outlook's "Recover deleted items" can restore it for a limited time, depending on your account.`,
317
367
  },
318
368
  ],
319
369
  };
@@ -458,6 +508,13 @@ async function handleForwardDraft(args) {
458
508
  * Standard error handler
459
509
  */
460
510
  function handleError(actionLabel, error) {
511
+ if (error instanceof DraftGuardError) {
512
+ return {
513
+ content: [{ type: 'text', text: error.message }],
514
+ isError: true,
515
+ };
516
+ }
517
+
461
518
  if (error.message === 'Authentication required') {
462
519
  return {
463
520
  content: [
package/email/export.js CHANGED
@@ -18,7 +18,9 @@ const {
18
18
  const { getEmailFields } = require('../utils/field-presets');
19
19
  const { resolveFolderPath } = require('./folder-utils');
20
20
  const { buildMailboxPrefix } = require('../utils/mailbox');
21
+ const { quoteSearchPhrase } = require('../utils/odata-helpers');
21
22
  const { safeAttachmentFilename } = require('./attachments');
23
+ const { writeClaimedFile } = require('../utils/safe-write');
22
24
 
23
25
  // Export format constants
24
26
  const EXPORT_FORMATS = {
@@ -336,7 +338,7 @@ async function handleBatchExportEmails(args) {
336
338
  outputDir,
337
339
  `batch_export_${timestamp}`,
338
340
  'csv',
339
- new Set(),
341
+ null,
340
342
  csvContent,
341
343
  'utf8'
342
344
  );
@@ -513,7 +515,7 @@ async function searchEmailsForExport(accessToken, query, mailbox = null) {
513
515
  searchParts.push(`subject:${query.subject}`);
514
516
  }
515
517
  if (searchParts.length > 0) {
516
- params.$search = `"${searchParts.join(' ')}"`;
518
+ params.$search = quoteSearchPhrase(searchParts.join(' '));
517
519
  delete params.$orderby; // Can't combine $search with $orderby
518
520
  }
519
521
 
@@ -694,7 +696,7 @@ async function saveAttachments(
694
696
  outputDir,
695
697
  `${messageTag(emailId)}_${base}`,
696
698
  extension,
697
- claimedPaths || new Set(),
699
+ claimedPaths,
698
700
  buffer
699
701
  );
700
702
  saved.push({
@@ -728,82 +730,6 @@ function filenameTimestamp(isoDateTime) {
728
730
  return parsed.toISOString().slice(0, 19).replace(/[:.]/g, '-');
729
731
  }
730
732
 
731
- /**
732
- * Claim a not-yet-used path in `outputDir`, appending `_2`, `_3`, ... until the
733
- * name is free both on disk and among the paths already claimed in this batch.
734
- *
735
- * Silent overwrite is the dangerous part of the collision defect: the exporter
736
- * reported `Successful N / Failed 0` while messages vanished. Never overwrite —
737
- * disambiguate instead, and let the caller reconcile via the manifest.
738
- *
739
- * The claim is synchronous, so it is atomic with respect to the event loop and
740
- * safe under the batch exporter's 4-way concurrency even though the write
741
- * itself happens after an await.
742
- *
743
- * @param {string} outputDir - Target directory
744
- * @param {string} base - Filename without extension
745
- * @param {string} extension - Extension without a leading dot
746
- * @param {Set<string>} claimed - Paths already claimed by this batch
747
- * @returns {string} - An unused absolute path, now claimed
748
- */
749
- function claimUniquePath(outputDir, base, extension, claimed) {
750
- const root = path.resolve(outputDir);
751
- const ext = extension ? `.${extension}` : '';
752
- let candidate = path.join(root, `${base}${ext}`);
753
- let suffix = 1;
754
- while (claimed.has(candidate) || pathEntryExists(candidate)) {
755
- suffix += 1;
756
- candidate = path.join(root, `${base}_${suffix}${ext}`);
757
- }
758
- // Names are built from sanitised parts, but confine defensively anyway.
759
- if (path.dirname(candidate) !== root) {
760
- throw new Error('Refusing to write export file outside outputDir');
761
- }
762
- claimed.add(candidate);
763
- return candidate;
764
- }
765
-
766
- /**
767
- * Like fs.existsSync, but a dangling symlink counts as existing (existsSync
768
- * follows the link and reports false).
769
- * @param {string} candidate
770
- * @returns {boolean}
771
- */
772
- function pathEntryExists(candidate) {
773
- try {
774
- fs.lstatSync(candidate);
775
- return true;
776
- } catch {
777
- return false;
778
- }
779
- }
780
-
781
- /**
782
- * Claim a unique name in `outputDir` and write `data` to it exclusively. The
783
- * `wx` flag fails on any existing entry — including a dangling symlink planted
784
- * after the claim — so an export never overwrites a file or follows a link;
785
- * on EEXIST the next suffix is claimed instead.
786
- * @param {string} outputDir - Target directory
787
- * @param {string} base - Filename without extension (already sanitised)
788
- * @param {string} extension - Extension without a leading dot ('' for none)
789
- * @param {Set<string>} claimed - Paths already claimed by this export
790
- * @param {string|Buffer} data - File contents
791
- * @param {string} [encoding] - Encoding for string data
792
- * @returns {string} - Absolute path actually written
793
- */
794
- function writeClaimedFile(outputDir, base, extension, claimed, data, encoding) {
795
- for (let attempt = 0; attempt < 1000; attempt++) {
796
- const candidate = claimUniquePath(outputDir, base, extension, claimed);
797
- try {
798
- fs.writeFileSync(candidate, data, { encoding, flag: 'wx' });
799
- return candidate;
800
- } catch (error) {
801
- if (error.code !== 'EEXIST') throw error;
802
- }
803
- }
804
- throw new Error(`Too many files named ${base} in ${outputDir}`);
805
- }
806
-
807
733
  /**
808
734
  * Short, filesystem-safe tag for a message id. Graph ids in one mailbox share
809
735
  * a long common prefix and are caller-supplied, so neither a raw prefix nor
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Email folder utilities
3
3
  */
4
- const { callGraphAPI } = require('../utils/graph-api');
5
4
  const { resolveFolder, looksLikeFolderId } = require('../folder/resolve');
6
5
  const { buildMailboxPrefix } = require('../utils/mailbox');
7
6
 
@@ -111,129 +110,7 @@ async function resolveFolderPath(accessToken, folderName, mailbox = null) {
111
110
  }
112
111
  }
113
112
 
114
- /**
115
- * Get the ID of a mail folder by its name
116
- * @param {string} accessToken - Access token
117
- * @param {string} folderName - Name of the folder to find
118
- * @returns {Promise<string|null>} - Folder ID or null if not found
119
- */
120
- async function getFolderIdByName(accessToken, folderName) {
121
- try {
122
- // First try with exact match filter
123
- console.error(`Looking for folder with name "${folderName}"`);
124
- const response = await callGraphAPI(
125
- accessToken,
126
- 'GET',
127
- 'me/mailFolders',
128
- null,
129
- { $filter: `displayName eq '${folderName}'` }
130
- );
131
-
132
- if (response.value && response.value.length > 0) {
133
- console.error(
134
- `Found folder "${folderName}" with ID: ${response.value[0].id}`
135
- );
136
- return response.value[0].id;
137
- }
138
-
139
- // If exact match fails, try to get all folders and do a case-insensitive comparison
140
- console.error(
141
- `No exact match found for "${folderName}", trying case-insensitive search`
142
- );
143
- const allFoldersResponse = await callGraphAPI(
144
- accessToken,
145
- 'GET',
146
- 'me/mailFolders',
147
- null,
148
- { $top: 100 }
149
- );
150
-
151
- if (allFoldersResponse.value) {
152
- const lowerFolderName = folderName.toLowerCase();
153
- const matchingFolder = allFoldersResponse.value.find(
154
- (folder) => folder.displayName.toLowerCase() === lowerFolderName
155
- );
156
-
157
- if (matchingFolder) {
158
- console.error(
159
- `Found case-insensitive match for "${folderName}" with ID: ${matchingFolder.id}`
160
- );
161
- return matchingFolder.id;
162
- }
163
- }
164
-
165
- console.error(`No folder found matching "${folderName}"`);
166
- return null;
167
- } catch (error) {
168
- console.error(`Error finding folder "${folderName}": ${error.message}`);
169
- return null;
170
- }
171
- }
172
-
173
- /**
174
- * Get all mail folders
175
- * @param {string} accessToken - Access token
176
- * @returns {Promise<Array>} - Array of folder objects
177
- */
178
- async function getAllFolders(accessToken) {
179
- try {
180
- // Get top-level folders
181
- const response = await callGraphAPI(
182
- accessToken,
183
- 'GET',
184
- 'me/mailFolders',
185
- null,
186
- {
187
- $top: 100,
188
- $select:
189
- 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount',
190
- }
191
- );
192
-
193
- if (!response.value) {
194
- return [];
195
- }
196
-
197
- // Get child folders for folders with children
198
- const foldersWithChildren = response.value.filter(
199
- (f) => f.childFolderCount > 0
200
- );
201
-
202
- const childFolderPromises = foldersWithChildren.map(async (folder) => {
203
- try {
204
- const childResponse = await callGraphAPI(
205
- accessToken,
206
- 'GET',
207
- `me/mailFolders/${folder.id}/childFolders`,
208
- null,
209
- {
210
- $select:
211
- 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount',
212
- }
213
- );
214
-
215
- return childResponse.value || [];
216
- } catch (error) {
217
- console.error(
218
- `Error getting child folders for "${folder.displayName}": ${error.message}`
219
- );
220
- return [];
221
- }
222
- });
223
-
224
- const childFolders = await Promise.all(childFolderPromises);
225
-
226
- // Combine top-level folders and all child folders
227
- return [...response.value, ...childFolders.flat()];
228
- } catch (error) {
229
- console.error(`Error getting all folders: ${error.message}`);
230
- return [];
231
- }
232
- }
233
-
234
113
  module.exports = {
235
114
  WELL_KNOWN_FOLDERS,
236
115
  resolveFolderPath,
237
- getFolderIdByName,
238
- getAllFolders,
239
116
  };
package/email/index.js CHANGED
@@ -48,7 +48,7 @@ const emailTools = [
48
48
  deltaMode: {
49
49
  type: 'boolean',
50
50
  description:
51
- 'Enable delta sync mode. Returns only changes since last sync. Use deltaToken for subsequent calls. Honors `sharedMailbox`/`email` (and custom `folder` paths) to sync within a shared/delegated mailbox.',
51
+ 'Enable delta sync mode. Returns only changes since last sync. Use deltaToken for subsequent calls; an initial sync larger than `maxResults` arrives over several pages, each returning a continuation token to pass back until a delta token is returned. Honors `sharedMailbox`/`email` (and custom `folder` paths) to sync within a shared/delegated mailbox.',
52
52
  },
53
53
  internetMessageId: {
54
54
  type: 'string',
@@ -58,7 +58,7 @@ const emailTools = [
58
58
  conversationId: {
59
59
  type: 'string',
60
60
  description:
61
- 'Get all messages in a conversation thread by conversationId. Honors `sharedMailbox`/`email` to thread within a shared/delegated mailbox.',
61
+ 'Get the messages in a conversation thread by conversationId, oldest first (up to 100; a longer thread is marked truncated — use `export target=conversation` for up to 1000). Honors `sharedMailbox`/`email` to thread within a shared/delegated mailbox.',
62
62
  },
63
63
  groupByConversation: {
64
64
  type: 'boolean',
@@ -74,7 +74,7 @@ const emailTools = [
74
74
  searchExpression: {
75
75
  type: 'string',
76
76
  description:
77
- 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases; a single bare token is auto-quoted. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there. RELEVANCE, NOT RECENCY: an untranslated expression is answered by Graph `$search` over the whole message including the body, ranked by relevance and not sorted by date, so top hits can look unrelated to a caller expecting a subject match. `query` is the more predictable choice for a term you expect in a subject line; `searchExpression` is the one that reaches body text.',
77
+ 'Raw Microsoft Graph `$search` expression for advanced server-side search, e.g. `subject:"invoice"`, `from:github.com`, or `foo OR bar`. Quote your own phrases, escaping any `"` or `\\` inside them with a backslash; a single bare token is auto-quoted and escaped for you. Pair with `searchAllFolders: true` for cross-folder search. Bypasses other search params. NOTE: personal Outlook.com accounts reject field-scoped `$search` outright; since v3.10.0 recognised `from:`/`to:`/`subject:` expressions are translated into the closest equivalent OData filters and retried automatically (a `subject:` term becomes a substring match, so it is close but not identical) (reported as strategy `raw-kql-translated`). Expressions that cannot be translated exactly — free text, `AND`/`OR`, unknown prefixes — are not retried, so use `query` for those there. RELEVANCE, NOT RECENCY: an untranslated expression is answered by Graph `$search` over the whole message including the body, ranked by relevance and not sorted by date, so top hits can look unrelated to a caller expecting a subject match. `query` is the more predictable choice for a term you expect in a subject line; `searchExpression` is the one that reaches body text.',
78
78
  },
79
79
  kqlQuery: {
80
80
  type: 'string',
@@ -147,7 +147,7 @@ const emailTools = [
147
147
  maxResults: {
148
148
  type: 'number',
149
149
  description:
150
- 'Max results per page for delta sync (default: 100, max: 200)',
150
+ 'Delta sync page size (deltaMode only): 1-200, default 100, sent to Graph as the `Prefer: odata.maxpagesize` header. It sizes each page, not the whole sync: while a page returns a continuation token, keep calling with that token until a delta token is returned, and pass the same `maxResults` on every page (an omitted value means 100).',
151
151
  },
152
152
  // Conversation params
153
153
  includeHeaders: {
@@ -327,7 +327,7 @@ const emailTools = [
327
327
  {
328
328
  name: 'draft',
329
329
  description:
330
- 'Full draft lifecycle for review-before-send workflows (destructive: covers `send` and `delete`). action=`create` saves a new draft in the Drafts folder and returns its id (use `dryRun: true` to preview without saving; `checkRecipients: true` runs mail-tips first). action=`update` patches an existing draft by `id` (only fields passed are changed). action=`send` dispatches an existing draft — shares the rate limit with `send-email`. action=`delete` removes a draft permanently. action=`reply`/`reply-all` creates a reply draft from a message `id` (use `comment` to prepend text — mutually exclusive with `body`). action=`forward` creates a forward draft (requires `id` and `to`). Recipient allowlist applies to create/update/forward. Returns the draft object on create/update/reply/forward; status confirmation on send/delete.',
330
+ 'Full draft lifecycle for review-before-send workflows (destructive: covers `send` and `delete`). action=`create` saves a new draft in the Drafts folder and returns its id (use `dryRun: true` to preview without saving; `checkRecipients: true` runs mail-tips first). action=`update` patches an existing draft by `id` (only fields passed are changed). action=`send` dispatches an existing draft — shares the rate limit with `send-email`. action=`delete` deletes a draft: it skips Deleted Items and goes to Recoverable Items (restorable for a limited time, depending on your account, via "Recover deleted items" in Outlook). update/send/delete refuse any `id` that is not an unsent draft (received or sent messages are never changed). action=`reply`/`reply-all` creates a reply draft from a message `id` (use `comment` to prepend text — mutually exclusive with `body`). action=`forward` creates a forward draft (requires `id` and `to`). Recipient allowlist applies to create/update/forward. Returns the draft object on create/update/reply/forward; status confirmation on send/delete.',
331
331
  annotations: {
332
332
  title: 'Draft Operations',
333
333
  readOnlyHint: false,
@@ -354,7 +354,7 @@ const emailTools = [
354
354
  id: {
355
355
  type: 'string',
356
356
  description:
357
- 'Draft or message ID. Required for update/send/delete/reply/reply-all/forward.',
357
+ 'Draft or message ID. Required for update/send/delete/reply/reply-all/forward. update/send/delete need a draft ID; reply/reply-all/forward take any message ID.',
358
358
  },
359
359
  to: {
360
360
  type: 'string',
@@ -406,7 +406,7 @@ const emailTools = [
406
406
  {
407
407
  name: 'update-email',
408
408
  description:
409
- 'Update message state without modifying content (idempotent — safe to retry). action=`mark-read`/`mark-unread` toggles the `isRead` flag on a single message by `id`. action=`flag` sets a follow-up flag with optional `dueDateTime`/`startDateTime` (ISO 8601). action=`unflag` clears the flag. action=`complete` marks the flag as done. Flag/unflag/complete accept either `id` (single) or `ids` (batch array) — batch operations use Graph `$batch` for efficiency. Pass `sharedMailbox` (or alias `email`) to update messages in a shared/delegated mailbox instead of the signed-in account (requires Mail.ReadWrite.Shared + delegate access). Returns status confirmation per message.',
409
+ 'Update message state without modifying content (idempotent — safe to retry). action=`mark-read`/`mark-unread` toggles the `isRead` flag on a single message by `id`. action=`flag` sets a follow-up flag with optional `dueDateTime`/`startDateTime` (ISO 8601 with a time: a value with `Z` or a ±hh:mm offset is kept as that exact instant; a value without one is read in the configured default timezone (OUTLOOK_DEFAULT_TIMEZONE); date-only or unparseable values are refused before any change). With only `dueDateTime`, the start defaults to 09:00 on the due date in the default timezone, or to the due time if that is earlier. action=`unflag` clears the flag. action=`complete` marks the flag as done. Flag/unflag/complete accept either `id` (single) or `ids` (batch array) — messages in a batch are updated one at a time (one PATCH each, not Graph `$batch`). Pass `sharedMailbox` (or alias `email`) to update messages in a shared/delegated mailbox instead of the signed-in account (requires Mail.ReadWrite.Shared + delegate access). Returns status confirmation per message.',
410
410
  annotations: {
411
411
  title: 'Update Email',
412
412
  readOnlyHint: false,
@@ -436,11 +436,13 @@ const emailTools = [
436
436
  // Flag params
437
437
  dueDateTime: {
438
438
  type: 'string',
439
- description: 'Due date/time for follow-up, ISO 8601 (action=flag)',
439
+ description:
440
+ 'Due date/time for follow-up (action=flag). ISO 8601 with a time: "2026-03-01T09:00:00Z" or "2026-03-01T09:00:00+10:00" is that exact instant; "2026-03-01T09:00:00" (no zone) is read in the default timezone (OUTLOOK_DEFAULT_TIMEZONE).',
440
441
  },
441
442
  startDateTime: {
442
443
  type: 'string',
443
- description: 'Start date/time for follow-up, ISO 8601 (action=flag)',
444
+ description:
445
+ 'Start date/time for follow-up (action=flag), same format as dueDateTime. Defaults to 09:00 on the due date in the default timezone (capped at the due time) when only dueDateTime is given.',
444
446
  },
445
447
  sharedMailbox: {
446
448
  type: 'string',
@@ -575,7 +577,7 @@ const emailTools = [
575
577
  {
576
578
  name: 'export',
577
579
  description:
578
- 'Export emails to file formats for archival, forensics, or programmatic processing. target=`message` (default) exports a single email by `id` to `savePath` — accepts `mime`/`eml`/`markdown`/`json`/`csv`. target=`messages` batch-exports either an explicit `emailIds` array or messages matching `searchQuery` (or `query` shortcut) into `outputDir` — accepts `markdown`/`json`/`csv`. target=`conversation` exports a full thread by `conversationId` into `outputDir` (chronological by default; pass `order: "reverse"` for newest-first) — accepts `eml`/`mbox`/`markdown`/`json`/`html`/`csv`. target=`mime` returns raw RFC-822 MIME bytes for `id` (use `headersOnly` for just headers, `base64` for encoded transport, `maxSize` to cap at default 1MB). All targets accept `sharedMailbox` (alias `email`) to export from a shared/delegated mailbox instead of the signed-in account — pass it whenever the id(s)/conversationId/searchQuery come from a shared mailbox, or exports fail with 404 ErrorInvalidMailboxItemId. `includeAttachments` defaults to true for single-message exports and false for batch. Format support varies by target — see the format param enum.',
580
+ 'Export emails to file formats for archival, forensics, or programmatic processing. target=`message` (default) exports a single email by `id` to `savePath` — accepts `mime`/`eml`/`markdown`/`json`/`csv`. target=`messages` batch-exports either an explicit `emailIds` array or messages matching `searchQuery` (or `query` shortcut) into `outputDir` — accepts `markdown`/`json`/`csv`. target=`conversation` exports a full thread (up to 1000 messages) by `conversationId` into `outputDir` (chronological by default; pass `order: "reverse"` for newest-first) — accepts `eml`/`mbox`/`markdown`/`json`/`html`/`csv`. target=`mime` returns raw RFC-822 MIME bytes for `id` (use `headersOnly` for just headers, `base64` for encoded transport, `maxSize` to cap at default 1MB). All targets accept `sharedMailbox` (alias `email`) to export from a shared/delegated mailbox instead of the signed-in account — pass it whenever the id(s)/conversationId/searchQuery come from a shared mailbox, or exports fail with 404 ErrorInvalidMailboxItemId. `includeAttachments` defaults to true for single-message exports and false for batch. Format support varies by target — see the format param enum.',
579
581
  annotations: {
580
582
  title: 'Export Emails',
581
583
  readOnlyHint: false,
package/email/search.js CHANGED
@@ -14,7 +14,11 @@ const {
14
14
  DEFAULT_LIMITS,
15
15
  } = require('../utils/response-formatter');
16
16
  const { getEmailFields } = require('../utils/field-presets');
17
- const { escapeODataString } = require('../utils/odata-helpers');
17
+ const {
18
+ escapeODataString,
19
+ escapeSearchPhrase,
20
+ quoteSearchPhrase,
21
+ } = require('../utils/odata-helpers');
18
22
 
19
23
  // Upper bound on how many recent messages the client-side fallback scans
20
24
  // before giving up. Deliberately DECOUPLED from the requested result count so
@@ -328,12 +332,13 @@ async function progressiveSearch(
328
332
  trimmedKql.includes(':') || /\s/.test(trimmedKql);
329
333
  // Already-quoted phrases and KQL-looking expressions (field syntax
330
334
  // or multi-word) are passed through as-is; only bare single tokens
331
- // are wrapped so Graph treats them as phrase searches.
335
+ // are wrapped so Graph treats them as phrase searches. The wrapped
336
+ // token's own `"` and `\` are escaped so they cannot end the phrase. (#251)
332
337
  let kqlForSearch;
333
338
  if (alreadyQuoted || looksLikeExpression) {
334
339
  kqlForSearch = trimmedKql;
335
340
  } else {
336
- kqlForSearch = `"${trimmedKql}"`;
341
+ kqlForSearch = `"${escapeSearchPhrase(trimmedKql)}"`;
337
342
  }
338
343
 
339
344
  // Graph rejects field-scoped expressions outright on personal accounts
@@ -1212,7 +1217,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
1212
1217
 
1213
1218
  // Handle search terms - use $search only for free-text query
1214
1219
  if (searchTerms.query) {
1215
- params.$search = `"${searchTerms.query}"`;
1220
+ params.$search = quoteSearchPhrase(searchTerms.query);
1216
1221
  }
1217
1222
 
1218
1223
  // Build filter conditions array - use $filter for structured fields (more reliable)
package/folder/index.js CHANGED
@@ -12,7 +12,7 @@ const folderTools = [
12
12
  {
13
13
  name: 'folders',
14
14
  description:
15
- "Manage mail folders (tool-level destructiveHint=true because `delete` permanently removes a folder; `list` and `stats` are read-only sub-actions despite the annotation). Folders can be addressed by name, by a slash-separated PATH for nested folders (e.g. `Triage/Delete`, `Inbox/Clients/Acme`, case-insensitive), or by explicit ID; `list` output includes each folder's full path and `[id: …]`. A bare name resolves a unique top-level folder first, then searches nested folders (ambiguous names return the candidates — disambiguate with a path or ID). action=`list` (default) returns the folder tree (toggle `includeItemCounts` for unread/total, `includeChildren` for hierarchy). action=`create` makes a new folder under the root, or under `parentFolder` (name/path) / `parentFolderId`, and returns its id. action=`move` relocates emails (`emailIds`) into `targetFolder` (name/path) or `targetFolderId`. action=`stats` returns counts (totalItemCount/unreadItemCount) for `folder` (name/path) or `folderId`, suitable for pagination planning. action=`delete` removes a folder (by `folderName`/path or `folderId`) and its contents — on Outlook.com the folder is moved to Deleted Items (recoverable until you empty it); M365/Exchange accounts may hard-delete per retention policy. Every action accepts `sharedMailbox` (alias `email`) to target a shared/delegated mailbox instead of the signed-in account — folder names, paths, and IDs are then resolved inside that mailbox. Protected folders cannot be deleted in any mailbox.",
15
+ "Manage mail folders (tool-level destructiveHint=true because `delete` removes a folder and its contents; `list` and `stats` are read-only sub-actions despite the annotation). Folders can be addressed by name, by a slash-separated PATH for nested folders (e.g. `Triage/Delete`, `Inbox/Clients/Acme`, case-insensitive), or by explicit ID; `list` output includes each folder's full path and `[id: …]`. A bare name resolves a unique top-level folder first, then searches nested folders (ambiguous names return the candidates — disambiguate with a path or ID). action=`list` (default) returns the folder tree (toggle `includeItemCounts` for unread/total, `includeChildren` for hierarchy). action=`create` makes a new folder under the root, or under `parentFolder` (name/path) / `parentFolderId`, and returns its id. action=`move` relocates emails (`emailIds`) into `targetFolder` (name/path) or `targetFolderId`. action=`stats` returns counts (totalItemCount/unreadItemCount) for `folder` (name/path) or `folderId`, suitable for pagination planning. action=`delete` removes a folder (by `folderName`/path or `folderId`) and its contents — it does not go to Deleted Items, and Graph doesn't document whether it can be restored (some accounts may offer Outlook's \"Recover deleted items\" for a limited time, but don't rely on it), so move out anything you might need first. Every action accepts `sharedMailbox` (alias `email`) to target a shared/delegated mailbox instead of the signed-in account — folder names, paths, and IDs are then resolved inside that mailbox. Protected folders cannot be deleted in any mailbox.",
16
16
  annotations: {
17
17
  title: 'Mail Folders',
18
18
  readOnlyHint: false,
package/folder/resolve.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Shared, path-aware, ambiguity-aware mail-folder resolver. (#216)
3
3
  *
4
- * Replaces the two top-level-only resolvers (`getFolderIdByName` in
4
+ * Replaced the top-level-only resolvers (`getFolderIdByName` in
5
5
  * email/folder-utils.js and `resolveFolderName` in folder/stats.js) that could
6
- * not address nested folders. Accepts, in priority order:
6
+ * not address nested folders (manage-rules folder actions use it since #248).
7
+ * Accepts, in priority order:
7
8
  * 1. an explicit folder ID (never guessed from a name),
8
9
  * 2. a well-known alias (inbox, archive, sent, ...),
9
10
  * 3. a folder PATH like "Triage/Delete" or "Inbox/Clients/Acme"
package/index.js CHANGED
@@ -35,8 +35,16 @@ Key environment variables:
35
35
  OUTLOOK_CLIENT_SECRET Client secret VALUE (not the Secret ID)
36
36
  OUTLOOK_AUTH_METHOD device-code (default) | browser
37
37
  OUTLOOK_AUTH_AUDIENCE common | consumers | organizations | <tenant-guid>
38
- OUTLOOK_MAX_EMAILS_PER_SESSION Cap on sends per session
38
+ OUTLOOK_SHARED_MAILBOX Opt in to shared mailboxes: read | true (work/school only)
39
39
  OUTLOOK_ALLOWED_RECIPIENTS Comma-separated recipient allowlist
40
+ OUTLOOK_MAX_EMAILS_PER_SESSION Default cap per session for every rate-limited tool
41
+ (send-email, draft, manage-rules); 0 or unset = no cap
42
+ OUTLOOK_MAX_<TOOL>_PER_SESSION Per-tool cap overriding the default, tool name in upper
43
+ case with _ for -, e.g. OUTLOOK_MAX_SEND_EMAIL_PER_SESSION
44
+ OUTLOOK_DEFAULT_TIMEZONE IANA timezone for event times (default Australia/Melbourne)
45
+ OUTLOOK_IMMUTABLE_IDS Set to "true" for message IDs that survive folder moves
46
+ OUTLOOK_SEARCH_SCAN_LIMIT Local search fallback window (default 500, max 5000)
47
+ OUTLOOK_REQUEST_TIMEOUT_MS Graph request inactivity timeout (default 60000)
40
48
  USE_TEST_MODE Set to "true" to run against mock data
41
49
 
42
50
  Documentation: https://github.com/littlebearapps/outlook-assistant`;
package/llms.txt CHANGED
@@ -83,6 +83,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
83
83
  - [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/>)
84
84
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
85
85
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
86
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.12.0 — opt-in shared-mailbox read and organise support via `sharedMailbox` and `OUTLOOK_SHARED_MAILBOX` (#228), `list-events` `startAfter`/`startBefore`/`subject` filters (#193), token refresh requesting the granted scopes plus `offline_access` (#241), and two security fixes: dot segments in resource paths rejected, `export` writes confined to the output directory. Preceded by v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2); v3.11.1 — search and export correctness (filters combined with dates no longer silently dropped, batch export no longer overwrites same-day reply chains); v3.11.0 — `--version`/`--help` CLI flags (#68) and the `AADSTS7000215` Secret ID vs Value explanation (#69); v3.10.0 — search correctness (#217, #229, #230, #231))
86
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.12.1 — Graph reliability and correctness fixes: throttling retries, a request inactivity timeout and a 4-request concurrency cap (#244), complete delta paging (#254), a draft-only guard on `draft` update/send/delete (#246), nested folder paths in `manage-rules` (#248), flag dates honouring `Z` and offsets (#247), attendee types kept on `manage-event update` (#249), no "via API" text on decline/cancel (#242), escaped `$search` phrases (#251), and conversation read/export working on personal accounts. Preceded by v3.12.0 — opt-in shared-mailbox read and organise support via `sharedMailbox` and `OUTLOOK_SHARED_MAILBOX` (#228), `list-events` `startAfter`/`startBefore`/`subject` filters (#193), token refresh requesting the granted scopes plus `offline_access` (#241), and two security fixes: dot segments in resource paths rejected, `export` writes confined to the output directory. Preceded by v3.11.2 — security release: attachment downloads confined to `outputDir` (GHSA-755c-c45g-69rv) and the access token only ever sent to Microsoft Graph (GHSA-mqfm-wfjq-jxq2); v3.11.1 — search and export correctness (filters combined with dates no longer silently dropped, batch export no longer overwrites same-day reply chains); v3.11.0 — `--version`/`--help` CLI flags (#68) and the `AADSTS7000215` Secret ID vs Value explanation (#69); v3.10.0 — search correctness (#217, #229, #230, #231))
87
87
  - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.12.x tool description audit, v3.8.x carry-over, v3.13.0+ new Graph APIs)
88
- - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
88
+ - [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.12.0",
3
+ "version": "3.12.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",