@littlebearapps/outlook-assistant 3.11.2 → 3.12.0

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/folder/move.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const { callGraphAPI } = require('../utils/graph-api');
5
5
  const { ensureAuthenticated } = require('../auth');
6
6
  const { resolveFolder } = require('./resolve');
7
+ const { buildMailboxPrefix } = require('../utils/mailbox');
7
8
 
8
9
  /**
9
10
  * Move emails handler
@@ -15,6 +16,7 @@ async function handleMoveEmails(args) {
15
16
  const targetFolder = args.targetFolder || '';
16
17
  const targetFolderId = args.targetFolderId || '';
17
18
  const sourceFolder = args.sourceFolder || '';
19
+ const sharedMailbox = args.sharedMailbox || args.email || null;
18
20
 
19
21
  if (!emailIds) {
20
22
  return {
@@ -63,7 +65,7 @@ async function handleMoveEmails(args) {
63
65
  const result = await moveEmailsToFolder(
64
66
  accessToken,
65
67
  ids,
66
- { name: targetFolder, id: targetFolderId },
68
+ { name: targetFolder, id: targetFolderId, mailbox: sharedMailbox },
67
69
  sourceFolder
68
70
  );
69
71
 
@@ -74,6 +76,12 @@ async function handleMoveEmails(args) {
74
76
  text: result.message,
75
77
  },
76
78
  ],
79
+ _meta: {
80
+ // Graph assigns a NEW message ID on move (unless immutable IDs are
81
+ // enabled) — surface the mapping so callers can keep addressing them.
82
+ moved: result.results?.successful || [],
83
+ failed: result.results?.failed || [],
84
+ },
77
85
  };
78
86
  } catch (error) {
79
87
  if (error.message === 'Authentication required') {
@@ -102,7 +110,7 @@ async function handleMoveEmails(args) {
102
110
  * Move emails to a folder
103
111
  * @param {string} accessToken - Access token
104
112
  * @param {Array<string>} emailIds - Array of email IDs to move
105
- * @param {{name?: string, id?: string}} targetSpec - Target folder name/path or ID
113
+ * @param {{name?: string, id?: string, mailbox?: string|null}} targetSpec - Target folder name/path or ID, plus optional shared mailbox
106
114
  * @param {string} sourceFolderName - Name of the source folder (optional)
107
115
  * @returns {Promise<object>} - Result object with status and message
108
116
  */
@@ -112,6 +120,7 @@ async function moveEmailsToFolder(
112
120
  targetSpec,
113
121
  _sourceFolderName
114
122
  ) {
123
+ const prefix = buildMailboxPrefix(targetSpec.mailbox);
115
124
  try {
116
125
  // Resolve the target folder (supports "Parent/Child" paths, aliases, and
117
126
  // explicit IDs — nested folders are now addressable). (#216)
@@ -133,12 +142,20 @@ async function moveEmailsToFolder(
133
142
  // Process each email one by one to handle errors independently
134
143
  for (const emailId of emailIds) {
135
144
  try {
136
- // Move the email
137
- await callGraphAPI(accessToken, 'POST', `me/messages/${emailId}/move`, {
138
- destinationId: targetFolderId,
145
+ // Move the email. The response carries the moved message, whose id
146
+ // changes unless immutable IDs are enabled — keep it, the old id is
147
+ // dead afterwards.
148
+ const moved = await callGraphAPI(
149
+ accessToken,
150
+ 'POST',
151
+ `${prefix}/messages/${emailId}/move`,
152
+ { destinationId: targetFolderId }
153
+ );
154
+
155
+ results.successful.push({
156
+ oldId: emailId,
157
+ newId: moved?.id || emailId,
139
158
  });
140
-
141
- results.successful.push(emailId);
142
159
  } catch (error) {
143
160
  console.error(`Error moving email ${emailId}: ${error.message}`);
144
161
  results.failed.push({
@@ -153,6 +170,14 @@ async function moveEmailsToFolder(
153
170
 
154
171
  if (results.successful.length > 0) {
155
172
  message += `Successfully moved ${results.successful.length} email(s) to "${targetLabel}".`;
173
+ // Small batches: show the id mapping inline so the caller can address
174
+ // the moved messages without a re-search.
175
+ if (results.successful.length <= 5) {
176
+ message += '\n\nNew message IDs (old -> new):';
177
+ for (const { oldId, newId } of results.successful) {
178
+ message += `\n- ${oldId} -> ${newId}`;
179
+ }
180
+ }
156
181
  }
157
182
 
158
183
  if (results.failed.length > 0) {
package/folder/resolve.js CHANGED
@@ -16,8 +16,14 @@
16
16
  *
17
17
  * `/` is the path separator, so a folder whose display name literally contains
18
18
  * `/` cannot be addressed by path — use its folderId (documented on the tool).
19
+ *
20
+ * Every function takes an optional `mailbox` (a shared/delegated mailbox email
21
+ * address). It only changes the Graph path prefix — `me` vs `users/{mailbox}` —
22
+ * so the same resolution logic reaches custom subfolders and localized folder
23
+ * names in a shared mailbox exactly as it does in the signed-in account.
19
24
  */
20
25
  const { callGraphAPI } = require('../utils/graph-api');
26
+ const { buildMailboxPrefix } = require('../utils/mailbox');
21
27
 
22
28
  // Alias → Graph well-known folder name (usable directly as a path segment).
23
29
  const WELL_KNOWN = {
@@ -73,11 +79,17 @@ function ambiguousError(spec, candidates) {
73
79
  * @odata.nextLink so folders with many children resolve completely.
74
80
  * @returns {Promise<Array<{id, displayName, parentFolderId, childFolderCount}>>}
75
81
  */
76
- async function listChildFolders(accessToken, parentId, select = FOLDER_SELECT) {
82
+ async function listChildFolders(
83
+ accessToken,
84
+ parentId,
85
+ select = FOLDER_SELECT,
86
+ mailbox = null
87
+ ) {
88
+ const prefix = buildMailboxPrefix(mailbox);
77
89
  const all = [];
78
90
  let path = parentId
79
- ? `me/mailFolders/${parentId}/childFolders`
80
- : 'me/mailFolders';
91
+ ? `${prefix}/mailFolders/${parentId}/childFolders`
92
+ : `${prefix}/mailFolders`;
81
93
  let params = { $top: 100, $select: select };
82
94
  // Follow pagination; nextLink already encodes params. Guard against a
83
95
  // repeated/malformed nextLink so a bad server response can't loop forever.
@@ -108,22 +120,22 @@ function toRecord(folder, path) {
108
120
  };
109
121
  }
110
122
 
111
- async function resolveWellKnown(accessToken, alias) {
123
+ async function resolveWellKnown(accessToken, alias, mailbox) {
112
124
  const resp = await callGraphAPI(
113
125
  accessToken,
114
126
  'GET',
115
- `me/mailFolders/${WELL_KNOWN[alias]}`,
127
+ `${buildMailboxPrefix(mailbox)}/mailFolders/${WELL_KNOWN[alias]}`,
116
128
  null,
117
129
  { $select: FOLDER_SELECT }
118
130
  );
119
131
  return toRecord(resp, resp.displayName);
120
132
  }
121
133
 
122
- async function resolveById(accessToken, id) {
134
+ async function resolveById(accessToken, id, mailbox) {
123
135
  const resp = await callGraphAPI(
124
136
  accessToken,
125
137
  'GET',
126
- `me/mailFolders/${id}`,
138
+ `${buildMailboxPrefix(mailbox)}/mailFolders/${id}`,
127
139
  null,
128
140
  { $select: FOLDER_SELECT }
129
141
  );
@@ -134,8 +146,9 @@ async function resolveById(accessToken, id) {
134
146
  * Build a flat list of every folder with its full path, breadth-first.
135
147
  * Accepts an already-fetched top-level list to avoid re-fetching.
136
148
  */
137
- async function buildTree(accessToken, topLevel) {
138
- const top = topLevel || (await listChildFolders(accessToken, null));
149
+ async function buildTree(accessToken, topLevel, mailbox = null) {
150
+ const top =
151
+ topLevel || (await listChildFolders(accessToken, null, undefined, mailbox));
139
152
  const out = [];
140
153
  const visited = new Set();
141
154
  const queue = top.map((f) => ({ folder: f, path: f.displayName, depth: 1 }));
@@ -164,7 +177,12 @@ async function buildTree(accessToken, topLevel) {
164
177
  'use an explicit folder path or folderId.'
165
178
  );
166
179
  }
167
- const children = await listChildFolders(accessToken, folder.id);
180
+ const children = await listChildFolders(
181
+ accessToken,
182
+ folder.id,
183
+ undefined,
184
+ mailbox
185
+ );
168
186
  for (const child of children) {
169
187
  queue.push({
170
188
  folder: child,
@@ -186,8 +204,8 @@ function matchName(folders, name) {
186
204
  * Resolve a bare display name: a unique top-level match wins (fast path,
187
205
  * back-compat); otherwise search the whole tree, reporting ambiguity.
188
206
  */
189
- async function resolveByName(accessToken, name) {
190
- const top = await listChildFolders(accessToken, null);
207
+ async function resolveByName(accessToken, name, mailbox) {
208
+ const top = await listChildFolders(accessToken, null, undefined, mailbox);
191
209
  const topMatches = matchName(top, name);
192
210
  if (topMatches.length === 1) {
193
211
  return toRecord(topMatches[0], topMatches[0].displayName);
@@ -199,7 +217,7 @@ async function resolveByName(accessToken, name) {
199
217
  );
200
218
  }
201
219
  // Not top-level — search nested folders.
202
- const tree = await buildTree(accessToken, top);
220
+ const tree = await buildTree(accessToken, top, mailbox);
203
221
  const matches = matchName(tree, name);
204
222
  if (matches.length === 0) {
205
223
  throw notFoundError(name);
@@ -213,13 +231,13 @@ async function resolveByName(accessToken, name) {
213
231
  /**
214
232
  * Resolve a path (segments already split/trimmed) by traversing childFolders.
215
233
  */
216
- async function resolvePath(accessToken, segments) {
234
+ async function resolvePath(accessToken, segments, mailbox) {
217
235
  let current;
218
236
  const first = segments[0];
219
237
  if (WELL_KNOWN[first.toLowerCase()]) {
220
- current = await resolveWellKnown(accessToken, first.toLowerCase());
238
+ current = await resolveWellKnown(accessToken, first.toLowerCase(), mailbox);
221
239
  } else {
222
- const top = await listChildFolders(accessToken, null);
240
+ const top = await listChildFolders(accessToken, null, undefined, mailbox);
223
241
  const matches = matchName(top, first);
224
242
  if (matches.length === 0) {
225
243
  throw notFoundError(first);
@@ -235,7 +253,12 @@ async function resolvePath(accessToken, segments) {
235
253
 
236
254
  for (let i = 1; i < segments.length; i++) {
237
255
  const seg = segments[i];
238
- const children = await listChildFolders(accessToken, current.id);
256
+ const children = await listChildFolders(
257
+ accessToken,
258
+ current.id,
259
+ undefined,
260
+ mailbox
261
+ );
239
262
  const matches = matchName(children, seg);
240
263
  if (matches.length === 0) {
241
264
  throw notFoundError(`${current.path}/${seg}`);
@@ -259,19 +282,34 @@ async function resolvePath(accessToken, segments) {
259
282
  return current;
260
283
  }
261
284
 
285
+ /**
286
+ * Does a `folder` value look like a raw Graph folder ID rather than a display
287
+ * name or path? Graph IDs are long base64url-style tokens (`AAMkAG…`,
288
+ * `AQMkAD…`) with no spaces or `/`; display names that long without a space
289
+ * are vanishingly rare. Used where `folder` historically accepted raw IDs.
290
+ * @param {string} value
291
+ * @returns {boolean}
292
+ */
293
+ function looksLikeFolderId(value) {
294
+ return (
295
+ typeof value === 'string' && /^[A-Za-z0-9_+=-]{60,}$/.test(value.trim())
296
+ );
297
+ }
298
+
262
299
  /**
263
300
  * Resolve a folder from a name/path and/or explicit ID.
264
301
  * @param {string} accessToken
265
- * @param {{name?: string, id?: string}} spec
302
+ * @param {{name?: string, id?: string, mailbox?: string|null}} spec
266
303
  * @returns {Promise<{id: string, displayName: string, parentId: string|null, path: string}>}
267
304
  * `path` is the full slash-separated path when resolved by name/path/alias;
268
305
  * when resolved by ID it is the folder's display name only (ancestors are not
269
306
  * fetched).
270
307
  */
271
308
  async function resolveFolder(accessToken, spec = {}) {
309
+ const mailbox = spec.mailbox || null;
272
310
  const id = (spec.id || '').trim();
273
311
  if (id) {
274
- return resolveById(accessToken, id);
312
+ return resolveById(accessToken, id, mailbox);
275
313
  }
276
314
  const name = (spec.name || '').trim();
277
315
  if (!name) {
@@ -297,18 +335,19 @@ async function resolveFolder(accessToken, spec = {}) {
297
335
  );
298
336
  }
299
337
  if (segments.length === 1) {
300
- return resolveFolder(accessToken, { name: segments[0] });
338
+ return resolveFolder(accessToken, { name: segments[0], mailbox });
301
339
  }
302
- return resolvePath(accessToken, segments);
340
+ return resolvePath(accessToken, segments, mailbox);
303
341
  }
304
342
  if (WELL_KNOWN[name.toLowerCase()]) {
305
- return resolveWellKnown(accessToken, name.toLowerCase());
343
+ return resolveWellKnown(accessToken, name.toLowerCase(), mailbox);
306
344
  }
307
- return resolveByName(accessToken, name);
345
+ return resolveByName(accessToken, name, mailbox);
308
346
  }
309
347
 
310
348
  module.exports = {
311
349
  WELL_KNOWN,
350
+ looksLikeFolderId,
312
351
  resolveFolder,
313
352
  listChildFolders,
314
353
  buildTree,
package/folder/stats.js CHANGED
@@ -7,6 +7,7 @@
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
9
  const { resolveFolder } = require('./resolve');
10
+ const { buildMailboxPrefix } = require('../utils/mailbox');
10
11
  const config = require('../config');
11
12
 
12
13
  const { VERBOSITY, DEFAULT_LIMITS } = config;
@@ -20,6 +21,8 @@ async function handleGetFolderStats(args) {
20
21
  const folderName = args.folder || 'inbox';
21
22
  const folderIdArg = args.folderId || '';
22
23
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
24
+ const sharedMailbox = args.sharedMailbox || args.email || null;
25
+ const prefix = buildMailboxPrefix(sharedMailbox);
23
26
 
24
27
  try {
25
28
  const accessToken = await ensureAuthenticated();
@@ -30,6 +33,7 @@ async function handleGetFolderStats(args) {
30
33
  resolved = await resolveFolder(accessToken, {
31
34
  name: folderName,
32
35
  id: folderIdArg,
36
+ mailbox: sharedMailbox,
33
37
  });
34
38
  } catch (resolveError) {
35
39
  return {
@@ -42,7 +46,7 @@ async function handleGetFolderStats(args) {
42
46
  const folder = await callGraphAPI(
43
47
  accessToken,
44
48
  'GET',
45
- `me/mailFolders/${folderId}`,
49
+ `${prefix}/mailFolders/${folderId}`,
46
50
  null,
47
51
  {
48
52
  // Note: sizeInBytes is NOT available on mailFolder resource type
@@ -54,7 +58,7 @@ async function handleGetFolderStats(args) {
54
58
  // Get recent email dates for context
55
59
  let dateRange = null;
56
60
  if (verbosity !== VERBOSITY.MINIMAL && folder.totalItemCount > 0) {
57
- dateRange = await getEmailDateRange(accessToken, folderId);
61
+ dateRange = await getEmailDateRange(accessToken, folderId, sharedMailbox);
58
62
  }
59
63
 
60
64
  // Format response based on verbosity
@@ -96,15 +100,17 @@ async function handleGetFolderStats(args) {
96
100
  * Get date range of emails in folder
97
101
  * @param {string} accessToken - Access token
98
102
  * @param {string} folderId - Folder ID
103
+ * @param {string|null} [mailbox] - Shared mailbox email, or null for the signed-in user
99
104
  * @returns {Promise<object|null>} - { oldest, newest } dates or null
100
105
  */
101
- async function getEmailDateRange(accessToken, folderId) {
106
+ async function getEmailDateRange(accessToken, folderId, mailbox = null) {
107
+ const prefix = buildMailboxPrefix(mailbox);
102
108
  try {
103
109
  // Get newest email
104
110
  const newestResponse = await callGraphAPI(
105
111
  accessToken,
106
112
  'GET',
107
- `me/mailFolders/${folderId}/messages`,
113
+ `${prefix}/mailFolders/${folderId}/messages`,
108
114
  null,
109
115
  {
110
116
  $select: 'receivedDateTime',
@@ -117,7 +123,7 @@ async function getEmailDateRange(accessToken, folderId) {
117
123
  const oldestResponse = await callGraphAPI(
118
124
  accessToken,
119
125
  'GET',
120
- `me/mailFolders/${folderId}/messages`,
126
+ `${prefix}/mailFolders/${folderId}/messages`,
121
127
  null,
122
128
  {
123
129
  $select: 'receivedDateTime',
package/llms-install.md CHANGED
@@ -21,7 +21,7 @@ Add to your MCP client configuration:
21
21
 
22
22
  ## Prerequisites
23
23
 
24
- 1. **Node.js 18+** must be installed
24
+ 1. **Node.js 18.18 or newer** must be installed
25
25
  2. **Azure app registration** is required for authentication (free tier works)
26
26
 
27
27
  ## Getting the Client ID and Secret
@@ -46,16 +46,32 @@ Users must create an Azure app registration to get credentials:
46
46
  2. Add: `offline_access`, `User.Read`, `Mail.Read`, `Mail.ReadWrite`, `Mail.Send`, `Calendars.Read`, `Calendars.ReadWrite`, `Contacts.Read`, `Contacts.ReadWrite`, `People.Read`, `MailboxSettings.ReadWrite`
47
47
  3. Click "Add permissions"
48
48
 
49
+ ### Enable device code sign-in (the default auth method):
50
+ 1. Go to "Authentication" → "Add a platform" → "Mobile and desktop applications"
51
+ 2. Check `https://login.microsoftonline.com/common/oauth2/nativeclient` and click "Configure"
52
+ 3. Under "Advanced settings", set "Allow public client flows" to **Yes** and save
53
+
49
54
  ## First-Time Authentication
50
55
 
51
- After configuring the MCP server:
56
+ After configuring the MCP server (device code flow, no auth server needed):
57
+
58
+ 1. Use the `auth` tool with `action=authenticate` — it returns a short code and the URL `https://microsoft.com/devicelogin`
59
+ 2. Open the URL on any device (a private/incognito window avoids cached sessions), enter the code, sign in and grant permissions
60
+ 3. Use the `auth` tool with `action=device-code-complete` — tokens are saved to `~/.outlook-assistant-tokens.json` and refresh automatically
61
+
62
+ **Browser redirect flow (alternative)**: start the auth server with `npm run auth-server` from a source checkout, or `node "$(npm root -g)/@littlebearapps/outlook-assistant/outlook-auth-server.js"` from a global install, then call `auth` with `action=authenticate` and `method=browser`. The auth server reads `OUTLOOK_CLIENT_ID` and `OUTLOOK_CLIENT_SECRET` from the environment or a `.env` file in the directory you start it from.
63
+
64
+ ## Optional Settings
52
65
 
53
- 1. Start the auth server: `npx @littlebearapps/outlook-assistant-auth` (or run `npm run auth-server` from source)
54
- 2. Use the `auth` tool with `action=authenticate` to get an OAuth URL
55
- 3. Open the URL in a browser, sign in with your Microsoft account
56
- 4. Grant permissions — tokens are saved to `~/.outlook-assistant-tokens.json` and refresh automatically
66
+ Add these to the same `env` block if needed:
57
67
 
58
- **Note**: The auth server needs `OUTLOOK_CLIENT_ID` and `OUTLOOK_CLIENT_SECRET` as environment variables. If running it separately from the MCP server, export them in your shell or create a `.env` file.
68
+ | Variable | Purpose |
69
+ |----------|---------|
70
+ | `OUTLOOK_AUTH_AUDIENCE` | `consumers` for Azure apps registered as personal-accounts-only (fixes `AADSTS9002331`); `organizations` or a tenant GUID for work-only apps. Default `common` |
71
+ | `OUTLOOK_DEFAULT_TIMEZONE` | IANA timezone for calendar times (default `Australia/Melbourne`) |
72
+ | `OUTLOOK_MAX_EMAILS_PER_SESSION` | Cap on sends per server session |
73
+ | `OUTLOOK_ALLOWED_RECIPIENTS` | Comma-separated allowlist of recipient domains/addresses |
74
+ | `OUTLOOK_SHARED_MAILBOX` | Opt-in shared-mailbox support, work/school accounts only: `read` or `true` (read and organise). Also add `Mail.Read.Shared` (and `Mail.ReadWrite.Shared` for `true`) in Azure, restart, then run `auth` with `action=authenticate` and `force=true` |
59
75
 
60
76
  ## Configuration Files by Client
61
77
 
@@ -76,7 +92,8 @@ File: `~/.codeium/windsurf/mcp_config.json`
76
92
  ## Verify Installation
77
93
 
78
94
  After authentication, test with:
79
- - `auth` tool with `action=status` — should show "authenticated"
95
+ - `auth` tool with `action=status` — should report "Authenticated and ready"
96
+ - `auth` tool with `action=about` — shows the connected mailbox, version and granted scopes
80
97
  - `search-emails` with no parameters — should list recent inbox emails
81
98
 
82
99
  ## Troubleshooting
@@ -84,7 +101,9 @@ After authentication, test with:
84
101
  | Problem | Solution |
85
102
  |---------|----------|
86
103
  | "Invalid client secret" (AADSTS7000215) | Use the secret **Value**, not the Secret ID. Also check it hasn't expired. v3.11.0+ appends an explanation to Microsoft's raw error |
87
- | Auth URL doesn't work | Start the auth server first |
104
+ | Auth URL doesn't work | Browser flow only: start the auth server first. With the default device code flow, use `microsoft.com/devicelogin` |
105
+ | Device code "invalid_client" | Enable "Allow public client flows" in Azure → Authentication → Advanced settings |
106
+ | "Shared-mailbox support is turned off" | Set `OUTLOOK_SHARED_MAILBOX`, restart, and re-authenticate with `force=true` (work/school accounts only) |
88
107
  | "EADDRINUSE :3333" | Run `npx kill-port 3333` then restart auth server |
89
108
  | Empty API responses | Run `auth` tool with `action=status` to check token |
90
109
  | Search returns no results (personal account) | Use `from`, `subject`, `to` filters instead of `query` |
package/llms.txt CHANGED
@@ -9,7 +9,7 @@ Built by [Little Bear Apps](https://littlebearapps.com).
9
9
  - **Package**: `@littlebearapps/outlook-assistant` on npm
10
10
  - **Install**: `npm install -g @littlebearapps/outlook-assistant` or `npx @littlebearapps/outlook-assistant`
11
11
  - **License**: MIT
12
- - **Node.js**: >= 18.0.0
12
+ - **Node.js**: >= 18.18.0 (development tooling: >= 22.22.1)
13
13
  - **Authentication**: OAuth 2.0 with Microsoft Graph API (requires Azure app registration)
14
14
  - **Tools**: 22 tools across 9 modules (reduced from 55 for optimal AI performance)
15
15
 
@@ -17,8 +17,9 @@ Built by [Little Bear Apps](https://littlebearapps.com).
17
17
 
18
18
  - Read, search, send, and export emails directly from Claude instead of switching apps
19
19
  - 8 email tools covering search, conversations, attachments, bulk export, pre-send mail tips, and draft management
20
- - Manage calendar events, contacts, rules, categories, and mailbox settings in one place
21
- - Export to multiple formats: MIME/EML, MBOX, Markdown, JSON, HTML
20
+ - Manage calendar events (upcoming by default, or past and named events via `startAfter`/`startBefore`/`subject`), contacts, rules, categories, and mailbox settings in one place
21
+ - Export to multiple formats: MIME/EML, MBOX, Markdown, JSON, HTML, CSV
22
+ - Opt-in shared-mailbox read and organise support (`sharedMailbox`, work/school accounts, `OUTLOOK_SHARED_MAILBOX`); sending from a shared mailbox is not supported
22
23
 
23
24
  ## Key Differentiators
24
25
 
@@ -35,6 +36,8 @@ Built by [Little Bear Apps](https://littlebearapps.com).
35
36
  - **MCP safety annotations** on all 22 tools — AI clients auto-approve reads and prompt for destructive operations
36
37
  - **Send-email protections**: pre-send mail tips, dry-run preview, session rate limiting, recipient allowlist
37
38
  - **Rule protections**: dry-run preview on create/update, rate limiting, recipient allowlist on forward/redirect, no permanent-delete action
39
+ - **Input and file hardening**: IDs with `.`/`..` path segments refused, the access token only ever sent to `graph.microsoft.com`, attachment downloads and exports confined to the output directory without overwriting
40
+ - **Shared mailboxes off by default**: `.Shared` scopes are requested only when `OUTLOOK_SHARED_MAILBOX` is set (`read` keeps shared access read-only)
38
41
  - **Token-optimised**: 22 tools instead of 55 saves ~11,000 tokens per turn (~64% reduction), improving AI accuracy and context efficiency
39
42
  - These safeguards reduce risk but are not foolproof — always review actions before approving
40
43
 
@@ -59,15 +62,15 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
59
62
 
60
63
  ## Tool Categories
61
64
 
62
- - **Authentication (1 tool)**: `auth` — OAuth flow, status, about
65
+ - **Authentication (1 tool)**: `auth` — status, authenticate (device code by default), device-code-complete, about (version, granted scopes, shared-mailbox status)
63
66
  - **Email (8 tools)**: `search-emails`, `read-email`, `send-email`, `draft`, `update-email`, `attachments`, `export`, `get-mail-tips`
64
- - **Calendar (3 tools)**: `list-events`, `create-event`, `manage-event`
67
+ - **Calendar (3 tools)**: `list-events` (`startAfter`, `startBefore`, `subject` filters), `create-event`, `manage-event` (update, decline, cancel, delete)
65
68
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
66
69
  - **Folders (1 tool)**: `folders` — list, create, move, stats, delete; folders addressable by nested path (`Parent/Child`) or ID
67
70
  - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
68
71
  - **Categories (3 tools)**: `manage-category`, `apply-category`, `manage-focused-inbox`
69
72
  - **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies, set working hours
70
- - **Advanced (2 tools)**: `access-shared-mailbox`, `find-meeting-rooms`
73
+ - **Advanced (2 tools)**: `access-shared-mailbox` (messages, `listFolders`, `folderId`, nested folder paths), `find-meeting-rooms`
71
74
 
72
75
  ## Documentation
73
76
 
@@ -76,9 +79,10 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
76
79
  - [Connect Outlook to Claude](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/connect-outlook-to-claude.md): Step-by-step setup guide for Claude Desktop / Claude Code
77
80
  - [Verify Your Connection](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/how-to/getting-started/verify-your-connection.md): Test and troubleshoot the connection after installation
78
81
  - [Azure Setup](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/guides/azure-setup.md): Azure app registration and API permissions walkthrough
82
+ - [Troubleshooting](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/troubleshooting.md): Known errors and fixes — auth, search, export, shared mailboxes
79
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/>)
80
84
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
81
85
  - [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
82
- - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: 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), HTML-to-text entity double-decoding fixed, `npm audit` at 0. Preceded by v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
83
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.2 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
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))
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)
84
88
  - [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, 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.11.2",
3
+ "version": "3.12.0",
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",
@@ -84,12 +84,12 @@
84
84
  "@commitlint/cli": "^20.4.3",
85
85
  "@commitlint/config-conventional": "^20.4.3",
86
86
  "@eslint/js": "^10.0.1",
87
- "@modelcontextprotocol/inspector": "^0.21.1",
87
+ "@modelcontextprotocol/inspector": "^2.8.0",
88
88
  "eslint": "^10.0.2",
89
89
  "globals": "^17.4.0",
90
90
  "husky": "^9.1.7",
91
91
  "jest": "^30.2.0",
92
- "lint-staged": "^16.3.2",
92
+ "lint-staged": "^17.6.0",
93
93
  "prettier": "^3.8.1",
94
94
  "supertest": "^7.2.2"
95
95
  },
@@ -37,6 +37,55 @@ function assertGraphUrl(url) {
37
37
  }
38
38
  }
39
39
 
40
+ /**
41
+ * Fully percent-decode a value (bounded), so `%2e`, `%252e` etc. are seen as
42
+ * the characters they eventually stand for. Malformed escapes stop decoding.
43
+ * @param {string} value
44
+ * @returns {string}
45
+ */
46
+ function decodeFully(value) {
47
+ let current = value;
48
+ for (let i = 0; i < 5; i++) {
49
+ let next;
50
+ try {
51
+ next = decodeURIComponent(current);
52
+ } catch {
53
+ return current;
54
+ }
55
+ if (next === current) return current;
56
+ current = next;
57
+ }
58
+ return current;
59
+ }
60
+
61
+ /**
62
+ * Is this path segment a dot segment (`.` or `..`) in any encoding?
63
+ * @param {string} segment
64
+ * @returns {boolean}
65
+ */
66
+ function isDotSegment(segment) {
67
+ const decoded = decodeFully(String(segment)).trim();
68
+ return decoded === '.' || decoded === '..';
69
+ }
70
+
71
+ /**
72
+ * Reject relative Graph resource paths containing dot segments. Caller-supplied
73
+ * IDs (message, folder, attachment, delta tokens) are interpolated into these
74
+ * paths, and URL normalisation would otherwise let `..` walk the request to a
75
+ * different Graph resource (another mailbox, another API version) than the
76
+ * tool intended. Legitimate Graph IDs never contain a bare `.`/`..` segment.
77
+ * @param {string} resourcePath - Relative path (query string, if any, ignored)
78
+ * @throws {Error} If any segment is `.` or `..` (literal or percent-encoded)
79
+ */
80
+ function assertSafeResourcePath(resourcePath) {
81
+ const pathOnly = String(resourcePath).split('?')[0];
82
+ if (pathOnly.split('/').some(isDotSegment)) {
83
+ throw new Error(
84
+ 'Invalid resource path: IDs must not contain "." or ".." path segments'
85
+ );
86
+ }
87
+ }
88
+
40
89
  /**
41
90
  * Makes a request to the Microsoft Graph API
42
91
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
@@ -71,7 +120,10 @@ async function callGraphAPI(
71
120
  assertGraphUrl(path);
72
121
  finalUrl = path;
73
122
  } else {
74
- // Build URL from path and queryParams
123
+ // Build URL from path and queryParams. Refuse dot segments before
124
+ // encoding: encodeURIComponent leaves `..` intact, and the URL parser
125
+ // would then resolve it to a different resource.
126
+ assertSafeResourcePath(path);
75
127
  // Encode path segments properly
76
128
  const encodedPath = path
77
129
  .split('/')
@@ -292,6 +344,12 @@ async function callGraphAPIBatch(accessToken, requests) {
292
344
  }));
293
345
  }
294
346
 
347
+ // Batch sub-request URLs are resolved by Graph itself — apply the same
348
+ // dot-segment guard as single requests.
349
+ for (const req of requests) {
350
+ assertSafeResourcePath(req.url);
351
+ }
352
+
295
353
  const batchPayload = {
296
354
  requests: requests.map((req) => ({
297
355
  id: req.id,
@@ -319,11 +377,12 @@ async function callGraphAPIBatch(accessToken, requests) {
319
377
  * In test mode (USE_TEST_MODE=true), returns mock MIME content instead of calling the real API.
320
378
  * @param {string} accessToken - The access token for authentication
321
379
  * @param {string} emailId - The email ID to export
380
+ * @param {string} [mailboxPrefix] - Resource prefix (`me` or `users/{email}`) for shared mailboxes. Defaults to `me`.
322
381
  * @returns {Promise<string>} - Raw MIME content as string
323
382
  * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
324
383
  * @throws {Error} If the HTTP status is outside 2xx or a network error occurs
325
384
  */
326
- async function callGraphAPIRaw(accessToken, emailId) {
385
+ async function callGraphAPIRaw(accessToken, emailId, mailboxPrefix = 'me') {
327
386
  // Test mode: return mock MIME content
328
387
  if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
329
388
  return mockData.getMockMimeContent
@@ -331,8 +390,21 @@ async function callGraphAPIRaw(accessToken, emailId) {
331
390
  : `MIME-Version: 1.0\nContent-Type: text/plain\n\nTest email content for ${emailId}`;
332
391
  }
333
392
 
393
+ // `emailId` is encoded as a single segment, but a bare `.`/`..` id would
394
+ // still be resolved as a dot segment — refuse it (and any in the prefix).
395
+ assertSafeResourcePath(`${mailboxPrefix}/messages`);
396
+ if (isDotSegment(emailId)) {
397
+ throw new Error(
398
+ 'Invalid resource path: IDs must not contain "." or ".." path segments'
399
+ );
400
+ }
401
+
334
402
  return new Promise((resolve, reject) => {
335
- const path = `me/messages/${encodeURIComponent(emailId)}/$value`;
403
+ const encodedPrefix = mailboxPrefix
404
+ .split('/')
405
+ .map((segment) => encodeURIComponent(segment))
406
+ .join('/');
407
+ const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
336
408
  const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`;
337
409
 
338
410
  const options = {
@@ -434,6 +506,7 @@ async function callGraphAPIWithAuth(
434
506
  }
435
507
 
436
508
  module.exports = {
509
+ assertSafeResourcePath,
437
510
  callGraphAPI,
438
511
  callGraphAPIPaginated,
439
512
  callGraphAPIBatch,