@littlebearapps/outlook-assistant 3.8.2 → 3.9.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Shared, path-aware, ambiguity-aware mail-folder resolver. (#216)
3
+ *
4
+ * Replaces the two top-level-only resolvers (`getFolderIdByName` in
5
+ * email/folder-utils.js and `resolveFolderName` in folder/stats.js) that could
6
+ * not address nested folders. Accepts, in priority order:
7
+ * 1. an explicit folder ID (never guessed from a name),
8
+ * 2. a well-known alias (inbox, archive, sent, ...),
9
+ * 3. a folder PATH like "Triage/Delete" or "Inbox/Clients/Acme"
10
+ * (case-insensitive, traversed segment-by-segment via childFolders),
11
+ * 4. a bare display name (unique top-level match wins for back-compat;
12
+ * otherwise the whole tree is searched, with ambiguity reported).
13
+ *
14
+ * Returns `{ id, displayName, parentId, path }`. Throws a caller-friendly Error
15
+ * on not-found or ambiguity (listing candidate paths + IDs).
16
+ *
17
+ * `/` is the path separator, so a folder whose display name literally contains
18
+ * `/` cannot be addressed by path — use its folderId (documented on the tool).
19
+ */
20
+ const { callGraphAPI } = require('../utils/graph-api');
21
+
22
+ // Alias → Graph well-known folder name (usable directly as a path segment).
23
+ const WELL_KNOWN = {
24
+ inbox: 'inbox',
25
+ drafts: 'drafts',
26
+ sent: 'sentitems',
27
+ sentitems: 'sentitems',
28
+ 'sent items': 'sentitems',
29
+ deleted: 'deleteditems',
30
+ deleteditems: 'deleteditems',
31
+ 'deleted items': 'deleteditems',
32
+ junk: 'junkemail',
33
+ junkemail: 'junkemail',
34
+ 'junk email': 'junkemail',
35
+ spam: 'junkemail',
36
+ archive: 'archive',
37
+ outbox: 'outbox',
38
+ };
39
+
40
+ // NB: mailFolder has no selectable `wellKnownName` property (Graph 400s on it,
41
+ // verified against consumer accounts) — well-known folders are addressed by
42
+ // name in the URL, not via a returned field.
43
+ const FOLDER_SELECT = 'id,displayName,parentFolderId,childFolderCount';
44
+ // Safety caps against pathological/looping nesting when walking the tree for a
45
+ // bare-name search. Beyond these we refuse to guess and ask for a path/ID.
46
+ const MAX_TREE_DEPTH = 20;
47
+ const MAX_TREE_REQUESTS = 200;
48
+
49
+ function notFoundError(spec) {
50
+ return new Error(
51
+ `Folder "${spec}" not found. Use \`folders\` action=list to see folders ` +
52
+ `(with IDs and full paths), pass a folder path like "Parent/Child", or a folderId.`
53
+ );
54
+ }
55
+
56
+ function ambiguousError(spec, candidates) {
57
+ const shown = candidates.slice(0, 20);
58
+ const lines = shown
59
+ .map((c) => ` - ${c.path} (folderId: ${c.id})`)
60
+ .join('\n');
61
+ const more =
62
+ candidates.length > shown.length
63
+ ? `\n … and ${candidates.length - shown.length} more`
64
+ : '';
65
+ return new Error(
66
+ `Folder "${spec}" is ambiguous — ${candidates.length} folders match:\n${lines}${more}\n` +
67
+ `Disambiguate with a folder path (e.g. "Parent/Child") or a folderId.`
68
+ );
69
+ }
70
+
71
+ /**
72
+ * List child folders of `parentId` (or top-level when null), following
73
+ * @odata.nextLink so folders with many children resolve completely.
74
+ * @returns {Promise<Array<{id, displayName, parentFolderId, childFolderCount}>>}
75
+ */
76
+ async function listChildFolders(accessToken, parentId, select = FOLDER_SELECT) {
77
+ const all = [];
78
+ let path = parentId
79
+ ? `me/mailFolders/${parentId}/childFolders`
80
+ : 'me/mailFolders';
81
+ let params = { $top: 100, $select: select };
82
+ // Follow pagination; nextLink already encodes params. Guard against a
83
+ // repeated/malformed nextLink so a bad server response can't loop forever.
84
+ const seen = new Set();
85
+ while (path) {
86
+ if (seen.has(path)) {
87
+ throw new Error(
88
+ 'Graph returned a repeated folder pagination link — aborting to avoid a loop.'
89
+ );
90
+ }
91
+ seen.add(path);
92
+ const resp = await callGraphAPI(accessToken, 'GET', path, null, params);
93
+ if (Array.isArray(resp.value)) {
94
+ all.push(...resp.value);
95
+ }
96
+ path = resp['@odata.nextLink'] || null;
97
+ params = {};
98
+ }
99
+ return all;
100
+ }
101
+
102
+ function toRecord(folder, path) {
103
+ return {
104
+ id: folder.id,
105
+ displayName: folder.displayName,
106
+ parentId: folder.parentFolderId || null,
107
+ path: path || folder.displayName,
108
+ };
109
+ }
110
+
111
+ async function resolveWellKnown(accessToken, alias) {
112
+ const resp = await callGraphAPI(
113
+ accessToken,
114
+ 'GET',
115
+ `me/mailFolders/${WELL_KNOWN[alias]}`,
116
+ null,
117
+ { $select: FOLDER_SELECT }
118
+ );
119
+ return toRecord(resp, resp.displayName);
120
+ }
121
+
122
+ async function resolveById(accessToken, id) {
123
+ const resp = await callGraphAPI(
124
+ accessToken,
125
+ 'GET',
126
+ `me/mailFolders/${id}`,
127
+ null,
128
+ { $select: FOLDER_SELECT }
129
+ );
130
+ return toRecord(resp, resp.displayName);
131
+ }
132
+
133
+ /**
134
+ * Build a flat list of every folder with its full path, breadth-first.
135
+ * Accepts an already-fetched top-level list to avoid re-fetching.
136
+ */
137
+ async function buildTree(accessToken, topLevel) {
138
+ const top = topLevel || (await listChildFolders(accessToken, null));
139
+ const out = [];
140
+ const visited = new Set();
141
+ const queue = top.map((f) => ({ folder: f, path: f.displayName, depth: 1 }));
142
+ let requests = 0;
143
+ // Index-based iteration (no O(n^2) shift); visited-set defends against
144
+ // cyclic/duplicate API data.
145
+ for (let i = 0; i < queue.length; i++) {
146
+ const { folder, path, depth } = queue[i];
147
+ if (visited.has(folder.id)) {
148
+ continue;
149
+ }
150
+ visited.add(folder.id);
151
+ out.push(toRecord(folder, path));
152
+ if (folder.childFolderCount > 0) {
153
+ // Refuse to return a "unique" bare-name result from a truncated scan —
154
+ // a match could exist in the unexplored remainder. Ask for a path/ID.
155
+ if (depth >= MAX_TREE_DEPTH) {
156
+ throw new Error(
157
+ `Folder tree exceeds the ${MAX_TREE_DEPTH}-level resolution limit — ` +
158
+ 'use an explicit folder path or folderId.'
159
+ );
160
+ }
161
+ if (++requests > MAX_TREE_REQUESTS) {
162
+ throw new Error(
163
+ 'Folder tree is too large to search by bare name — ' +
164
+ 'use an explicit folder path or folderId.'
165
+ );
166
+ }
167
+ const children = await listChildFolders(accessToken, folder.id);
168
+ for (const child of children) {
169
+ queue.push({
170
+ folder: child,
171
+ path: `${path}/${child.displayName}`,
172
+ depth: depth + 1,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ function matchName(folders, name) {
181
+ const lower = name.toLowerCase();
182
+ return folders.filter((f) => f.displayName.toLowerCase() === lower);
183
+ }
184
+
185
+ /**
186
+ * Resolve a bare display name: a unique top-level match wins (fast path,
187
+ * back-compat); otherwise search the whole tree, reporting ambiguity.
188
+ */
189
+ async function resolveByName(accessToken, name) {
190
+ const top = await listChildFolders(accessToken, null);
191
+ const topMatches = matchName(top, name);
192
+ if (topMatches.length === 1) {
193
+ return toRecord(topMatches[0], topMatches[0].displayName);
194
+ }
195
+ if (topMatches.length > 1) {
196
+ throw ambiguousError(
197
+ name,
198
+ topMatches.map((m) => ({ id: m.id, path: m.displayName }))
199
+ );
200
+ }
201
+ // Not top-level — search nested folders.
202
+ const tree = await buildTree(accessToken, top);
203
+ const matches = matchName(tree, name);
204
+ if (matches.length === 0) {
205
+ throw notFoundError(name);
206
+ }
207
+ if (matches.length > 1) {
208
+ throw ambiguousError(name, matches);
209
+ }
210
+ return matches[0];
211
+ }
212
+
213
+ /**
214
+ * Resolve a path (segments already split/trimmed) by traversing childFolders.
215
+ */
216
+ async function resolvePath(accessToken, segments) {
217
+ let current;
218
+ const first = segments[0];
219
+ if (WELL_KNOWN[first.toLowerCase()]) {
220
+ current = await resolveWellKnown(accessToken, first.toLowerCase());
221
+ } else {
222
+ const top = await listChildFolders(accessToken, null);
223
+ const matches = matchName(top, first);
224
+ if (matches.length === 0) {
225
+ throw notFoundError(first);
226
+ }
227
+ if (matches.length > 1) {
228
+ throw ambiguousError(
229
+ first,
230
+ matches.map((m) => ({ id: m.id, path: m.displayName }))
231
+ );
232
+ }
233
+ current = toRecord(matches[0], matches[0].displayName);
234
+ }
235
+
236
+ for (let i = 1; i < segments.length; i++) {
237
+ const seg = segments[i];
238
+ const children = await listChildFolders(accessToken, current.id);
239
+ const matches = matchName(children, seg);
240
+ if (matches.length === 0) {
241
+ throw notFoundError(`${current.path}/${seg}`);
242
+ }
243
+ if (matches.length > 1) {
244
+ throw ambiguousError(
245
+ `${current.path}/${seg}`,
246
+ matches.map((m) => ({
247
+ id: m.id,
248
+ path: `${current.path}/${m.displayName}`,
249
+ }))
250
+ );
251
+ }
252
+ current = {
253
+ id: matches[0].id,
254
+ displayName: matches[0].displayName,
255
+ parentId: current.id,
256
+ path: `${current.path}/${matches[0].displayName}`,
257
+ };
258
+ }
259
+ return current;
260
+ }
261
+
262
+ /**
263
+ * Resolve a folder from a name/path and/or explicit ID.
264
+ * @param {string} accessToken
265
+ * @param {{name?: string, id?: string}} spec
266
+ * @returns {Promise<{id: string, displayName: string, parentId: string|null, path: string}>}
267
+ * `path` is the full slash-separated path when resolved by name/path/alias;
268
+ * when resolved by ID it is the folder's display name only (ancestors are not
269
+ * fetched).
270
+ */
271
+ async function resolveFolder(accessToken, spec = {}) {
272
+ const id = (spec.id || '').trim();
273
+ if (id) {
274
+ return resolveById(accessToken, id);
275
+ }
276
+ const name = (spec.name || '').trim();
277
+ if (!name) {
278
+ throw new Error('A folder name, path, or folderId is required.');
279
+ }
280
+ if (name.includes('/')) {
281
+ // Trim each segment and drop leading/trailing empties ("/Inbox/" is fine),
282
+ // but reject INTERIOR empty segments ("A//B") so a typo can't silently
283
+ // collapse to a real folder — important for the destructive delete path.
284
+ const segments = name.split('/').map((s) => s.trim());
285
+ while (segments.length && segments[0] === '') {
286
+ segments.shift();
287
+ }
288
+ while (segments.length && segments[segments.length - 1] === '') {
289
+ segments.pop();
290
+ }
291
+ if (segments.length === 0) {
292
+ throw new Error(`Invalid folder path "${spec.name}".`);
293
+ }
294
+ if (segments.some((s) => s === '')) {
295
+ throw new Error(
296
+ `Invalid folder path "${spec.name}": empty path segment.`
297
+ );
298
+ }
299
+ if (segments.length === 1) {
300
+ return resolveFolder(accessToken, { name: segments[0] });
301
+ }
302
+ return resolvePath(accessToken, segments);
303
+ }
304
+ if (WELL_KNOWN[name.toLowerCase()]) {
305
+ return resolveWellKnown(accessToken, name.toLowerCase());
306
+ }
307
+ return resolveByName(accessToken, name);
308
+ }
309
+
310
+ module.exports = {
311
+ WELL_KNOWN,
312
+ resolveFolder,
313
+ listChildFolders,
314
+ buildTree,
315
+ };
package/folder/stats.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  const { callGraphAPI } = require('../utils/graph-api');
8
8
  const { ensureAuthenticated } = require('../auth');
9
+ const { resolveFolder } = require('./resolve');
9
10
  const config = require('../config');
10
11
 
11
12
  const { VERBOSITY, DEFAULT_LIMITS } = config;
@@ -17,24 +18,25 @@ const { VERBOSITY, DEFAULT_LIMITS } = config;
17
18
  */
18
19
  async function handleGetFolderStats(args) {
19
20
  const folderName = args.folder || 'inbox';
21
+ const folderIdArg = args.folderId || '';
20
22
  const verbosity = args.outputVerbosity || VERBOSITY.STANDARD;
21
23
 
22
24
  try {
23
25
  const accessToken = await ensureAuthenticated();
24
26
 
25
- // Resolve folder name to ID
26
- const folderId = await resolveFolderName(accessToken, folderName);
27
-
28
- if (!folderId) {
27
+ // Resolve folder (name/path/alias/ID → concrete folder). (#216)
28
+ let resolved;
29
+ try {
30
+ resolved = await resolveFolder(accessToken, {
31
+ name: folderName,
32
+ id: folderIdArg,
33
+ });
34
+ } catch (resolveError) {
29
35
  return {
30
- content: [
31
- {
32
- type: 'text',
33
- text: `Folder "${folderName}" not found.`,
34
- },
35
- ],
36
+ content: [{ type: 'text', text: resolveError.message }],
36
37
  };
37
38
  }
39
+ const folderId = resolved.id;
38
40
 
39
41
  // Get folder details with full stats
40
42
  const folder = await callGraphAPI(
@@ -90,71 +92,6 @@ async function handleGetFolderStats(args) {
90
92
  }
91
93
  }
92
94
 
93
- /**
94
- * Resolve folder name to ID
95
- * @param {string} accessToken - Access token
96
- * @param {string} folderName - Folder name or well-known name
97
- * @returns {Promise<string|null>} - Folder ID or null
98
- */
99
- async function resolveFolderName(accessToken, folderName) {
100
- const wellKnownFolders = {
101
- inbox: 'inbox',
102
- sent: 'sentitems',
103
- sentitems: 'sentitems',
104
- 'sent items': 'sentitems',
105
- drafts: 'drafts',
106
- deleted: 'deleteditems',
107
- deleteditems: 'deleteditems',
108
- 'deleted items': 'deleteditems',
109
- junk: 'junkemail',
110
- junkemail: 'junkemail',
111
- 'junk email': 'junkemail',
112
- spam: 'junkemail',
113
- archive: 'archive',
114
- outbox: 'outbox',
115
- };
116
-
117
- const normalised = folderName.toLowerCase().trim();
118
-
119
- // Check if it's a well-known folder
120
- if (wellKnownFolders[normalised]) {
121
- try {
122
- const response = await callGraphAPI(
123
- accessToken,
124
- 'GET',
125
- `me/mailFolders/${wellKnownFolders[normalised]}`,
126
- null,
127
- { $select: 'id' }
128
- );
129
- return response.id;
130
- } catch (_error) {
131
- // Fall through to search
132
- }
133
- }
134
-
135
- // Search for folder by name
136
- try {
137
- const response = await callGraphAPI(
138
- accessToken,
139
- 'GET',
140
- 'me/mailFolders',
141
- null,
142
- {
143
- $filter: `displayName eq '${folderName}'`,
144
- $select: 'id',
145
- }
146
- );
147
-
148
- if (response.value && response.value.length > 0) {
149
- return response.value[0].id;
150
- }
151
- } catch (error) {
152
- console.error(`Error searching for folder: ${error.message}`);
153
- }
154
-
155
- return null;
156
- }
157
-
158
95
  /**
159
96
  * Get date range of emails in folder
160
97
  * @param {string} accessToken - Access token
package/llms.txt CHANGED
@@ -22,13 +22,13 @@ Built by [Little Bear Apps](https://littlebearapps.com).
22
22
 
23
23
  ## Key Differentiators
24
24
 
25
- - **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback.
25
+ - **Progressive search**: Automatically falls back through 4 search strategies when Microsoft's `$search` API is unavailable (personal accounts) — most Graph API wrappers fail silently. Explicit "no results" messaging instead of unfiltered fallback. Cross-folder search (`searchAllFolders`) reliably returns a superset of inbox results.
26
26
  - **Remote-friendly auth**: Device code flow (default) — no auth server, no port forwarding, no SSH tunnels. State persists across MCP server restarts. Works from Untether, mosh, SSH, and headless environments.
27
27
  - **Email forensics**: Built-in header analysis for DKIM, SPF, DMARC authentication, delivery chains, and spam scores — useful for phishing investigation and compliance
28
28
  - **Delta sync**: Incremental inbox monitoring — returns only new, modified, and deleted emails since last check, with tokens for continuous polling
29
29
  - **Batch operations**: Flag, move, export, or categorise multiple emails in a single tool call; search-driven export for batch archiving without collecting IDs
30
30
  - **Pre-send intelligence**: Check recipients for out-of-office, mailbox full, delivery restrictions, and moderation before sending — no other Outlook MCP server offers this
31
- - **Compound automation**: Rules + categories + folders + Focused Inbox for complete inbox management in one conversation
31
+ - **Compound automation**: Rules + categories + nested folders (addressable by path or ID) + Focused Inbox for complete inbox management in one conversation
32
32
 
33
33
  ## Safety & Token Efficiency
34
34
 
@@ -63,7 +63,7 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
63
63
  - **Email (8 tools)**: `search-emails`, `read-email`, `send-email`, `draft`, `update-email`, `attachments`, `export`, `get-mail-tips`
64
64
  - **Calendar (3 tools)**: `list-events`, `create-event`, `manage-event`
65
65
  - **Contacts (2 tools)**: `manage-contact`, `search-people`
66
- - **Folders (1 tool)**: `folders` — list, create, move, stats
66
+ - **Folders (1 tool)**: `folders` — list, create, move, stats, delete; folders addressable by nested path (`Parent/Child`) or ID
67
67
  - **Rules (1 tool)**: `manage-rules` — list, create, update, reorder, delete
68
68
  - **Categories (3 tools)**: `manage-category`, `apply-category`, `manage-focused-inbox`
69
69
  - **Settings (1 tool)**: `mailbox-settings` — get, set auto-replies, set working hours
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
79
79
  - [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
80
  - [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
81
81
  - [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.8.0 — `manage-event` gains an `update` action covering 12 fields with `dryRun` (#173, closes #124); new `OUTLOOK_AUTH_AUDIENCE` env var fixes AADSTS9002331 for personal-only Azure apps (#174); new `OUTLOOK_DEFAULT_TIMEZONE` env var overrides the hardcoded Australia/Melbourne default (#175))
83
- - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.8.x polish, v3.9.0 new Graph APIs)
82
+ - [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.9.0 — nested folder addressing by path/ID (#216); reliable cross-folder search with `kqlQuery` renamed to `searchExpression` (#169); plus the v3.8.3 patch — security `overrides` clearing the audit gate (#215), `openWorldHint` on external-content tools (#92), canonical UTC ISO-8601 `list-events` times (#118))
83
+ - [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.7.5 polish, v3.8.x carry-over, v3.10.0+ new Graph APIs)
84
84
  - [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.8.2",
3
+ "version": "3.9.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",
@@ -96,6 +96,9 @@
96
96
  "node": ">=18.18.0"
97
97
  },
98
98
  "overrides": {
99
- "minimatch": ">=3.1.3"
99
+ "minimatch": ">=3.1.3",
100
+ "hono": "^4.12.31",
101
+ "fast-uri": "^3.1.4",
102
+ "body-parser": "^2.3.0"
100
103
  }
101
104
  }
@@ -90,8 +90,18 @@ async function callGraphAPI(
90
90
  headers.Prefer = 'IdType="ImmutableId"';
91
91
  }
92
92
 
93
- // Merge any extra headers (caller overrides take precedence)
93
+ // Merge any extra headers (caller overrides take precedence). `Prefer`
94
+ // is multi-valued in HTTP (comma-separated); combine both values rather
95
+ // than letting a caller Prefer (e.g. outlook.timezone) clobber the global
96
+ // immutable-IDs Prefer, or vice versa.
97
+ const combinedPrefer =
98
+ headers.Prefer && extraHeaders.Prefer
99
+ ? `${headers.Prefer}, ${extraHeaders.Prefer}`
100
+ : null;
94
101
  Object.assign(headers, extraHeaders);
102
+ if (combinedPrefer) {
103
+ headers.Prefer = combinedPrefer;
104
+ }
95
105
 
96
106
  const options = {
97
107
  method: method,