@littlebearapps/outlook-assistant 3.11.2 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.env.example +20 -0
  2. package/README.md +51 -28
  3. package/advanced/index.js +319 -46
  4. package/auth/device-code.js +100 -3
  5. package/auth/token-storage.js +44 -2
  6. package/auth/tools.js +196 -14
  7. package/calendar/attendees.js +101 -0
  8. package/calendar/cancel.js +5 -4
  9. package/calendar/create.js +15 -4
  10. package/calendar/decline.js +10 -5
  11. package/calendar/index.js +51 -10
  12. package/calendar/list.js +146 -3
  13. package/calendar/update.js +65 -33
  14. package/categories/index.js +17 -3
  15. package/config.js +103 -17
  16. package/contacts/index.js +2 -1
  17. package/email/attachments.js +19 -37
  18. package/email/conversations.js +180 -91
  19. package/email/delta.js +123 -13
  20. package/email/draft.js +66 -9
  21. package/email/export.js +113 -77
  22. package/email/folder-utils.js +29 -129
  23. package/email/headers.js +5 -1
  24. package/email/index.js +76 -19
  25. package/email/list.js +8 -1
  26. package/email/mark-as-read.js +3 -1
  27. package/email/mime.js +4 -1
  28. package/email/read.js +5 -1
  29. package/email/search.js +23 -9
  30. package/folder/create.js +11 -4
  31. package/folder/delete.js +9 -1
  32. package/folder/index.js +11 -1
  33. package/folder/list.js +61 -27
  34. package/folder/move.js +32 -7
  35. package/folder/resolve.js +65 -25
  36. package/folder/stats.js +11 -5
  37. package/index.js +9 -1
  38. package/llms-install.md +28 -9
  39. package/llms.txt +13 -9
  40. package/package.json +3 -3
  41. package/rules/index.js +3 -3
  42. package/rules/rule-builder.js +61 -16
  43. package/utils/datetime.js +170 -0
  44. package/utils/graph-api.js +390 -211
  45. package/utils/mailbox.js +77 -0
  46. package/utils/mock-data.js +3 -0
  47. package/utils/odata-helpers.js +24 -0
  48. package/utils/safe-write.js +151 -0
  49. package/calendar/accept.js +0 -72
package/folder/list.js CHANGED
@@ -12,38 +12,44 @@ const { listChildFolders } = require('./resolve');
12
12
  async function handleListFolders(args) {
13
13
  const includeItemCounts = args.includeItemCounts === true;
14
14
  const includeChildren = args.includeChildren === true;
15
+ // Target a shared/delegated mailbox instead of the signed-in account.
16
+ const sharedMailbox = args.sharedMailbox || args.email || null;
15
17
 
16
18
  try {
17
19
  // Get access token
18
20
  const accessToken = await ensureAuthenticated();
19
21
 
20
22
  // Get all mail folders
21
- const folders = await getAllFoldersHierarchy(
23
+ const { folders, warnings } = await getAllFoldersHierarchy(
22
24
  accessToken,
23
- includeItemCounts
25
+ includeItemCounts,
26
+ sharedMailbox
24
27
  );
25
28
 
26
- // If including children, format as hierarchy
27
- if (includeChildren) {
28
- return {
29
- content: [
30
- {
31
- type: 'text',
32
- text: formatFolderHierarchy(folders, includeItemCounts),
33
- },
34
- ],
35
- };
36
- } else {
37
- // Otherwise, format as flat list
38
- return {
39
- content: [
40
- {
41
- type: 'text',
42
- text: formatFolderList(folders, includeItemCounts),
43
- },
44
- ],
45
- };
29
+ let heading = sharedMailbox ? `\n\nMailbox: ${sharedMailbox}` : '';
30
+ // The walk can skip branches (permission errors, depth cap) — say so
31
+ // instead of presenting a partial tree as complete.
32
+ if (warnings.length > 0) {
33
+ heading += `\n\n**Partial listing — ${warnings.length} branch(es) incomplete:**\n${warnings.map((w) => `- ${w}`).join('\n')}`;
46
34
  }
35
+
36
+ const body = includeChildren
37
+ ? formatFolderHierarchy(folders, includeItemCounts)
38
+ : formatFolderList(folders, includeItemCounts);
39
+
40
+ return {
41
+ content: [
42
+ {
43
+ type: 'text',
44
+ text: body + heading,
45
+ },
46
+ ],
47
+ _meta: {
48
+ folderCount: folders.length,
49
+ partial: warnings.length > 0,
50
+ warnings,
51
+ },
52
+ };
47
53
  } catch (error) {
48
54
  if (error.message === 'Authentication required') {
49
55
  return {
@@ -71,9 +77,14 @@ async function handleListFolders(args) {
71
77
  * Get all mail folders with hierarchy information
72
78
  * @param {string} accessToken - Access token
73
79
  * @param {boolean} includeItemCounts - Include item counts in response
74
- * @returns {Promise<Array>} - Array of folder objects with hierarchy
80
+ * @param {string|null} [sharedMailbox] - Shared mailbox email, or null for the signed-in account
81
+ * @returns {Promise<{folders: Array, warnings: Array<string>}>} - Folders plus any reasons the tree is incomplete
75
82
  */
76
- async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
83
+ async function getAllFoldersHierarchy(
84
+ accessToken,
85
+ includeItemCounts,
86
+ sharedMailbox = null
87
+ ) {
77
88
  // Determine select fields based on whether to include counts
78
89
  const selectFields = includeItemCounts
79
90
  ? 'id,displayName,parentFolderId,childFolderCount,totalItemCount,unreadItemCount'
@@ -81,8 +92,14 @@ async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
81
92
 
82
93
  // Full recursive, paginated walk so nested folders at ANY depth appear with
83
94
  // their complete path (not just one level). (#216 review)
84
- const top = await listChildFolders(accessToken, null, selectFields);
95
+ const top = await listChildFolders(
96
+ accessToken,
97
+ null,
98
+ selectFields,
99
+ sharedMailbox
100
+ );
85
101
  const all = [];
102
+ const warnings = [];
86
103
  const visited = new Set();
87
104
  const queue = top.map((folder) => ({
88
105
  folder,
@@ -100,14 +117,28 @@ async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
100
117
  visited.add(folder.id);
101
118
  all.push({ ...folder, path, parentFolder: parentPath, isTopLevel });
102
119
 
120
+ if (folder.childFolderCount > 0 && depth >= 20) {
121
+ warnings.push(
122
+ `Depth limit (20) reached at "${path}" [id: ${folder.id}] — its subfolders were not listed.`
123
+ );
124
+ }
125
+
103
126
  if (folder.childFolderCount > 0 && depth < 20) {
104
127
  let children;
105
128
  try {
106
- children = await listChildFolders(accessToken, folder.id, selectFields);
129
+ children = await listChildFolders(
130
+ accessToken,
131
+ folder.id,
132
+ selectFields,
133
+ sharedMailbox
134
+ );
107
135
  } catch (error) {
108
136
  console.error(
109
137
  `Error getting child folders for "${folder.displayName}": ${error.message}`
110
138
  );
139
+ warnings.push(
140
+ `Could not list subfolders of "${path}" [id: ${folder.id}]: ${error.message}`
141
+ );
111
142
  continue;
112
143
  }
113
144
  for (const child of children) {
@@ -121,7 +152,7 @@ async function getAllFoldersHierarchy(accessToken, includeItemCounts) {
121
152
  }
122
153
  }
123
154
  }
124
- return all;
155
+ return { folders: all, warnings };
125
156
  }
126
157
 
127
158
  /**
@@ -272,3 +303,6 @@ function formatFolderHierarchy(folders, includeItemCounts) {
272
303
  }
273
304
 
274
305
  module.exports = handleListFolders;
306
+ // Named export so the shared-mailbox folder listing in `access-shared-mailbox`
307
+ // reuses this walk instead of carrying its own copy.
308
+ module.exports.getAllFoldersHierarchy = getAllFoldersHierarchy;
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
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Shared, path-aware, ambiguity-aware mail-folder resolver. (#216)
3
3
  *
4
- * Replaces the two top-level-only resolvers (`getFolderIdByName` in
4
+ * Replaced the top-level-only resolvers (`getFolderIdByName` in
5
5
  * email/folder-utils.js and `resolveFolderName` in folder/stats.js) that could
6
- * not address nested folders. Accepts, in priority order:
6
+ * not address nested folders (manage-rules folder actions use it since #248).
7
+ * Accepts, in priority order:
7
8
  * 1. an explicit folder ID (never guessed from a name),
8
9
  * 2. a well-known alias (inbox, archive, sent, ...),
9
10
  * 3. a folder PATH like "Triage/Delete" or "Inbox/Clients/Acme"
@@ -16,8 +17,14 @@
16
17
  *
17
18
  * `/` is the path separator, so a folder whose display name literally contains
18
19
  * `/` cannot be addressed by path — use its folderId (documented on the tool).
20
+ *
21
+ * Every function takes an optional `mailbox` (a shared/delegated mailbox email
22
+ * address). It only changes the Graph path prefix — `me` vs `users/{mailbox}` —
23
+ * so the same resolution logic reaches custom subfolders and localized folder
24
+ * names in a shared mailbox exactly as it does in the signed-in account.
19
25
  */
20
26
  const { callGraphAPI } = require('../utils/graph-api');
27
+ const { buildMailboxPrefix } = require('../utils/mailbox');
21
28
 
22
29
  // Alias → Graph well-known folder name (usable directly as a path segment).
23
30
  const WELL_KNOWN = {
@@ -73,11 +80,17 @@ function ambiguousError(spec, candidates) {
73
80
  * @odata.nextLink so folders with many children resolve completely.
74
81
  * @returns {Promise<Array<{id, displayName, parentFolderId, childFolderCount}>>}
75
82
  */
76
- async function listChildFolders(accessToken, parentId, select = FOLDER_SELECT) {
83
+ async function listChildFolders(
84
+ accessToken,
85
+ parentId,
86
+ select = FOLDER_SELECT,
87
+ mailbox = null
88
+ ) {
89
+ const prefix = buildMailboxPrefix(mailbox);
77
90
  const all = [];
78
91
  let path = parentId
79
- ? `me/mailFolders/${parentId}/childFolders`
80
- : 'me/mailFolders';
92
+ ? `${prefix}/mailFolders/${parentId}/childFolders`
93
+ : `${prefix}/mailFolders`;
81
94
  let params = { $top: 100, $select: select };
82
95
  // Follow pagination; nextLink already encodes params. Guard against a
83
96
  // repeated/malformed nextLink so a bad server response can't loop forever.
@@ -108,22 +121,22 @@ function toRecord(folder, path) {
108
121
  };
109
122
  }
110
123
 
111
- async function resolveWellKnown(accessToken, alias) {
124
+ async function resolveWellKnown(accessToken, alias, mailbox) {
112
125
  const resp = await callGraphAPI(
113
126
  accessToken,
114
127
  'GET',
115
- `me/mailFolders/${WELL_KNOWN[alias]}`,
128
+ `${buildMailboxPrefix(mailbox)}/mailFolders/${WELL_KNOWN[alias]}`,
116
129
  null,
117
130
  { $select: FOLDER_SELECT }
118
131
  );
119
132
  return toRecord(resp, resp.displayName);
120
133
  }
121
134
 
122
- async function resolveById(accessToken, id) {
135
+ async function resolveById(accessToken, id, mailbox) {
123
136
  const resp = await callGraphAPI(
124
137
  accessToken,
125
138
  'GET',
126
- `me/mailFolders/${id}`,
139
+ `${buildMailboxPrefix(mailbox)}/mailFolders/${id}`,
127
140
  null,
128
141
  { $select: FOLDER_SELECT }
129
142
  );
@@ -134,8 +147,9 @@ async function resolveById(accessToken, id) {
134
147
  * Build a flat list of every folder with its full path, breadth-first.
135
148
  * Accepts an already-fetched top-level list to avoid re-fetching.
136
149
  */
137
- async function buildTree(accessToken, topLevel) {
138
- const top = topLevel || (await listChildFolders(accessToken, null));
150
+ async function buildTree(accessToken, topLevel, mailbox = null) {
151
+ const top =
152
+ topLevel || (await listChildFolders(accessToken, null, undefined, mailbox));
139
153
  const out = [];
140
154
  const visited = new Set();
141
155
  const queue = top.map((f) => ({ folder: f, path: f.displayName, depth: 1 }));
@@ -164,7 +178,12 @@ async function buildTree(accessToken, topLevel) {
164
178
  'use an explicit folder path or folderId.'
165
179
  );
166
180
  }
167
- const children = await listChildFolders(accessToken, folder.id);
181
+ const children = await listChildFolders(
182
+ accessToken,
183
+ folder.id,
184
+ undefined,
185
+ mailbox
186
+ );
168
187
  for (const child of children) {
169
188
  queue.push({
170
189
  folder: child,
@@ -186,8 +205,8 @@ function matchName(folders, name) {
186
205
  * Resolve a bare display name: a unique top-level match wins (fast path,
187
206
  * back-compat); otherwise search the whole tree, reporting ambiguity.
188
207
  */
189
- async function resolveByName(accessToken, name) {
190
- const top = await listChildFolders(accessToken, null);
208
+ async function resolveByName(accessToken, name, mailbox) {
209
+ const top = await listChildFolders(accessToken, null, undefined, mailbox);
191
210
  const topMatches = matchName(top, name);
192
211
  if (topMatches.length === 1) {
193
212
  return toRecord(topMatches[0], topMatches[0].displayName);
@@ -199,7 +218,7 @@ async function resolveByName(accessToken, name) {
199
218
  );
200
219
  }
201
220
  // Not top-level — search nested folders.
202
- const tree = await buildTree(accessToken, top);
221
+ const tree = await buildTree(accessToken, top, mailbox);
203
222
  const matches = matchName(tree, name);
204
223
  if (matches.length === 0) {
205
224
  throw notFoundError(name);
@@ -213,13 +232,13 @@ async function resolveByName(accessToken, name) {
213
232
  /**
214
233
  * Resolve a path (segments already split/trimmed) by traversing childFolders.
215
234
  */
216
- async function resolvePath(accessToken, segments) {
235
+ async function resolvePath(accessToken, segments, mailbox) {
217
236
  let current;
218
237
  const first = segments[0];
219
238
  if (WELL_KNOWN[first.toLowerCase()]) {
220
- current = await resolveWellKnown(accessToken, first.toLowerCase());
239
+ current = await resolveWellKnown(accessToken, first.toLowerCase(), mailbox);
221
240
  } else {
222
- const top = await listChildFolders(accessToken, null);
241
+ const top = await listChildFolders(accessToken, null, undefined, mailbox);
223
242
  const matches = matchName(top, first);
224
243
  if (matches.length === 0) {
225
244
  throw notFoundError(first);
@@ -235,7 +254,12 @@ async function resolvePath(accessToken, segments) {
235
254
 
236
255
  for (let i = 1; i < segments.length; i++) {
237
256
  const seg = segments[i];
238
- const children = await listChildFolders(accessToken, current.id);
257
+ const children = await listChildFolders(
258
+ accessToken,
259
+ current.id,
260
+ undefined,
261
+ mailbox
262
+ );
239
263
  const matches = matchName(children, seg);
240
264
  if (matches.length === 0) {
241
265
  throw notFoundError(`${current.path}/${seg}`);
@@ -259,19 +283,34 @@ async function resolvePath(accessToken, segments) {
259
283
  return current;
260
284
  }
261
285
 
286
+ /**
287
+ * Does a `folder` value look like a raw Graph folder ID rather than a display
288
+ * name or path? Graph IDs are long base64url-style tokens (`AAMkAG…`,
289
+ * `AQMkAD…`) with no spaces or `/`; display names that long without a space
290
+ * are vanishingly rare. Used where `folder` historically accepted raw IDs.
291
+ * @param {string} value
292
+ * @returns {boolean}
293
+ */
294
+ function looksLikeFolderId(value) {
295
+ return (
296
+ typeof value === 'string' && /^[A-Za-z0-9_+=-]{60,}$/.test(value.trim())
297
+ );
298
+ }
299
+
262
300
  /**
263
301
  * Resolve a folder from a name/path and/or explicit ID.
264
302
  * @param {string} accessToken
265
- * @param {{name?: string, id?: string}} spec
303
+ * @param {{name?: string, id?: string, mailbox?: string|null}} spec
266
304
  * @returns {Promise<{id: string, displayName: string, parentId: string|null, path: string}>}
267
305
  * `path` is the full slash-separated path when resolved by name/path/alias;
268
306
  * when resolved by ID it is the folder's display name only (ancestors are not
269
307
  * fetched).
270
308
  */
271
309
  async function resolveFolder(accessToken, spec = {}) {
310
+ const mailbox = spec.mailbox || null;
272
311
  const id = (spec.id || '').trim();
273
312
  if (id) {
274
- return resolveById(accessToken, id);
313
+ return resolveById(accessToken, id, mailbox);
275
314
  }
276
315
  const name = (spec.name || '').trim();
277
316
  if (!name) {
@@ -297,18 +336,19 @@ async function resolveFolder(accessToken, spec = {}) {
297
336
  );
298
337
  }
299
338
  if (segments.length === 1) {
300
- return resolveFolder(accessToken, { name: segments[0] });
339
+ return resolveFolder(accessToken, { name: segments[0], mailbox });
301
340
  }
302
- return resolvePath(accessToken, segments);
341
+ return resolvePath(accessToken, segments, mailbox);
303
342
  }
304
343
  if (WELL_KNOWN[name.toLowerCase()]) {
305
- return resolveWellKnown(accessToken, name.toLowerCase());
344
+ return resolveWellKnown(accessToken, name.toLowerCase(), mailbox);
306
345
  }
307
- return resolveByName(accessToken, name);
346
+ return resolveByName(accessToken, name, mailbox);
308
347
  }
309
348
 
310
349
  module.exports = {
311
350
  WELL_KNOWN,
351
+ looksLikeFolderId,
312
352
  resolveFolder,
313
353
  listChildFolders,
314
354
  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/index.js CHANGED
@@ -35,8 +35,16 @@ Key environment variables:
35
35
  OUTLOOK_CLIENT_SECRET Client secret VALUE (not the Secret ID)
36
36
  OUTLOOK_AUTH_METHOD device-code (default) | browser
37
37
  OUTLOOK_AUTH_AUDIENCE common | consumers | organizations | <tenant-guid>
38
- OUTLOOK_MAX_EMAILS_PER_SESSION Cap on sends per session
38
+ OUTLOOK_SHARED_MAILBOX Opt in to shared mailboxes: read | true (work/school only)
39
39
  OUTLOOK_ALLOWED_RECIPIENTS Comma-separated recipient allowlist
40
+ OUTLOOK_MAX_EMAILS_PER_SESSION Default cap per session for every rate-limited tool
41
+ (send-email, draft, manage-rules); 0 or unset = no cap
42
+ OUTLOOK_MAX_<TOOL>_PER_SESSION Per-tool cap overriding the default, tool name in upper
43
+ case with _ for -, e.g. OUTLOOK_MAX_SEND_EMAIL_PER_SESSION
44
+ OUTLOOK_DEFAULT_TIMEZONE IANA timezone for event times (default Australia/Melbourne)
45
+ OUTLOOK_IMMUTABLE_IDS Set to "true" for message IDs that survive folder moves
46
+ OUTLOOK_SEARCH_SCAN_LIMIT Local search fallback window (default 500, max 5000)
47
+ OUTLOOK_REQUEST_TIMEOUT_MS Graph request inactivity timeout (default 60000)
40
48
  USE_TEST_MODE Set to "true" to run against mock data
41
49
 
42
50
  Documentation: https://github.com/littlebearapps/outlook-assistant`;
package/llms-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` |