@littlebearapps/outlook-assistant 3.11.2 → 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.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
package/email/headers.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
+ const { buildMailboxPrefix } = require('../utils/mailbox');
9
10
 
10
11
  /**
11
12
  * Important headers to highlight (in order of relevance)
@@ -158,6 +159,9 @@ async function handleGetEmailHeaders(args) {
158
159
  const groupByType = args.groupByType || false;
159
160
  const importantOnly = args.importantOnly || false;
160
161
  const raw = args.raw || false;
162
+ // Shared-mailbox message IDs aren't resolvable under /me — route to the
163
+ // owning mailbox when a sharedMailbox/email is supplied.
164
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
161
165
 
162
166
  if (!emailId) {
163
167
  return {
@@ -187,7 +191,7 @@ async function handleGetEmailHeaders(args) {
187
191
  'sentDateTime',
188
192
  ].join(',');
189
193
 
190
- const endpoint = `me/messages/${emailId}`;
194
+ const endpoint = `${prefix}/messages/${emailId}`;
191
195
  const queryParams = {
192
196
  $select: selectFields,
193
197
  };
package/email/index.js CHANGED
@@ -33,7 +33,7 @@ const emailTools = [
33
33
  {
34
34
  name: 'search-emails',
35
35
  description:
36
- 'Search, list, delta-sync, or thread-group emails — six modes selected by parameters (read-only). With no params: lists recent emails in `folder` (default `inbox`). With `query`/`from`/`to`/`subject`/date filters: full search (combines via OData filter). With `searchExpression` (deprecated alias `kqlQuery`): a raw Microsoft Graph `$search` expression for advanced server-side search. With `deltaMode: true`: returns current state plus a `deltaToken`; pass the token back on the next call for incremental changes only — ideal for inbox monitoring. With `groupByConversation: true`: returns conversation threads. With `conversationId`: returns all messages in a single thread. With `internetMessageId`: looks up a message by its RFC Message-ID header. Personal Outlook.com accounts have limited `$search` support — this tool falls back through OData filters / boolean filters / recent listing automatically, but structured filters (`from`/`subject`/`receivedAfter`/`hasAttachments`/`unreadOnly`) return cleaner results. Returns paged messages with id/subject/from/receivedDateTime/preview by default; use `outputVerbosity` to expand.',
36
+ 'Search, list, delta-sync, or thread-group emails — six modes selected by parameters (read-only). With no params: lists recent emails in `folder` (default `inbox`). With `query`/`from`/`to`/`subject`/date filters: full search (combines via OData filter). With `searchExpression` (deprecated alias `kqlQuery`): a raw Microsoft Graph `$search` expression for advanced server-side search. With `deltaMode: true`: returns current state plus a `deltaToken`; pass the token back on the next call for incremental changes only — ideal for inbox monitoring. With `groupByConversation: true`: returns conversation threads. With `conversationId`: returns all messages in a single thread. With `internetMessageId`: looks up a message by its RFC Message-ID header. Set `sharedMailbox` (or alias `email`) to search a shared/delegated mailbox instead of the signed-in account — works with custom folders and nested folder paths. Personal Outlook.com accounts have limited `$search` support — this tool falls back through OData filters / boolean filters / recent listing automatically, but structured filters (`from`/`subject`/`receivedAfter`/`hasAttachments`/`unreadOnly`) return cleaner results. Returns paged messages with id/subject/from/receivedDateTime/preview by default; use `outputVerbosity` to expand.',
37
37
  annotations: {
38
38
  title: 'Search Emails',
39
39
  readOnlyHint: true,
@@ -48,22 +48,22 @@ 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.',
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',
55
55
  description:
56
- 'Look up email by Message-ID header (e.g. <abc123@example.com>). For threading/deduplication.',
56
+ 'Look up email by Message-ID header (e.g. <abc123@example.com>). For threading/deduplication. Honors `sharedMailbox`/`email` to look up within a shared/delegated mailbox.',
57
57
  },
58
58
  conversationId: {
59
59
  type: 'string',
60
60
  description:
61
- 'Get all messages in a conversation thread by conversationId.',
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',
65
65
  description:
66
- 'List conversations (threads) grouped by conversationId instead of individual emails.',
66
+ 'List conversations (threads) grouped by conversationId instead of individual emails. Honors `sharedMailbox`/`email` (and custom `folder` paths) to group within a shared/delegated mailbox.',
67
67
  },
68
68
  // Search/list params
69
69
  query: {
@@ -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',
@@ -83,7 +83,17 @@ const emailTools = [
83
83
  },
84
84
  folder: {
85
85
  type: 'string',
86
- description: "Email folder (default: 'inbox')",
86
+ description:
87
+ "Email folder (default: 'inbox'). Accepts a well-known name, a custom/localized display name, or a nested path like `Inbox/Subfolder`.",
88
+ },
89
+ sharedMailbox: {
90
+ type: 'string',
91
+ description:
92
+ 'Email address of a shared/delegated mailbox to search instead of the signed-in account. Combine with `folder` (incl. custom subfolders/paths) or `searchAllFolders`. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
93
+ },
94
+ email: {
95
+ type: 'string',
96
+ description: 'Alias for `sharedMailbox`.',
87
97
  },
88
98
  from: {
89
99
  type: 'string',
@@ -132,12 +142,12 @@ const emailTools = [
132
142
  deltaToken: {
133
143
  type: 'string',
134
144
  description:
135
- 'Token from previous delta call for incremental sync (deltaMode only)',
145
+ 'Token from previous delta call for incremental sync (deltaMode only). The token is authoritative — it encodes its own mailbox and folder, so `folder`/`sharedMailbox` are ignored and a token from a different mailbox is rejected.',
136
146
  },
137
147
  maxResults: {
138
148
  type: 'number',
139
149
  description:
140
- '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).',
141
151
  },
142
152
  // Conversation params
143
153
  includeHeaders: {
@@ -158,6 +168,7 @@ const emailTools = [
158
168
  return handleSearchByMessageId({
159
169
  messageId: args.internetMessageId,
160
170
  outputVerbosity: args.outputVerbosity,
171
+ sharedMailbox: args.sharedMailbox || args.email || null,
161
172
  });
162
173
  }
163
174
  if (args.conversationId) {
@@ -189,7 +200,7 @@ const emailTools = [
189
200
  {
190
201
  name: 'read-email',
191
202
  description:
192
- 'Read a single email by id (read-only). Default: returns the full message body (HTML stripped to text by default), subject, from/to/cc, receivedDateTime, conversationId, attachments metadata, and webLink as Markdown. With `headersMode: true`: returns RFC-822 forensic headers instead (DKIM, SPF, DMARC, Received chain, Message-ID, Authentication-Results) — pair with `importantOnly: true` for the security-relevant subset, `groupByType: true` for category-bucketed view, or `raw: true` for JSON instead of Markdown. With `includeHeaders: true` (non-headers-mode): adds basic headers alongside body. Use `outputVerbosity` (minimal/standard/full) to control field count.',
203
+ 'Read a single email by id (read-only). Default: returns the full message body (HTML stripped to text by default), subject, from/to/cc, receivedDateTime, conversationId, attachments metadata, and webLink as Markdown. With `headersMode: true`: returns RFC-822 forensic headers instead (DKIM, SPF, DMARC, Received chain, Message-ID, Authentication-Results) — pair with `importantOnly: true` for the security-relevant subset, `groupByType: true` for category-bucketed view, or `raw: true` for JSON instead of Markdown. With `includeHeaders: true` (non-headers-mode): adds basic headers alongside body. Use `outputVerbosity` (minimal/standard/full) to control field count. **If the id came from a shared/delegated mailbox (e.g. via `search-emails` or `access-shared-mailbox` with `sharedMailbox` set), you MUST pass the same `sharedMailbox` (or alias `email`) here** — message IDs are mailbox-scoped, and reading a shared-mailbox id without it fails with 404 ErrorInvalidMailboxItemId.',
193
204
  annotations: {
194
205
  title: 'Read Email',
195
206
  readOnlyHint: true,
@@ -203,6 +214,15 @@ const emailTools = [
203
214
  type: 'string',
204
215
  description: 'ID of the email to read',
205
216
  },
217
+ sharedMailbox: {
218
+ type: 'string',
219
+ description:
220
+ 'Email address of the shared/delegated mailbox the id belongs to. Required when the id was obtained from a shared mailbox — message IDs are mailbox-scoped and reading without it returns 404 ErrorInvalidMailboxItemId. Requires delegate access + Mail.Read.Shared. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
221
+ },
222
+ email: {
223
+ type: 'string',
224
+ description: 'Alias for `sharedMailbox`.',
225
+ },
206
226
  headersMode: {
207
227
  type: 'boolean',
208
228
  description:
@@ -307,7 +327,7 @@ const emailTools = [
307
327
  {
308
328
  name: 'draft',
309
329
  description:
310
- '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.',
311
331
  annotations: {
312
332
  title: 'Draft Operations',
313
333
  readOnlyHint: false,
@@ -334,7 +354,7 @@ const emailTools = [
334
354
  id: {
335
355
  type: 'string',
336
356
  description:
337
- '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.',
338
358
  },
339
359
  to: {
340
360
  type: 'string',
@@ -386,7 +406,7 @@ const emailTools = [
386
406
  {
387
407
  name: 'update-email',
388
408
  description:
389
- '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. 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.',
390
410
  annotations: {
391
411
  title: 'Update Email',
392
412
  readOnlyHint: false,
@@ -416,40 +436,59 @@ const emailTools = [
416
436
  // Flag params
417
437
  dueDateTime: {
418
438
  type: 'string',
419
- 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).',
420
441
  },
421
442
  startDateTime: {
422
443
  type: 'string',
423
- 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.',
446
+ },
447
+ sharedMailbox: {
448
+ type: 'string',
449
+ description:
450
+ 'Email address of a shared/delegated mailbox whose message(s) to update instead of the signed-in account. Requires delegate access + Mail.ReadWrite.Shared. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
451
+ },
452
+ email: {
453
+ type: 'string',
454
+ description: 'Alias for `sharedMailbox`.',
424
455
  },
425
456
  },
426
457
  additionalProperties: false,
427
458
  required: ['action'],
428
459
  },
429
460
  handler: async (args) => {
461
+ const sharedMailbox = args.sharedMailbox || args.email || null;
430
462
  switch (args.action) {
431
463
  case 'mark-read':
432
- return handleMarkAsRead({ id: args.id, isRead: true });
464
+ return handleMarkAsRead({ id: args.id, isRead: true, sharedMailbox });
433
465
  case 'mark-unread':
434
- return handleMarkAsRead({ id: args.id, isRead: false });
466
+ return handleMarkAsRead({
467
+ id: args.id,
468
+ isRead: false,
469
+ sharedMailbox,
470
+ });
435
471
  case 'flag':
436
472
  return handleSetMessageFlag({
437
473
  messageId: args.id,
438
474
  messageIds: args.ids,
439
475
  dueDateTime: args.dueDateTime,
440
476
  startDateTime: args.startDateTime,
477
+ sharedMailbox,
441
478
  });
442
479
  case 'unflag':
443
480
  return handleClearMessageFlag({
444
481
  messageId: args.id,
445
482
  messageIds: args.ids,
446
483
  markComplete: false,
484
+ sharedMailbox,
447
485
  });
448
486
  case 'complete':
449
487
  return handleClearMessageFlag({
450
488
  messageId: args.id,
451
489
  messageIds: args.ids,
452
490
  markComplete: true,
491
+ sharedMailbox,
453
492
  });
454
493
  default:
455
494
  return {
@@ -466,7 +505,7 @@ const emailTools = [
466
505
  {
467
506
  name: 'attachments',
468
507
  description:
469
- 'Inspect or retrieve email attachments. action=`list` (default) returns metadata for all attachments on `messageId` (id, name, contentType, size, isInline) — read-only. action=`view` returns inline content for text/JSON/XML attachments via `attachmentId`; binary types require download. action=`download` saves the attachment to disk at `outputDir` (default system tmpdir, auto-created) and returns the saved file path. `messageId` is required for all actions; `attachmentId` is required for view/download. Use `outputVerbosity` to control list field count.',
508
+ 'Inspect or retrieve email attachments. action=`list` (default) returns metadata for all attachments on `messageId` (id, name, contentType, size, isInline) — read-only. action=`view` returns inline content for text/JSON/XML attachments via `attachmentId`; binary types require download. action=`download` saves the attachment to disk at `outputDir` (default system tmpdir, auto-created) and returns the saved file path. `messageId` is required for all actions; `attachmentId` is required for view/download. If `messageId` came from a shared/delegated mailbox, pass the same `sharedMailbox` (or alias `email`) — attachment IDs are scoped to the message and fail under /me otherwise. Use `outputVerbosity` to control list field count.',
470
509
  annotations: {
471
510
  title: 'Attachments',
472
511
  readOnlyHint: false,
@@ -491,6 +530,15 @@ const emailTools = [
491
530
  type: 'string',
492
531
  description: 'Attachment ID (action=view/download, required)',
493
532
  },
533
+ sharedMailbox: {
534
+ type: 'string',
535
+ description:
536
+ 'Email address of the shared/delegated mailbox the messageId belongs to. Required when the message came from a shared mailbox. Requires delegate access + Mail.Read.Shared. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
537
+ },
538
+ email: {
539
+ type: 'string',
540
+ description: 'Alias for `sharedMailbox`.',
541
+ },
494
542
  outputDir: {
495
543
  type: 'string',
496
544
  description:
@@ -529,7 +577,7 @@ const emailTools = [
529
577
  {
530
578
  name: 'export',
531
579
  description:
532
- '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). `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.',
533
581
  annotations: {
534
582
  title: 'Export Emails',
535
583
  readOnlyHint: false,
@@ -606,6 +654,15 @@ const emailTools = [
606
654
  description:
607
655
  'Message order (target=conversation, default: chronological)',
608
656
  },
657
+ sharedMailbox: {
658
+ type: 'string',
659
+ description:
660
+ 'Email address of a shared/delegated mailbox to export from instead of the signed-in account. Applies to all targets (message/messages/conversation/mime) — pass it whenever the id(s)/conversationId/searchQuery belong to a shared mailbox. Requires delegate access + Mail.Read.Shared. Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
661
+ },
662
+ email: {
663
+ type: 'string',
664
+ description: 'Alias for `sharedMailbox`.',
665
+ },
609
666
  // MIME params
610
667
  headersOnly: {
611
668
  type: 'boolean',
package/email/list.js CHANGED
@@ -51,13 +51,20 @@ async function handleListEmails(args) {
51
51
  const requestedCount =
52
52
  args.count ?? args.maxResults ?? DEFAULT_LIMITS.listEmails;
53
53
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
54
+ // `search-emails` falls through to list mode when no filters are supplied,
55
+ // so the shared-mailbox scope has to be honoured here too.
56
+ const sharedMailbox = args.sharedMailbox || args.email || null;
54
57
 
55
58
  try {
56
59
  // Get access token
57
60
  const accessToken = await ensureAuthenticated();
58
61
 
59
62
  // Resolve the folder path
60
- const endpoint = await resolveFolderPath(accessToken, folder);
63
+ const endpoint = await resolveFolderPath(
64
+ accessToken,
65
+ folder,
66
+ sharedMailbox
67
+ );
61
68
 
62
69
  // Select fields based on verbosity level
63
70
  const fieldPreset = getFieldPresetForVerbosity(verbosity);
@@ -4,6 +4,7 @@
4
4
  const _config = require('../config'); // Reserved for future use
5
5
  const { callGraphAPI } = require('../utils/graph-api');
6
6
  const { ensureAuthenticated } = require('../auth');
7
+ const { buildMailboxPrefix } = require('../utils/mailbox');
7
8
 
8
9
  /**
9
10
  * Mark email as read handler
@@ -13,6 +14,7 @@ const { ensureAuthenticated } = require('../auth');
13
14
  async function handleMarkAsRead(args) {
14
15
  const emailId = args.id;
15
16
  const isRead = args.isRead !== undefined ? args.isRead : true; // Default to true
17
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
16
18
 
17
19
  if (!emailId) {
18
20
  return {
@@ -30,7 +32,7 @@ async function handleMarkAsRead(args) {
30
32
  const accessToken = await ensureAuthenticated();
31
33
 
32
34
  // Make API call to update email read status
33
- const endpoint = `me/messages/${emailId}`;
35
+ const endpoint = `${prefix}/messages/${emailId}`;
34
36
  const updateData = {
35
37
  isRead: isRead,
36
38
  };
package/email/mime.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  const { callGraphAPIRaw } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
+ const { buildMailboxPrefix } = require('../utils/mailbox');
9
10
 
10
11
  /**
11
12
  * Parse MIME headers from raw content
@@ -93,6 +94,8 @@ async function handleGetMimeContent(args) {
93
94
  const headersOnly = args.headersOnly || false;
94
95
  const returnBase64 = args.base64 || false;
95
96
  const maxSize = args.maxSize !== undefined ? args.maxSize : 1024 * 1024; // 1MB default
97
+ // Mailbox-scoped IDs: route to /users/{mailbox} for a shared/delegated mailbox.
98
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
96
99
 
97
100
  if (!emailId) {
98
101
  return {
@@ -111,7 +114,7 @@ async function handleGetMimeContent(args) {
111
114
 
112
115
  try {
113
116
  // Fetch raw MIME content
114
- const mimeContent = await callGraphAPIRaw(accessToken, emailId);
117
+ const mimeContent = await callGraphAPIRaw(accessToken, emailId, prefix);
115
118
 
116
119
  if (!mimeContent) {
117
120
  return {
package/email/read.js CHANGED
@@ -11,6 +11,7 @@ const {
11
11
  VERBOSITY,
12
12
  } = require('../utils/response-formatter');
13
13
  const { getEmailFields } = require('../utils/field-presets');
14
+ const { buildMailboxPrefix } = require('../utils/mailbox');
14
15
 
15
16
  /**
16
17
  * Get field preset based on verbosity and options
@@ -45,6 +46,9 @@ async function handleReadEmail(args) {
45
46
  const emailId = args.id;
46
47
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
47
48
  const includeHeaders = args.includeHeaders || false;
49
+ // Message IDs are mailbox-scoped: an ID issued by a shared/delegated mailbox
50
+ // is not resolvable under /me. Route to /users/{mailbox} when supplied.
51
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
48
52
 
49
53
  if (!emailId) {
50
54
  return {
@@ -66,7 +70,7 @@ async function handleReadEmail(args) {
66
70
  const selectFields = getEmailFields(fieldPreset);
67
71
 
68
72
  // Make API call to get email details
69
- const endpoint = `me/messages/${emailId}`;
73
+ const endpoint = `${prefix}/messages/${emailId}`;
70
74
  const queryParams = {
71
75
  $select: selectFields,
72
76
  };
package/email/search.js CHANGED
@@ -7,13 +7,18 @@ const _config = require('../config'); // Reserved for future use
7
7
  const { callGraphAPI, callGraphAPIPaginated } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const { resolveFolderPath } = require('./folder-utils');
10
+ const { buildMailboxPrefix } = require('../utils/mailbox');
10
11
  const {
11
12
  formatEmailList,
12
13
  VERBOSITY,
13
14
  DEFAULT_LIMITS,
14
15
  } = require('../utils/response-formatter');
15
16
  const { getEmailFields } = require('../utils/field-presets');
16
- const { escapeODataString } = require('../utils/odata-helpers');
17
+ const {
18
+ escapeODataString,
19
+ escapeSearchPhrase,
20
+ quoteSearchPhrase,
21
+ } = require('../utils/odata-helpers');
17
22
 
18
23
  // Upper bound on how many recent messages the client-side fallback scans
19
24
  // before giving up. Deliberately DECOUPLED from the requested result count so
@@ -71,6 +76,9 @@ async function handleSearchEmails(args) {
71
76
  // Trim so a whitespace-only value doesn't send `$search: '""'`. (#169)
72
77
  const kqlQuery =
73
78
  (args.searchExpression || '').trim() || (args.kqlQuery || '').trim();
79
+ // Optional: scope the search to a shared/delegated mailbox rather than the
80
+ // signed-in account. Accepts a custom/localized folder name or nested path.
81
+ const sharedMailbox = args.sharedMailbox || args.email || null;
74
82
 
75
83
  // Select fields based on verbosity — but never at the cost of correctness.
76
84
  // When more than one search term is supplied, the ladder may satisfy one
@@ -98,13 +106,16 @@ async function handleSearchEmails(args) {
98
106
  // Get access token
99
107
  const accessToken = await ensureAuthenticated();
100
108
 
101
- // Determine endpoint - search all folders or specific folder
109
+ // Determine endpoint - search all folders or specific folder, scoped to
110
+ // the signed-in account or a shared mailbox.
102
111
  let endpoint;
103
112
  if (searchAllFolders) {
104
- endpoint = 'me/messages';
105
- console.error('Searching across all mail folders');
113
+ endpoint = `${buildMailboxPrefix(sharedMailbox)}/messages`;
114
+ console.error(
115
+ `Searching across all mail folders${sharedMailbox ? ` in ${sharedMailbox}` : ''}`
116
+ );
106
117
  } else {
107
- endpoint = await resolveFolderPath(accessToken, folder);
118
+ endpoint = await resolveFolderPath(accessToken, folder, sharedMailbox);
108
119
  console.error(`Using endpoint: ${endpoint} for folder: ${folder}`);
109
120
  }
110
121
 
@@ -321,12 +332,13 @@ async function progressiveSearch(
321
332
  trimmedKql.includes(':') || /\s/.test(trimmedKql);
322
333
  // Already-quoted phrases and KQL-looking expressions (field syntax
323
334
  // or multi-word) are passed through as-is; only bare single tokens
324
- // 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)
325
337
  let kqlForSearch;
326
338
  if (alreadyQuoted || looksLikeExpression) {
327
339
  kqlForSearch = trimmedKql;
328
340
  } else {
329
- kqlForSearch = `"${trimmedKql}"`;
341
+ kqlForSearch = `"${escapeSearchPhrase(trimmedKql)}"`;
330
342
  }
331
343
 
332
344
  // Graph rejects field-scoped expressions outright on personal accounts
@@ -1205,7 +1217,7 @@ function buildSearchParams(searchTerms, filterTerms, count, selectFields) {
1205
1217
 
1206
1218
  // Handle search terms - use $search only for free-text query
1207
1219
  if (searchTerms.query) {
1208
- params.$search = `"${searchTerms.query}"`;
1220
+ params.$search = quoteSearchPhrase(searchTerms.query);
1209
1221
  }
1210
1222
 
1211
1223
  // Build filter conditions array - use $filter for structured fields (more reliable)
@@ -1569,6 +1581,8 @@ function formatSearchResults(response, folder, verbosity, searchAllFolders) {
1569
1581
  async function handleSearchByMessageId(args) {
1570
1582
  const messageId = args.messageId;
1571
1583
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
1584
+ // Optional: scope the lookup to a shared/delegated mailbox.
1585
+ const prefix = buildMailboxPrefix(args.sharedMailbox || args.email || null);
1572
1586
 
1573
1587
  if (!messageId) {
1574
1588
  return {
@@ -1604,7 +1618,7 @@ async function handleSearchByMessageId(args) {
1604
1618
  const response = await callGraphAPI(
1605
1619
  accessToken,
1606
1620
  'GET',
1607
- 'me/messages',
1621
+ `${prefix}/messages`,
1608
1622
  null,
1609
1623
  params
1610
1624
  );
package/folder/create.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
6
  const { resolveFolder, listChildFolders } = require('./resolve');
7
+ const { buildMailboxPrefix } = require('../utils/mailbox');
7
8
 
8
9
  /**
9
10
  * Create folder handler
@@ -14,6 +15,7 @@ async function handleCreateFolder(args) {
14
15
  const folderName = (args.name || '').trim();
15
16
  const parentFolder = args.parentFolder || '';
16
17
  const parentFolderId = args.parentFolderId || '';
18
+ const sharedMailbox = args.sharedMailbox || args.email || null;
17
19
 
18
20
  if (!folderName) {
19
21
  return {
@@ -34,6 +36,7 @@ async function handleCreateFolder(args) {
34
36
  const result = await createMailFolder(accessToken, folderName, {
35
37
  name: parentFolder,
36
38
  id: parentFolderId,
39
+ mailbox: sharedMailbox,
37
40
  });
38
41
 
39
42
  return {
@@ -74,10 +77,12 @@ async function handleCreateFolder(args) {
74
77
  * Create a new mail folder
75
78
  * @param {string} accessToken - Access token
76
79
  * @param {string} folderName - Name of the folder to create
77
- * @param {{name?: string, id?: string}} parentSpec - Parent folder name/path or ID
80
+ * @param {{name?: string, id?: string, mailbox?: string|null}} parentSpec - Parent folder name/path or ID, plus optional shared mailbox
78
81
  * @returns {Promise<object>} - Result object with status and message
79
82
  */
80
83
  async function createMailFolder(accessToken, folderName, parentSpec) {
84
+ const mailbox = parentSpec.mailbox || null;
85
+ const prefix = buildMailboxPrefix(mailbox);
81
86
  try {
82
87
  // Resolve the parent folder if one was specified (supports "Parent/Child"
83
88
  // paths and explicit IDs). Leaf name (folderName) is created, not
@@ -98,7 +103,9 @@ async function createMailFolder(accessToken, folderName, parentSpec) {
98
103
  // mailbox — a name may legitimately exist under a different parent. (#216)
99
104
  const siblings = await listChildFolders(
100
105
  accessToken,
101
- parent ? parent.id : null
106
+ parent ? parent.id : null,
107
+ undefined,
108
+ mailbox
102
109
  );
103
110
  const lower = folderName.toLowerCase();
104
111
  if (siblings.some((f) => f.displayName.toLowerCase() === lower)) {
@@ -111,8 +118,8 @@ async function createMailFolder(accessToken, folderName, parentSpec) {
111
118
  }
112
119
 
113
120
  const endpoint = parent
114
- ? `me/mailFolders/${parent.id}/childFolders`
115
- : 'me/mailFolders';
121
+ ? `${prefix}/mailFolders/${parent.id}/childFolders`
122
+ : `${prefix}/mailFolders`;
116
123
 
117
124
  // Create the folder
118
125
  const folderData = {
package/folder/delete.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
6
  const { resolveFolder, WELL_KNOWN } = require('./resolve');
7
+ const { buildMailboxPrefix } = require('../utils/mailbox');
7
8
 
8
9
  /**
9
10
  * Delete folder handler
@@ -22,6 +23,8 @@ const { resolveFolder, WELL_KNOWN } = require('./resolve');
22
23
  */
23
24
  async function handleDeleteFolder(args) {
24
25
  const { folderId, folderName } = args;
26
+ const sharedMailbox = args.sharedMailbox || args.email || null;
27
+ const prefix = buildMailboxPrefix(sharedMailbox);
25
28
 
26
29
  if (!folderId && !folderName) {
27
30
  return {
@@ -56,6 +59,7 @@ async function handleDeleteFolder(args) {
56
59
  resolved = await resolveFolder(accessToken, {
57
60
  id: folderId,
58
61
  name: folderName,
62
+ mailbox: sharedMailbox,
59
63
  });
60
64
  } catch (resolveError) {
61
65
  return {
@@ -64,7 +68,11 @@ async function handleDeleteFolder(args) {
64
68
  }
65
69
 
66
70
  // Delete the folder
67
- await callGraphAPI(accessToken, 'DELETE', `me/mailFolders/${resolved.id}`);
71
+ await callGraphAPI(
72
+ accessToken,
73
+ 'DELETE',
74
+ `${prefix}/mailFolders/${resolved.id}`
75
+ );
68
76
  return {
69
77
  content: [
70
78
  {
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.",
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,
@@ -36,6 +36,16 @@ const folderTools = [
36
36
  type: 'boolean',
37
37
  description: 'Include child folders in hierarchy (action=list)',
38
38
  },
39
+ // shared-mailbox scoping (all actions)
40
+ sharedMailbox: {
41
+ type: 'string',
42
+ description:
43
+ 'Email address of a shared/delegated mailbox to target instead of the signed-in account (all actions). Requires delegate access + Mail.Read.Shared (list/stats) or Mail.ReadWrite.Shared (create/move/delete). Work/school only; needs the server opt-in setting OUTLOOK_SHARED_MAILBOX (otherwise the call is refused with setup guidance).',
44
+ },
45
+ email: {
46
+ type: 'string',
47
+ description: 'Alias for `sharedMailbox`.',
48
+ },
39
49
  // create params
40
50
  name: {
41
51
  type: 'string',