@littlebearapps/outlook-assistant 3.11.1 → 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 +79 -5
- package/email/conversations.js +29 -15
- 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 +5 -5
- package/utils/graph-api.js +109 -3
- package/utils/mailbox.js +77 -0
- package/utils/response-formatter.js +44 -10
package/utils/graph-api.js
CHANGED
|
@@ -5,6 +5,87 @@ const https = require('https');
|
|
|
5
5
|
const config = require('../config');
|
|
6
6
|
const mockData = require('./mock-data');
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Guard for caller-supplied full URLs (nextLink/deltaLink continuations).
|
|
10
|
+
* The bearer token is attached to every request, so only the configured
|
|
11
|
+
* Graph host over HTTPS on the default port is allowed (GHSA-mqfm-wfjq-jxq2).
|
|
12
|
+
* @param {string} url - Full URL about to be requested
|
|
13
|
+
* @throws {Error} If the URL is malformed or not the Graph host
|
|
14
|
+
*/
|
|
15
|
+
function assertGraphUrl(url) {
|
|
16
|
+
const allowed = new URL(config.GRAPH_API_ENDPOINT);
|
|
17
|
+
let target;
|
|
18
|
+
try {
|
|
19
|
+
target = new URL(url);
|
|
20
|
+
} catch {
|
|
21
|
+
target = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ok =
|
|
25
|
+
target &&
|
|
26
|
+
target.protocol === 'https:' &&
|
|
27
|
+
target.hostname === allowed.hostname &&
|
|
28
|
+
target.port === '' &&
|
|
29
|
+
target.username === '' &&
|
|
30
|
+
target.password === '';
|
|
31
|
+
|
|
32
|
+
if (!ok) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'Refusing to call non-Graph URL: continuation links must be https://' +
|
|
35
|
+
`${allowed.hostname}/ (check the deltaToken or nextLink value)`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
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
|
+
|
|
8
89
|
/**
|
|
9
90
|
* Makes a request to the Microsoft Graph API
|
|
10
91
|
* In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
|
|
@@ -36,9 +117,13 @@ async function callGraphAPI(
|
|
|
36
117
|
let finalUrl;
|
|
37
118
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
38
119
|
// Path is already a full URL (from pagination nextLink)
|
|
120
|
+
assertGraphUrl(path);
|
|
39
121
|
finalUrl = path;
|
|
40
122
|
} else {
|
|
41
|
-
// 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);
|
|
42
127
|
// Encode path segments properly
|
|
43
128
|
const encodedPath = path
|
|
44
129
|
.split('/')
|
|
@@ -259,6 +344,12 @@ async function callGraphAPIBatch(accessToken, requests) {
|
|
|
259
344
|
}));
|
|
260
345
|
}
|
|
261
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
|
+
|
|
262
353
|
const batchPayload = {
|
|
263
354
|
requests: requests.map((req) => ({
|
|
264
355
|
id: req.id,
|
|
@@ -286,11 +377,12 @@ async function callGraphAPIBatch(accessToken, requests) {
|
|
|
286
377
|
* In test mode (USE_TEST_MODE=true), returns mock MIME content instead of calling the real API.
|
|
287
378
|
* @param {string} accessToken - The access token for authentication
|
|
288
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`.
|
|
289
381
|
* @returns {Promise<string>} - Raw MIME content as string
|
|
290
382
|
* @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
|
|
291
383
|
* @throws {Error} If the HTTP status is outside 2xx or a network error occurs
|
|
292
384
|
*/
|
|
293
|
-
async function callGraphAPIRaw(accessToken, emailId) {
|
|
385
|
+
async function callGraphAPIRaw(accessToken, emailId, mailboxPrefix = 'me') {
|
|
294
386
|
// Test mode: return mock MIME content
|
|
295
387
|
if (config.USE_TEST_MODE && accessToken.startsWith('test_access_token_')) {
|
|
296
388
|
return mockData.getMockMimeContent
|
|
@@ -298,8 +390,21 @@ async function callGraphAPIRaw(accessToken, emailId) {
|
|
|
298
390
|
: `MIME-Version: 1.0\nContent-Type: text/plain\n\nTest email content for ${emailId}`;
|
|
299
391
|
}
|
|
300
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
|
+
|
|
301
402
|
return new Promise((resolve, reject) => {
|
|
302
|
-
const
|
|
403
|
+
const encodedPrefix = mailboxPrefix
|
|
404
|
+
.split('/')
|
|
405
|
+
.map((segment) => encodeURIComponent(segment))
|
|
406
|
+
.join('/');
|
|
407
|
+
const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
|
|
303
408
|
const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`;
|
|
304
409
|
|
|
305
410
|
const options = {
|
|
@@ -401,6 +506,7 @@ async function callGraphAPIWithAuth(
|
|
|
401
506
|
}
|
|
402
507
|
|
|
403
508
|
module.exports = {
|
|
509
|
+
assertSafeResourcePath,
|
|
404
510
|
callGraphAPI,
|
|
405
511
|
callGraphAPIPaginated,
|
|
406
512
|
callGraphAPIBatch,
|
package/utils/mailbox.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mailbox scoping helper.
|
|
3
|
+
*
|
|
4
|
+
* Every Graph path in this server is built as `${prefix}/...`. The prefix is
|
|
5
|
+
* `me` for the signed-in account, or `users/{email}` for a shared/delegated
|
|
6
|
+
* mailbox. Keeping the construction in one place is what lets the shared-mailbox
|
|
7
|
+
* parameter be threaded through readers, writers, and folder resolution without
|
|
8
|
+
* each call site re-deciding the shape.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Pragmatic SMTP address / UPN shape — deliberately not full RFC 5322. The
|
|
12
|
+
// point is to keep caller input inside a single Graph path segment, so only
|
|
13
|
+
// printable ASCII is accepted: RFC 5322 `atext` in the local part minus `#`
|
|
14
|
+
// (a URL fragment delimiter), and dot-separated letters/digits/hyphens in the
|
|
15
|
+
// domain. No whitespace, control characters, `/ ? # % \`, or non-ASCII
|
|
16
|
+
// look-alikes (full-width `/`, zero-width spaces). The tool schemas advertise
|
|
17
|
+
// an email address only, so bare user GUIDs are not accepted.
|
|
18
|
+
const MAILBOX_PATTERN =
|
|
19
|
+
/^[A-Za-z0-9.!$&'*+=^_`{|}~-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/;
|
|
20
|
+
|
|
21
|
+
const config = require('../config');
|
|
22
|
+
|
|
23
|
+
const SHARED_MAILBOX_DISABLED_MESSAGE =
|
|
24
|
+
'Shared-mailbox support is turned off. It is opt-in and work/school only: ' +
|
|
25
|
+
'set OUTLOOK_SHARED_MAILBOX=read (read) or OUTLOOK_SHARED_MAILBOX=true ' +
|
|
26
|
+
'(read and organise) in the MCP server environment, restart the server, then ' +
|
|
27
|
+
're-authenticate with `auth action=authenticate force=true` so the token ' +
|
|
28
|
+
'carries the shared-mailbox scopes.';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate a mailbox and build its Graph resource prefix, WITHOUT checking
|
|
32
|
+
* whether shared-mailbox support is enabled. Only for paths that worked
|
|
33
|
+
* before the opt-in flag existed (access-shared-mailbox's direct read).
|
|
34
|
+
* @param {string|null} [mailbox] - Shared mailbox email address, or null/empty for the signed-in user
|
|
35
|
+
* @returns {string} - `me` or `users/{mailbox}`
|
|
36
|
+
* @throws {Error} If `mailbox` is non-empty but not a plausible email address
|
|
37
|
+
*/
|
|
38
|
+
function validateMailboxPrefix(mailbox) {
|
|
39
|
+
const trimmed = typeof mailbox === 'string' ? mailbox.trim() : mailbox;
|
|
40
|
+
if (!trimmed) {
|
|
41
|
+
return 'me';
|
|
42
|
+
}
|
|
43
|
+
if (trimmed === 'me') {
|
|
44
|
+
return 'me';
|
|
45
|
+
}
|
|
46
|
+
if (!MAILBOX_PATTERN.test(trimmed)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid mailbox "${mailbox}" — expected a shared mailbox email address (e.g. "team@contoso.com").`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
// Return the address raw: encoding happens exactly once, in the Graph
|
|
52
|
+
// client (`callGraphAPI` / `callGraphAPIRaw` encode each path segment).
|
|
53
|
+
// Pre-encoding here double-encoded addresses like `team+archive@…` into
|
|
54
|
+
// `%252B`. The pattern above already confines the value to one segment.
|
|
55
|
+
return `users/${trimmed}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the Graph resource prefix for a mailbox. A non-`me` mailbox requires
|
|
60
|
+
* shared-mailbox support to be enabled (OUTLOOK_SHARED_MAILBOX).
|
|
61
|
+
* @param {string|null} [mailbox] - Shared mailbox email address, or null/empty for the signed-in user
|
|
62
|
+
* @returns {string} - `me` or `users/{mailbox}`
|
|
63
|
+
* @throws {Error} If `mailbox` is invalid, or shared-mailbox support is off
|
|
64
|
+
*/
|
|
65
|
+
function buildMailboxPrefix(mailbox) {
|
|
66
|
+
const prefix = validateMailboxPrefix(mailbox);
|
|
67
|
+
if (prefix !== 'me' && config.SHARED_MAILBOX_MODE === 'off') {
|
|
68
|
+
throw new Error(SHARED_MAILBOX_DISABLED_MESSAGE);
|
|
69
|
+
}
|
|
70
|
+
return prefix;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
buildMailboxPrefix,
|
|
75
|
+
validateMailboxPrefix,
|
|
76
|
+
SHARED_MAILBOX_DISABLED_MESSAGE,
|
|
77
|
+
};
|
|
@@ -467,20 +467,54 @@ function truncateText(text, maxLength) {
|
|
|
467
467
|
return `${text.substring(0, maxLength - 3)}...`;
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
+
const HTML_ENTITIES = {
|
|
471
|
+
nbsp: ' ',
|
|
472
|
+
amp: '&',
|
|
473
|
+
lt: '<',
|
|
474
|
+
gt: '>',
|
|
475
|
+
quot: '"',
|
|
476
|
+
apos: "'",
|
|
477
|
+
'#39': "'",
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Removes tags in one linear scan: `<…>` with no `<` inside is dropped
|
|
482
|
+
* whole, and any other `<` (nested fragments such as `<scr<script>ipt>`,
|
|
483
|
+
* or an unterminated tag) is dropped on its own, so no markup can
|
|
484
|
+
* reassemble. A scanner rather than a repeated regex replace keeps deeply
|
|
485
|
+
* nested input like `<<<…x…>>>` linear.
|
|
486
|
+
* @param {string} text - HTML fragment
|
|
487
|
+
* @returns {string} - Text with no `<` remaining
|
|
488
|
+
*/
|
|
489
|
+
function removeTags(text) {
|
|
490
|
+
let out = '';
|
|
491
|
+
let i = 0;
|
|
492
|
+
while (i < text.length) {
|
|
493
|
+
const lt = text.indexOf('<', i);
|
|
494
|
+
if (lt === -1) {
|
|
495
|
+
out += text.slice(i);
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
out += text.slice(i, lt);
|
|
499
|
+
// Scan only to the next '<' or '>', so each character is visited once.
|
|
500
|
+
let j = lt + 1;
|
|
501
|
+
while (j < text.length && text[j] !== '<' && text[j] !== '>') j++;
|
|
502
|
+
i = text[j] === '>' ? j + 1 : lt + 1;
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
470
507
|
/**
|
|
471
|
-
* Strips HTML tags (simple implementation)
|
|
508
|
+
* Strips HTML tags (simple implementation), in linear time.
|
|
509
|
+
* Entities are decoded after tag removal, in a single pass, so `&lt;`
|
|
510
|
+
* becomes `<`, not `<`.
|
|
472
511
|
*/
|
|
473
512
|
function stripHtml(html) {
|
|
474
513
|
if (!html) return '';
|
|
475
|
-
return
|
|
476
|
-
.replace(/<br\s*\/?>/gi, '\n')
|
|
477
|
-
|
|
478
|
-
.replace(
|
|
479
|
-
.replace(/ /g, ' ')
|
|
480
|
-
.replace(/&/g, '&')
|
|
481
|
-
.replace(/</g, '<')
|
|
482
|
-
.replace(/>/g, '>')
|
|
483
|
-
.replace(/"/g, '"')
|
|
514
|
+
return removeTags(
|
|
515
|
+
html.replace(/<br\s*\/?>/gi, '\n').replace(/<\/p>/gi, '\n\n')
|
|
516
|
+
)
|
|
517
|
+
.replace(/&(nbsp|amp|lt|gt|quot|apos|#39);/g, (_, e) => HTML_ENTITIES[e])
|
|
484
518
|
.replace(/\n{3,}/g, '\n\n')
|
|
485
519
|
.trim();
|
|
486
520
|
}
|