@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.
- package/.env.example +20 -0
- package/README.md +51 -28
- package/advanced/index.js +319 -46
- package/auth/device-code.js +100 -3
- package/auth/token-storage.js +44 -2
- package/auth/tools.js +196 -14
- package/calendar/attendees.js +101 -0
- package/calendar/cancel.js +5 -4
- package/calendar/create.js +15 -4
- package/calendar/decline.js +10 -5
- package/calendar/index.js +51 -10
- package/calendar/list.js +146 -3
- package/calendar/update.js +65 -33
- package/categories/index.js +17 -3
- package/config.js +103 -17
- package/contacts/index.js +2 -1
- package/email/attachments.js +19 -37
- package/email/conversations.js +180 -91
- package/email/delta.js +123 -13
- package/email/draft.js +66 -9
- package/email/export.js +113 -77
- package/email/folder-utils.js +29 -129
- package/email/headers.js +5 -1
- package/email/index.js +76 -19
- 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 +23 -9
- 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 +65 -25
- package/folder/stats.js +11 -5
- package/index.js +9 -1
- package/llms-install.md +28 -9
- package/llms.txt +13 -9
- package/package.json +3 -3
- package/rules/index.js +3 -3
- package/rules/rule-builder.js +61 -16
- package/utils/datetime.js +170 -0
- package/utils/graph-api.js +390 -211
- package/utils/mailbox.js +77 -0
- package/utils/mock-data.js +3 -0
- package/utils/odata-helpers.js +24 -0
- package/utils/safe-write.js +151 -0
- 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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
* @
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared, path-aware, ambiguity-aware mail-folder resolver. (#216)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
?
|
|
80
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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/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
|
-
|
|
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
|
|
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` |
|