@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/.env.example +12 -0
- package/README.md +46 -28
- package/advanced/index.js +239 -10
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/index.js +19 -1
- package/calendar/list.js +154 -2
- package/categories/index.js +17 -3
- package/config.js +73 -17
- package/email/attachments.js +13 -3
- package/email/conversations.js +27 -5
- package/email/delta.js +94 -4
- package/email/export.js +164 -54
- package/email/folder-utils.js +29 -6
- package/email/headers.js +5 -1
- package/email/index.js +68 -13
- package/email/list.js +8 -1
- package/email/mark-as-read.js +3 -1
- package/email/mime.js +4 -1
- package/email/read.js +5 -1
- package/email/search.js +14 -5
- package/folder/create.js +11 -4
- package/folder/delete.js +9 -1
- package/folder/index.js +11 -1
- package/folder/list.js +61 -27
- package/folder/move.js +32 -7
- package/folder/resolve.js +62 -23
- package/folder/stats.js +11 -5
- package/llms-install.md +28 -9
- package/llms.txt +12 -8
- package/package.json +3 -3
- package/utils/graph-api.js +76 -3
- package/utils/mailbox.js +77 -0
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
|
-
|
|
138
|
-
|
|
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(
|
|
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
|
-
?
|
|
80
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 |
|
|
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.
|
|
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` —
|
|
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.
|
|
83
|
-
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
92
|
+
"lint-staged": "^17.6.0",
|
|
93
93
|
"prettier": "^3.8.1",
|
|
94
94
|
"supertest": "^7.2.2"
|
|
95
95
|
},
|
package/utils/graph-api.js
CHANGED
|
@@ -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
|
|
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,
|