@littlebearapps/outlook-assistant 3.13.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.
Files changed (67) hide show
  1. package/.env.example +30 -3
  2. package/README.md +67 -27
  3. package/advanced/index.js +44 -174
  4. package/auth/auth-errors.js +23 -1
  5. package/auth/oauth-server.js +7 -1
  6. package/auth/token-manager.js +7 -3
  7. package/auth/token-storage.js +28 -30
  8. package/auth/tools.js +61 -82
  9. package/calendar/attendees.js +36 -0
  10. package/calendar/cancel.js +9 -25
  11. package/calendar/create.js +42 -48
  12. package/calendar/decline.js +10 -25
  13. package/calendar/delete.js +10 -25
  14. package/calendar/index.js +20 -37
  15. package/calendar/list.js +4 -16
  16. package/calendar/preview.js +461 -0
  17. package/calendar/update.js +55 -83
  18. package/categories/index.js +68 -265
  19. package/config.js +29 -1
  20. package/contacts/index.js +72 -128
  21. package/email/attachments.js +43 -125
  22. package/email/conversations.js +44 -78
  23. package/email/delta.js +69 -46
  24. package/email/draft.js +170 -103
  25. package/email/export.js +145 -110
  26. package/email/folder-utils.js +3 -2
  27. package/email/headers.js +11 -49
  28. package/email/index.js +86 -110
  29. package/email/list.js +4 -17
  30. package/email/mail-tips.js +86 -57
  31. package/email/mark-as-read.js +13 -49
  32. package/email/mime.js +39 -51
  33. package/email/read.js +16 -50
  34. package/email/search.js +47 -87
  35. package/email/send.js +82 -48
  36. package/folder/create.js +6 -25
  37. package/folder/delete.js +117 -38
  38. package/folder/index.js +19 -17
  39. package/folder/list.js +5 -17
  40. package/folder/move.js +13 -42
  41. package/folder/resolve.js +11 -6
  42. package/folder/stats.js +18 -27
  43. package/index.js +39 -45
  44. package/llms-install.md +22 -4
  45. package/llms.txt +20 -11
  46. package/outlook-auth-server.js +10 -3
  47. package/package.json +4 -1
  48. package/request-handler.js +217 -116
  49. package/rules/create.js +28 -71
  50. package/rules/index.js +52 -93
  51. package/rules/list.js +7 -19
  52. package/rules/rule-builder.js +59 -22
  53. package/rules/update.js +27 -61
  54. package/server.js +41 -0
  55. package/settings/index.js +162 -145
  56. package/tools.js +30 -0
  57. package/utils/field-presets.js +4 -2
  58. package/utils/graph-api.js +65 -22
  59. package/utils/logger.js +251 -0
  60. package/utils/mock-data.js +91 -2
  61. package/utils/read-only.js +59 -0
  62. package/utils/response-formatter.js +54 -15
  63. package/utils/risk-classes.js +324 -0
  64. package/utils/safe-write.js +372 -6
  65. package/utils/safety.js +247 -42
  66. package/utils/server-instructions.js +73 -0
  67. package/utils/tool-error.js +33 -0
package/.env.example CHANGED
@@ -14,13 +14,27 @@ OUTLOOK_CLIENT_SECRET=your-client-secret-here
14
14
  # Optional: Enable test mode with mock data (true/false)
15
15
  USE_TEST_MODE=false
16
16
 
17
- # Optional: Safety controls for send-email
18
- # Maximum emails that can be sent per server session (0 = unlimited)
17
+ # Optional: Safety controls for sending
18
+ # Default per-session cap, counted separately per tool, for send-email
19
+ # (including draft send), draft create/update/reply/reply-all/forward,
20
+ # manage-rules and create-event. Unset = no limit. 0 BLOCKS those tools (since
21
+ # v3.14.1; it used to mean no limit), and so does any value that isn't a whole
22
+ # number. Override one tool with OUTLOOK_MAX_<TOOL>_PER_SESSION, e.g.
23
+ # OUTLOOK_MAX_SEND_EMAIL_PER_SESSION=0 with OUTLOOK_MAX_DRAFT_PER_SESSION=20
24
+ # lets the AI write drafts but never send.
19
25
  # OUTLOOK_MAX_EMAILS_PER_SESSION=10
20
26
 
21
- # Restrict sending to specific domains/addresses (comma-separated)
27
+ # Restrict recipients of sends, drafts, rule forwards and event attendees to
28
+ # these domains/addresses (comma-separated)
22
29
  # OUTLOOK_ALLOWED_RECIPIENTS=mycompany.com,partner@example.com
23
30
 
31
+ # Optional: Read-only mode. Every tool call that would change something (send,
32
+ # draft, move, flag, delete, rules, settings, local file writes) is refused
33
+ # before it runs, including dryRun previews; reads and sign-in still work.
34
+ # Accepts true/1/yes/on (any case). A value that isn't recognised also turns it
35
+ # on, with a warning. Restart the server after changing it.
36
+ # OUTLOOK_READ_ONLY=true
37
+
24
38
  # Optional: Enable immutable IDs (IDs persist through folder moves)
25
39
  # OUTLOOK_IMMUTABLE_IDS=true
26
40
 
@@ -39,6 +53,19 @@ USE_TEST_MODE=false
39
53
  # retried on a 429 asking for a short wait (10 s or less, 20 s in total).
40
54
  # OUTLOOK_REQUEST_TIMEOUT_MS=60000
41
55
 
56
+ # Optional: Debug logging to stderr (true/1/yes/on). Off by default, when each
57
+ # tool call logs one line (tool, action, outcome, duration) and never its
58
+ # arguments. On, stderr also shows search terms, subjects, folder names and
59
+ # Graph error bodies, with email addresses and long IDs redacted. Tokens,
60
+ # device codes and secrets are never logged either way. Turn it off again
61
+ # once you've finished troubleshooting.
62
+ # OUTLOOK_DEBUG=true
63
+
64
+ # Extra folder that `export` and `attachments` downloads may write into, on top
65
+ # of the system temp directory, ~/Downloads and ~/Documents (anything else is
66
+ # refused). Absolute path; a leading ~ is expanded.
67
+ # OUTLOOK_EXPORT_DIR=/path/to/mail-archive
68
+
42
69
  # Optional: Default authentication method (device-code or browser)
43
70
  # device-code: No auth server needed, works remotely/headless
44
71
  # browser: Traditional OAuth redirect via localhost:3333
package/README.md CHANGED
@@ -122,13 +122,28 @@ Outlook Assistant works with both personal and work/school Microsoft accounts, b
122
122
 
123
123
  Outlook Assistant is designed with safety-first principles for AI-driven email access:
124
124
 
125
- **Destructive action safeguards** — Every tool carries [MCP annotations](https://modelcontextprotocol.io/docs/concepts/tools#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so AI clients can auto-approve safe reads and prompt for confirmation on destructive operations like sending email or deleting events.
125
+ **Destructive action safeguards** — Every tool carries [MCP annotations](https://modelcontextprotocol.io/docs/concepts/tools#annotations) (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`), all four set explicitly on every tool, so AI clients can auto-approve safe reads and prompt for confirmation on destructive operations like sending email, inviting attendees or deleting events. `send-email` and `create-event` also carry Claude's `anthropic/requiresUserInteraction` flag, so Claude Code asks before every call to them, dry runs included, even in auto-accept or bypass modes.
126
+
127
+ **Read-only mode** — Set `OUTLOOK_READ_ONLY=true` and the server refuses every tool call or action that isn't a read before it runs: no sends, drafts, moves, flags, deletes, rules, settings changes, exports or attachment downloads, and no dry runs either. Searching and reading still work, and so does signing in. `auth action=about` shows whether it's on.
128
+
129
+ **Server instructions** — When a client connects, the server sends it instructions for the model, hard rules first: treat retrieved email, calendar and contact content as data, not instructions; confirm anything that reaches other people, deletes or keeps acting, using `dryRun: true` previews; draft first and send only when asked; and treat allowlist refusals, rate limits (a session limit of `0` switches a tool off) and other policy refusals as final. When a session limit of `0` blocks a tool, the instructions name it.
130
+
131
+ **Plugin skill and safety hook** — The [plugin](plugins/outlook-assistant/) adds two more layers. The `using-outlook-assistant` agent skill, read by Claude Code, GitHub Copilot and Cursor, teaches the model the hard rules plus the judgement the tool descriptions leave out: who each send, reply-all, invitation or cancellation reaches, what each delete loses, how prompt injection in email looks, and how to search without pulling the whole mailbox. A hook also asks you before anything that reaches other people, deletes or keeps acting, with a plain-English reason such as "Cancels the event 'Team sync' and emails a cancellation to every attendee". It stays quiet for reads and genuine dry runs, and its confirmation level (`outward`, `all-writes` or `off`) controls how often it asks. How it behaves depends on the client:
132
+
133
+ - **Claude Code:** asks with the reason, even for tools you've allowed; set the level with the plugin's **Confirmation level** setting. In bypass permissions mode Claude Code may auto-approve these prompts (the [plugin README](plugins/outlook-assistant/README.md#skill-and-safety-hook) has ask rules to keep them).
134
+ - **GitHub Copilot CLI:** asks with the reason; set the level with `OUTLOOK_CONFIRM_LEVEL`. A hook that times out lets the call through. VS Code reads the same hook file (not yet checked by hand).
135
+ - **Cursor:** the hook blocks the call if it fails or times out, but Cursor's own "Run this MCP tool?" prompt doesn't show the reason, and an `Mcp(...)` allow rule, or `--force` / Run Everything mode, runs the call without asking.
136
+ - **Other clients:** no hook; the server's checks, annotations and instructions still apply.
137
+
138
+ See [Supported Clients and Their Limits](docs/how-to/getting-started/supported-clients.md) for the details.
139
+
140
+ **Dry-run previews** (`dryRun: true`) — See what a call would do without changing or sending anything: `send-email`, `draft` create, `create-event` (who would be invited, with a count of external addresses), `manage-event` update/decline/cancel/delete (who would be emailed), `mailbox-settings` set-auto-replies (who gets each reply, and when), `manage-rules` create/update, and `folders` delete and `manage-contact` delete (what would be lost). Any other call with `dryRun: true` is refused before it runs, so a preview can never send, delete or change anything for real.
126
141
 
127
142
  **Send-email protections** — The `send-email` tool includes:
128
- - **Pre-send mail tips** (`checkRecipients: true`) — check recipients for out-of-office, mailbox full, delivery restrictions before sending
143
+ - **Pre-send mail tips** (`checkRecipients: true`) — check recipients for out-of-office, mailbox full and delivery restrictions. If the tips show any of those, an external recipient or a group with external members, the send is refused with the warnings listed; repeat it with `acknowledgeWarnings: true` once you've seen them. A failed check also stops the send. Mail tips are Microsoft 365 only: personal accounts return none
129
144
  - **Dry-run mode** (`dryRun: true`) — preview composed emails without sending
130
- - **Session rate limiting** — configurable via `OUTLOOK_MAX_EMAILS_PER_SESSION` (default: unlimited)
131
- - **Recipient allowlist** — restrict sending to approved addresses/domains via `OUTLOOK_ALLOWED_RECIPIENTS`
145
+ - **Session rate limiting** — configurable via `OUTLOOK_MAX_EMAILS_PER_SESSION` (default: no limit; `0` blocks sending and the other rate-limited tools)
146
+ - **Recipient allowlist** — restrict recipients to approved addresses/domains via `OUTLOOK_ALLOWED_RECIPIENTS`. It covers `send-email`, `draft` (create, update, forward, reply, reply-all and send), rule forward/redirect (a rule that would forward or redirect to a blocked address is refused whole), `create-event` attendees and `manage-event` update attendees; it doesn't cover `manage-event` cancel/decline messages, the cancellation an organiser's delete sends, or `mailbox-settings` automatic replies. Anything that isn't a single plain email address is refused while it's set
132
147
 
133
148
  > **Recommended setup**: enable both safety belts in your `.mcp.json` from day one. They're off by default; `auth action=about` reports their state and prints a setup hint when unset. See [`.mcp.json.example`](.mcp.json.example) for a copy-paste template.
134
149
  >
@@ -140,9 +155,9 @@ Outlook Assistant is designed with safety-first principles for AI-driven email a
140
155
  > }
141
156
  > ```
142
157
 
143
- **Input and file hardening** — IDs containing `.` or `..` path segments are refused before any request is made, continuation links (`deltaToken`) must point at `graph.microsoft.com`, and attachment downloads and exports write sanitised filenames inside the output directory without overwriting existing files or following symlinks.
158
+ **Input and file hardening** — IDs containing `.` or `..` path segments are refused before any request is made, continuation links (`deltaToken`) must point at `graph.microsoft.com`, and attachment downloads and exports write only inside the system temp directory, `~/Downloads`, `~/Documents` or `OUTLOOK_EXPORT_DIR` (never to dot-prefixed names), using sanitised filenames without overwriting existing files or following symlinks. Paths must be absolute (or start with `~/`). An explicit `export` file path is replaced only when you pass `overwrite: true`, and never if it's a symlink. Files are created readable only by you (`0600`; new folders `0700`).
144
159
 
145
- **Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview, recipient allowlist, mail-tips validation, and rate limiting. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway. `update`, `send` and `delete` refuse any ID that is not an unsent draft, so a received or sent message is never edited, deleted or re-sent.
160
+ **Draft protections** — The `draft` tool shares `send-email` safety controls: dry-run preview (`create`), mail-tips validation, rate limiting and the recipient allowlist. The allowlist is checked on create, update and forward; a reply or reply-all draft whose recipients it doesn't allow is deleted again; and `send` re-checks the draft's current to/cc/bcc, so a draft edited in Outlook can't slip past it. The `send` action shares the `send-email` rate limit counter, preventing circumvention via the draft-then-send pathway, so `OUTLOOK_MAX_SEND_EMAIL_PER_SESSION=0` blocks both. A reply or reply-all draft that the allowlist refuses, or that couldn't be created, doesn't use up a `draft` session-limit slot. `update`, `send` and `delete` refuse any ID that is not an unsent draft, so a received or sent message is never edited, deleted or re-sent.
146
161
 
147
162
  **Token-optimised architecture** — Tools are consolidated using the STRAP (Single Tool, Resource, Action Pattern) approach. 22 tools instead of 55 reduces per-turn overhead by ~11,000 tokens (~64%), keeping more of the AI's context window available for your actual conversation. Fewer tools also means the AI selects the right tool more accurately — research shows tool selection degrades beyond ~40 tools.
148
163
 
@@ -165,7 +180,7 @@ npx @littlebearapps/outlook-assistant
165
180
  To check which version you have, or to see the available options:
166
181
 
167
182
  ```bash
168
- outlook-assistant --version # prints e.g. 3.13.0
183
+ outlook-assistant --version # prints e.g. 3.14.1
169
184
  outlook-assistant --help # usage, options and key environment variables
170
185
  ```
171
186
 
@@ -186,16 +201,29 @@ You need a Microsoft Azure app registration to authenticate. See the **[Azure Se
186
201
 
187
202
  ### 3. Configure Your MCP Client
188
203
 
189
- **Plugin install (Claude Code).** The plugin bundles the server pinned to an exact version and asks for your settings when you enable it:
204
+ **Client support.** Every MCP client gets the server's own checks. The plugin adds the `using-outlook-assistant` skill and a safety hook in Claude Code, GitHub Copilot and Cursor, with different limits in each. See [Supported Clients and Their Limits](docs/how-to/getting-started/supported-clients.md).
190
205
 
191
- ```bash
192
- claude plugin marketplace add littlebearapps/outlook-assistant
193
- claude plugin install outlook-assistant@littlebearapps
194
- ```
206
+ **Plugin install.** The plugin ([`plugins/outlook-assistant`](plugins/outlook-assistant/)) bundles the server pinned to an exact version, the skill and the safety hook. It follows both the Claude Code plugin format and the [Agent Plugins](https://agent-plugins.org/) format used by GitHub Copilot, plus a Cursor manifest (`.cursor-plugin/`).
207
+
208
+ - **Claude Code.** The plugin asks for your settings when you enable it (client ID, sign-in audience, send limit per session, allowed recipients, read-only mode and confirmation level):
209
+
210
+ ```bash
211
+ claude plugin marketplace add littlebearapps/outlook-assistant
212
+ claude plugin install outlook-assistant@littlebearapps
213
+ ```
214
+
215
+ - **GitHub Copilot CLI.** Copilot has no plugin settings, so give your client ID when you first sign in, and set the hook's confirmation level with the `OUTLOOK_CONFIRM_LEVEL` environment variable:
195
216
 
196
- The same plugin folder ([`plugins/outlook-assistant`](plugins/outlook-assistant/)) also follows the [Agent Plugins](https://agent-plugins.org/) format used by GitHub Copilot and Cursor.
217
+ ```bash
218
+ copilot plugin marketplace add littlebearapps/outlook-assistant
219
+ copilot plugin install outlook-assistant@littlebearapps
220
+ ```
197
221
 
198
- **Manual config.** Add to your MCP client config. Only `OUTLOOK_CLIENT_ID` is needed for the default device-code sign-in; add `OUTLOOK_CLIENT_SECRET` only if you use the [browser flow](#browser-redirect-flow-alternative). You can also leave the client ID out and give it to your assistant when you first connect (`auth action=authenticate clientId=…`), which saves it to `~/.outlook-assistant-config.json`. An `OUTLOOK_CLIENT_ID` in the environment always takes precedence.
222
+ VS Code's Copilot agent reads the same plugin and hook file; that hasn't been checked by hand yet.
223
+
224
+ - **Cursor** (v3.14.0 or later). Cursor loads the folder as a Cursor plugin (`.cursor-plugin/plugin.json`). In Cursor CLI, load it from a clone of this repository with `cursor-agent --plugin-dir outlook-assistant/plugins/outlook-assistant`. Give your client ID when you first sign in. The v3.13.0 plugin can't sign in from Cursor (`AADSTS900023`); use the manual config below instead.
225
+
226
+ **Manual config.** Use this for Claude Desktop, Codex CLI, Gemini CLI, Windsurf and other MCP clients, or in place of a plugin (you then get no hook). Add to your MCP client config. Only `OUTLOOK_CLIENT_ID` is needed for the default device-code sign-in; add `OUTLOOK_CLIENT_SECRET` only if you use the [browser flow](#browser-redirect-flow-alternative). You can also leave the client ID out and give it to your assistant when you first connect (`auth action=authenticate clientId=…`), which saves it to `~/.outlook-assistant-config.json`. An `OUTLOOK_CLIENT_ID` in the environment always takes precedence.
199
227
 
200
228
  <details>
201
229
  <summary><strong>Claude Desktop</strong> (<code>claude_desktop_config.json</code>)</summary>
@@ -406,15 +434,20 @@ USE_TEST_MODE=false
406
434
  |----------|---------|---------|
407
435
  | `OUTLOOK_AUTH_AUDIENCE` | OAuth audience: `common`, `consumers` (personal-only Azure apps), `organizations`, or single-tenant GUID. Fixes `AADSTS9002331` for personal-only app registrations. | `common` |
408
436
  | `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone applied to calendar events when callers don't pass one (e.g. `Europe/London`, `America/New_York`). | `Australia/Melbourne` |
409
- | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Cap on `send-email` + `draft send` per MCP server lifetime. | unlimited |
410
- | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, and rule forwards. | unrestricted |
437
+ | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Default per-session cap for each rate-limited tool, counted separately until the server restarts: `send-email` (including `draft action=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 or empty means no limit; **`0` blocks the tool** (before v3.14.1, `0` meant no limit), and so does any value that isn't a whole number. | no limit |
438
+ | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of domains/addresses for sends, drafts, rule forwards and calendar invitations (`create-event` and `manage-event` update attendees). Not applied to cancellation/decline messages or automatic replies. | unrestricted |
411
439
  | `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support (work/school only). `read` requests `Mail.Read.Shared`; `true` (or `readwrite`/`1`) also requests `Mail.ReadWrite.Shared`. Unset leaves sign-in unchanged. After enabling, restart and run `auth action=authenticate force=true`. | unset (off) |
412
440
  | `OUTLOOK_SEARCH_SCAN_LIMIT` | How many recent messages the client-side search fallback scans. Personal accounts match `to` locally within this window, so the default caps how far back a `to` search reaches. Max 5000. | `500` |
413
441
  | `OUTLOOK_REQUEST_TIMEOUT_MS` | Inactivity timeout for each Graph request attempt, in milliseconds: an attempt that receives no data for this long is abandoned with a timeout error. It isn't an overall deadline, so a slow response that keeps arriving isn't cut off. Throttled (`429`) and busy (`503`/`504`) responses are retried automatically, honouring `Retry-After`. | `60000` |
442
+ | `OUTLOOK_READ_ONLY` | Read-only mode: `true` (or `1`/`yes`/`on`) refuses every tool call or action that isn't a read, including dry runs, exports and attachment downloads, before it runs. Signing in still works. An unrecognised value also turns it on, with a warning. Restart the server after changing it. | off |
443
+ | `OUTLOOK_DEBUG` | Detailed stderr logs: `true` (or `1`/`yes`/`on`) adds search strategies, subjects, folder names and Graph error bodies, with email addresses and long IDs redacted. Off, each tool call logs one line (tool, action, outcome, duration) and never its arguments. Tokens, device codes and secrets are never logged. See [Server Logs and Debug Logging](docs/troubleshooting.md#server-logs-and-debug-logging). | off |
444
+ | `OUTLOOK_EXPORT_DIR` | Extra folder that `export` and `attachments` downloads may write into. Without it, files can only go to the system temp directory, `~/Downloads` or `~/Documents`; other paths are refused. Absolute path (a leading `~` is expanded). | unset |
445
+
446
+ `OUTLOOK_CONFIRM_LEVEL` (`outward`, `all-writes` or `off`; default `outward`) isn't a server setting: the plugin's safety hook reads it, in clients with no plugin settings (GitHub Copilot, VS Code, Cursor). Set it in the environment the client starts from, not in the server's `env` block. In Claude Code, use the plugin's **Confirmation level** setting instead. See [Supported Clients and Their Limits](docs/how-to/getting-started/supported-clients.md).
414
447
 
415
448
  ### MCP Client Configuration
416
449
 
417
- See [Quick Start — Configure Your MCP Client](#3-configure-your-mcp-client) above for Claude Desktop, Claude Code, VS Code / GitHub Copilot, Cursor, and Windsurf configs.
450
+ See [Quick Start — Configure Your MCP Client](#3-configure-your-mcp-client) above for the plugin installs and the Claude Desktop, Claude Code, VS Code / GitHub Copilot, Cursor, and Windsurf configs.
418
451
 
419
452
  If installed from source, use `node` instead of `npx`:
420
453
 
@@ -476,7 +509,10 @@ This starts a local server on port 3333 to handle the OAuth callback. (The `outl
476
509
 
477
510
  ```
478
511
  outlook-assistant/
479
- ├── index.js # Main entry point (22 tools)
512
+ ├── index.js # Entry point: CLI flags, stdio transport
513
+ ├── server.js # MCP server factory (capabilities, request handler)
514
+ ├── tools.js # Tool registry (22 tools)
515
+ ├── request-handler.js # Routes MCP requests; JSON-RPC errors for unknown methods/tools
480
516
  ├── config.js # Configuration settings
481
517
  ├── outlook-auth-server.js # OAuth server (port 3333)
482
518
  ├── auth/ # Authentication module (1 tool)
@@ -499,8 +535,10 @@ outlook-assistant/
499
535
  └── utils/
500
536
  ├── graph-api.js # Microsoft Graph API client (includes $batch, path guards)
501
537
  ├── mailbox.js # me vs users/{sharedMailbox} prefix, shared-mailbox opt-in
538
+ ├── risk-classes.js # Risk class per tool/action; derives annotations and titles
539
+ ├── tool-error.js # isError tool results with a next step
502
540
  ├── safety.js # Rate limiting, recipient allowlist, dry-run
503
- ├── safe-write.js # Exclusive, outputDir-confined file writes
541
+ ├── safe-write.js # Exclusive, folder-confined file writes
504
542
  ├── datetime.js # ISO 8601 parsing and timezone conversion
505
543
  ├── odata-helpers.js # OData query building
506
544
  ├── field-presets.js # Token-efficient field selections
@@ -543,9 +581,9 @@ Enable "Allow public client flows" in Azure Portal > App registrations > Authent
543
581
 
544
582
  Fixed in v3.7.2. Earlier versions sent `client_secret` in token refresh requests for device-code auth, which Microsoft rejects for public client flows. Update to v3.7.2+ or re-authenticate.
545
583
 
546
- ### Empty API responses
584
+ ### "Authentication required."
547
585
 
548
- Check authentication status with the `auth` tool (action=status). Tokens may have expired — re-authenticate if needed.
586
+ You're signed out, or the saved token expired and couldn't be refreshed. The error says what to do next: sign in with the `auth` tool with `action=authenticate` (add `force=true` to replace an existing session), then retry the call. `auth action=status` shows the current state.
549
587
 
550
588
  ## Development
551
589
 
@@ -569,18 +607,20 @@ USE_TEST_MODE=true npm start
569
607
  1. Create a new module directory (e.g. `tasks/`)
570
608
  2. Implement tool handlers in separate files
571
609
  3. Export tool definitions from the module's `index.js`
572
- 4. Import and add tools to the `TOOLS` array in main `index.js`
573
- 5. Add tests in `test/`
574
- 6. Update `docs/quickrefs/tools-reference.md`
610
+ 4. Add the module's tools to the `TOOLS` array in `tools.js`
611
+ 5. Classify every tool and action in `utils/risk-classes.js` (a test fails on anything unclassified); the annotations and title come from there
612
+ 6. Add tests in `test/`
613
+ 7. Update `docs/quickrefs/tools-reference.md`
575
614
 
576
615
  ## Documentation
577
616
 
578
617
  | Guide | Description |
579
618
  |-------|-------------|
580
619
  | [Getting Started](docs/how-to/getting-started/connect-outlook-to-claude.md) | Install, configure, and authenticate — start here |
620
+ | [Supported Clients](docs/how-to/getting-started/supported-clients.md) | Install per client, what the skill and safety hook do in each, and known limits |
581
621
  | [Azure Setup Guide](docs/guides/azure-setup.md) | Azure account creation, app registration, permissions, and secrets |
582
- | [How-To Guides](docs/how-to/index.md) | 29 practical guides for email, calendar, contacts, and settings |
583
- | [Roadmap](ROADMAP.md) | Active milestones (v3.12.x, v3.8.x, v3.13.0+) and recent releases |
622
+ | [How-To Guides](docs/how-to/index.md) | 30 practical guides for email, calendar, contacts, and settings |
623
+ | [Roadmap](ROADMAP.md) | Active milestones (v3.14.1, v3.15.0, v4.0.0, v3.8.x, v3.16.0+) and recent releases |
584
624
  | [Troubleshooting](docs/troubleshooting.md) | Known errors and fixes, including auth, search, export and shared mailboxes |
585
625
  | [FAQ](docs/faq/faq.md) | Install, accounts, permissions, tokens, updates, uninstall |
586
626
  | [Tools Reference](docs/quickrefs/tools-reference.md) | All 22 tools with parameters |
@@ -595,7 +635,7 @@ Full documentation: [docs/](docs/README.md)
595
635
  - **Focused Inbox**: Only available on work/school Microsoft 365 accounts.
596
636
  - **Shared mailboxes**: Require a work/school account and are **opt-in**: set `OUTLOOK_SHARED_MAILBOX=read` (read) or `=true` (read and organise), restart the server, then re-authenticate with `auth action=authenticate force=true`. Until then, `sharedMailbox` calls are refused with setup guidance (`access-shared-mailbox` keeps its previous well-known-folder behaviour). `auth action=about` shows whether the shared scopes were actually granted. Support covers reading and organising only. Reading needs `Mail.Read.Shared`; organising (move/categorise/flag/mark-read/create folders via `sharedMailbox`) needs `Mail.ReadWrite.Shared` — add it in Azure and re-authenticate (until then, shared-scoped writes fail with 403; they never fall back to your own mailbox). Custom subfolders are supported — pass `folder` as a display name or nested path (e.g. `Inbox/Vendors/Acme`), a raw `folderId`, or use `listFolders: true` (or `folders action=list, sharedMailbox: …`) to discover them. **Sending, drafts, replies, and forwards from a shared mailbox are not supported** — `send-email` and `draft` (including reply/reply-all/forward) always act on the signed-in user's own mailbox, and `Mail.Send.Shared` is not requested.
597
637
  - **Meeting room search**: Requires `Place.Read.All` permission with admin consent (work/school accounts only).
598
- - **Export default path**: Exports and attachment downloads save to the system temp directory by default. Use `outputDir` (or `savePath`) to choose a different location.
638
+ - **Export default path**: Exports and attachment downloads save to the system temp directory by default (a batch `export` with `target=messages` needs an `outputDir`). Use `outputDir` (or `savePath`) with an absolute path (or one starting with `~/`) inside the system temp directory, `~/Downloads`, `~/Documents` or `OUTLOOK_EXPORT_DIR`; relative paths and other folders are refused. An existing `savePath` file is replaced only with `overwrite: true`.
599
639
  - **`list-events` date filters**: `startAfter`/`startBefore` must include `Z` or a ±hh:mm offset; zone-less and date-only values are rejected rather than guessed.
600
640
 
601
641
  ## Contributing
package/advanced/index.js CHANGED
@@ -26,6 +26,8 @@ const {
26
26
  zonedParts,
27
27
  zonedWallTimeToUtcMs,
28
28
  } = require('../utils/datetime');
29
+ const { toolMetadata } = require('../utils/risk-classes');
30
+ const { toolError, authRequiredError } = require('../utils/tool-error');
29
31
 
30
32
  /**
31
33
  * Format an email for display (simplified)
@@ -78,14 +80,9 @@ async function handleAccessSharedMailbox(args) {
78
80
  const sharedMailbox = args.sharedMailbox || args.email;
79
81
 
80
82
  if (!sharedMailbox) {
81
- return {
82
- content: [
83
- {
84
- type: 'text',
85
- text: "Shared mailbox email address is required (e.g., 'shared@company.com').",
86
- },
87
- ],
88
- };
83
+ return toolError(
84
+ "Shared mailbox email address is required (e.g., 'shared@company.com')."
85
+ );
89
86
  }
90
87
 
91
88
  // Validate up front, through the same helper every other tool uses. The
@@ -96,17 +93,12 @@ async function handleAccessSharedMailbox(args) {
96
93
  try {
97
94
  mailboxPrefix = validateMailboxPrefix(sharedMailbox);
98
95
  } catch (error) {
99
- return { content: [{ type: 'text', text: error.message }] };
96
+ return toolError(error.message);
100
97
  }
101
98
  if (mailboxPrefix === 'me') {
102
- return {
103
- content: [
104
- {
105
- type: 'text',
106
- text: 'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.',
107
- },
108
- ],
109
- };
99
+ return toolError(
100
+ 'access-shared-mailbox reads another mailbox — pass its email address. To read your own mailbox, use `search-emails`.'
101
+ );
110
102
  }
111
103
 
112
104
  // listFolders mode: enumerate the shared mailbox's folder tree so callers
@@ -115,9 +107,7 @@ async function handleAccessSharedMailbox(args) {
115
107
 
116
108
  if (listFolders) {
117
109
  if (!sharedEnabled) {
118
- return {
119
- content: [{ type: 'text', text: SHARED_MAILBOX_DISABLED_MESSAGE }],
120
- };
110
+ return toolError(SHARED_MAILBOX_DISABLED_MESSAGE);
121
111
  }
122
112
  return handleListSharedMailboxFolders(sharedMailbox, args);
123
113
  }
@@ -151,18 +141,12 @@ async function handleAccessSharedMailbox(args) {
151
141
  if (!/not found|ambiguous/i.test(resolveError.message)) {
152
142
  throw resolveError;
153
143
  }
154
- return {
155
- content: [
156
- {
157
- type: 'text',
158
- text:
159
- `${resolveError.message}\n\n` +
160
- `Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
161
- '- `access-shared-mailbox` with `listFolders: true`, or\n' +
162
- `- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``,
163
- },
164
- ],
165
- };
144
+ return toolError(
145
+ `${resolveError.message}\n\n` +
146
+ `Searched in ${sharedMailbox}. List its folders first to get exact names/IDs:\n` +
147
+ '- `access-shared-mailbox` with `listFolders: true`, or\n' +
148
+ `- \`folders\` tool with \`action: list\`, \`sharedMailbox: "${sharedMailbox}"\``
149
+ );
166
150
  }
167
151
  }
168
152
 
@@ -244,49 +228,25 @@ async function handleAccessSharedMailbox(args) {
244
228
  };
245
229
  } catch (error) {
246
230
  if (error.message === 'Authentication required') {
247
- return {
248
- content: [
249
- {
250
- type: 'text',
251
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
252
- },
253
- ],
254
- };
231
+ return authRequiredError();
255
232
  }
256
233
 
257
234
  if (
258
235
  error.message.includes('Access is denied') ||
259
236
  error.message.includes('403')
260
237
  ) {
261
- return {
262
- content: [
263
- {
264
- type: 'text',
265
- text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`,
266
- },
267
- ],
268
- };
238
+ return toolError(
239
+ `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect${sharedEnabled ? '' : ENABLE_SHARED_HINT}`
240
+ );
269
241
  }
270
242
 
271
243
  if (error.message.includes('not found') || error.message.includes('404')) {
272
- return {
273
- content: [
274
- {
275
- type: 'text',
276
- text: `Shared mailbox "${sharedMailbox}" not found. Please verify the email address.`,
277
- },
278
- ],
279
- };
244
+ return toolError(
245
+ `Shared mailbox "${sharedMailbox}" not found, or you can't access it. Please verify the email address.${sharedEnabled ? '' : ENABLE_SHARED_HINT}`
246
+ );
280
247
  }
281
248
 
282
- return {
283
- content: [
284
- {
285
- type: 'text',
286
- text: `Error accessing shared mailbox: ${error.message}`,
287
- },
288
- ],
289
- };
249
+ return toolError(`Error accessing shared mailbox: ${error.message}`);
290
250
  }
291
251
  }
292
252
 
@@ -379,38 +339,19 @@ async function handleListSharedMailboxFolders(sharedMailbox, args) {
379
339
  };
380
340
  } catch (error) {
381
341
  if (error.message === 'Authentication required') {
382
- return {
383
- content: [
384
- {
385
- type: 'text',
386
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
387
- },
388
- ],
389
- };
342
+ return authRequiredError();
390
343
  }
391
344
 
392
345
  if (
393
346
  error.message.includes('Access is denied') ||
394
347
  error.message.includes('403')
395
348
  ) {
396
- return {
397
- content: [
398
- {
399
- type: 'text',
400
- text: `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`,
401
- },
402
- ],
403
- };
349
+ return toolError(
350
+ `Access denied to shared mailbox "${sharedMailbox}".\n\n**Possible causes:**\n- You don't have delegate access to this shared mailbox\n- The Mail.Read.Shared permission is not granted\n- The shared mailbox address is incorrect`
351
+ );
404
352
  }
405
353
 
406
- return {
407
- content: [
408
- {
409
- type: 'text',
410
- text: `Error listing shared mailbox folders: ${error.message}`,
411
- },
412
- ],
413
- };
354
+ return toolError(`Error listing shared mailbox folders: ${error.message}`);
414
355
  }
415
356
  }
416
357
 
@@ -474,14 +415,7 @@ async function handleSetMessageFlag(args) {
474
415
  const ids = messageIds || (messageId ? [messageId] : []);
475
416
 
476
417
  if (ids.length === 0) {
477
- return {
478
- content: [
479
- {
480
- type: 'text',
481
- text: 'Message ID (messageId) or IDs (messageIds) required.',
482
- },
483
- ],
484
- };
418
+ return toolError('Message ID (messageId) or IDs (messageIds) required.');
485
419
  }
486
420
 
487
421
  // Build flag object. Zoned values (Z/offset) are sent as the same instant in
@@ -568,23 +502,9 @@ async function handleSetMessageFlag(args) {
568
502
  };
569
503
  } catch (error) {
570
504
  if (error.message === 'Authentication required') {
571
- return {
572
- content: [
573
- {
574
- type: 'text',
575
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
576
- },
577
- ],
578
- };
505
+ return authRequiredError();
579
506
  }
580
- return {
581
- content: [
582
- {
583
- type: 'text',
584
- text: `Error setting message flag: ${error.message}`,
585
- },
586
- ],
587
- };
507
+ return toolError(`Error setting message flag: ${error.message}`);
588
508
  }
589
509
  }
590
510
 
@@ -599,14 +519,7 @@ async function handleClearMessageFlag(args) {
599
519
  const ids = messageIds || (messageId ? [messageId] : []);
600
520
 
601
521
  if (ids.length === 0) {
602
- return {
603
- content: [
604
- {
605
- type: 'text',
606
- text: 'Message ID (messageId) or IDs (messageIds) required.',
607
- },
608
- ],
609
- };
522
+ return toolError('Message ID (messageId) or IDs (messageIds) required.');
610
523
  }
611
524
 
612
525
  try {
@@ -671,23 +584,9 @@ async function handleClearMessageFlag(args) {
671
584
  };
672
585
  } catch (error) {
673
586
  if (error.message === 'Authentication required') {
674
- return {
675
- content: [
676
- {
677
- type: 'text',
678
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
679
- },
680
- ],
681
- };
587
+ return authRequiredError();
682
588
  }
683
- return {
684
- content: [
685
- {
686
- type: 'text',
687
- text: `Error clearing message flag: ${error.message}`,
688
- },
689
- ],
690
- };
589
+ return toolError(`Error clearing message flag: ${error.message}`);
691
590
  }
692
591
  }
693
592
 
@@ -735,14 +634,9 @@ async function handleFindMeetingRooms(args) {
735
634
  const explanation = isLikelyPersonal
736
635
  ? 'Meeting room search is M365-only. Personal Outlook.com accounts cannot use this feature — there are no rooms to find. Connect a Microsoft 365 work/school account to enable.'
737
636
  : 'This feature requires:\n- Places.Read.All permission\n- Meeting rooms configured in your organization';
738
- return {
739
- content: [
740
- {
741
- type: 'text',
742
- text: `Unable to find meeting rooms.\n\n**Note**: ${explanation}\n\nError: ${errMsg}`,
743
- },
744
- ],
745
- };
637
+ return toolError(
638
+ `Unable to find meeting rooms.\n\n**Note**: ${explanation}\n\nError: ${errMsg}`
639
+ );
746
640
  }
747
641
  }
748
642
 
@@ -843,23 +737,9 @@ async function handleFindMeetingRooms(args) {
843
737
  };
844
738
  } catch (error) {
845
739
  if (error.message === 'Authentication required') {
846
- return {
847
- content: [
848
- {
849
- type: 'text',
850
- text: "Authentication required. Please use the 'auth' tool with action=authenticate first.",
851
- },
852
- ],
853
- };
740
+ return authRequiredError();
854
741
  }
855
- return {
856
- content: [
857
- {
858
- type: 'text',
859
- text: `Error finding meeting rooms: ${error.message}`,
860
- },
861
- ],
862
- };
742
+ return toolError(`Error finding meeting rooms: ${error.message}`);
863
743
  }
864
744
  }
865
745
 
@@ -868,14 +748,8 @@ const advancedTools = [
868
748
  {
869
749
  name: 'access-shared-mailbox',
870
750
  description:
871
- "List emails — or enumerate folders — from a shared mailbox the signed-in user has been granted access to (read-only). Returns paged messages from the named `sharedMailbox` (or alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview — same shape as `search-emails` list mode. `folder` accepts a well-known name (inbox, sent, archive…), a custom/localized folder display name (e.g. `Archiv`), a nested folder path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. Set `listFolders: true` to enumerate the shared mailbox's full folder tree (names, paths, IDs, counts) — use this to discover custom subfolders before reading them. Requires that the shared mailbox has been delegated to the signed-in user in Exchange (admin-configured). Use `outputVerbosity` to control field count and `count` (default 25, max 50) for page size. For full search/filter capability over a shared mailbox, prefer `search-emails` with `sharedMailbox` set. Custom/localized names, nested paths and `listFolders` need the server opt-in setting OUTLOOK_SHARED_MAILBOX (work/school only); without it `folder` must be a well-known name or a folder ID, as before.",
872
- annotations: {
873
- title: 'Shared Mailbox',
874
- readOnlyHint: true,
875
- // openWorldHint: returns shared-mailbox messages authored by external
876
- // senders. (#92)
877
- openWorldHint: true,
878
- },
751
+ "List emails or folders in a shared mailbox the signed-in user can access (read-only). Returns messages from `sharedMailbox` (alias `email`) and `folder` (default `inbox`) with id/subject/from/receivedDateTime/preview, the same shape as `search-emails` list mode. `folder` takes a well-known name (inbox, sent, archive…), a custom/localized display name (e.g. `Archiv`), a nested path (e.g. `Inbox/Vendors/Acme`), or pass a raw `folderId`. `listFolders: true` enumerates the shared mailbox's folder tree (names, paths, IDs, counts), to find custom subfolders before reading them. Needs the mailbox delegated to the signed-in user in Exchange (admin-configured). `outputVerbosity` sets field count and `count` (default 25, max 50) page size. For search and filters over a shared mailbox, use `search-emails` with `sharedMailbox` set. Custom/localized names, nested paths and `listFolders` need the server opt-in setting OUTLOOK_SHARED_MAILBOX (work/school only); without it `folder` must be a well-known name or a folder ID.",
752
+ ...toolMetadata('access-shared-mailbox', 'Shared Mailbox'),
879
753
  inputSchema: {
880
754
  type: 'object',
881
755
  properties: {
@@ -901,7 +775,7 @@ const advancedTools = [
901
775
  listFolders: {
902
776
  type: 'boolean',
903
777
  description:
904
- "Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) instead of reading messages.",
778
+ "Enumerate the shared mailbox's full folder tree (names, paths, IDs, item counts) in place of reading messages.",
905
779
  },
906
780
  count: {
907
781
  type: 'number',
@@ -922,11 +796,7 @@ const advancedTools = [
922
796
  name: 'find-meeting-rooms',
923
797
  description:
924
798
  "Discover bookable meeting rooms in the user's organisation via the Graph rooms endpoint (read-only). Returns room resources with displayName, emailAddress, building, floor, capacity, and bookingType — suitable for piping into `create-event` as attendees. Filter by `query` (matches name/email), `building`, `floor`, or minimum `capacity`. Returns empty list on personal accounts (the rooms endpoint is M365-only). Use `outputVerbosity` to control field count.",
925
- annotations: {
926
- title: 'Meeting Rooms',
927
- readOnlyHint: true,
928
- openWorldHint: false,
929
- },
799
+ ...toolMetadata('find-meeting-rooms', 'Meeting Rooms'),
930
800
  inputSchema: {
931
801
  type: 'object',
932
802
  properties: {
@@ -86,4 +86,26 @@ function describeAuthError(input) {
86
86
  );
87
87
  }
88
88
 
89
- module.exports = { getAuthErrorHints, describeAuthError, AUTH_ERROR_HINTS };
89
+ /**
90
+ * Short, PII-free label for the default log level (#278): `label` plus the
91
+ * first AADSTS code (or a network error code) found, never the description,
92
+ * which can carry the user's address.
93
+ * @param {string} label - e.g. 'refresh-failed'
94
+ * @param {Error|string|null|undefined} input
95
+ * @returns {string} - e.g. 'refresh-failed:AADSTS70008'
96
+ */
97
+ function authErrorLogLabel(label, input) {
98
+ const aadsts = toMessage(input).match(/AADSTS\d+/);
99
+ if (aadsts) return `${label}:${aadsts[0]}`;
100
+ const code = input && input.code;
101
+ return typeof code === 'string' && /^[A-Z][A-Z_]{1,30}$/.test(code)
102
+ ? `${label}:${code}`
103
+ : label;
104
+ }
105
+
106
+ module.exports = {
107
+ getAuthErrorHints,
108
+ describeAuthError,
109
+ authErrorLogLabel,
110
+ AUTH_ERROR_HINTS,
111
+ };