@littlebearapps/outlook-assistant 3.11.1 → 3.11.2
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/README.md +1 -1
- package/email/attachments.js +66 -2
- package/email/conversations.js +2 -10
- package/llms.txt +1 -1
- package/package.json +3 -3
- package/utils/graph-api.js +33 -0
- package/utils/response-formatter.js +44 -10
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ npx @littlebearapps/outlook-assistant
|
|
|
164
164
|
To check which version you have, or to see the available options:
|
|
165
165
|
|
|
166
166
|
```bash
|
|
167
|
-
outlook-assistant --version # prints e.g. 3.11.
|
|
167
|
+
outlook-assistant --version # prints e.g. 3.11.2
|
|
168
168
|
outlook-assistant --help # usage, options and key environment variables
|
|
169
169
|
```
|
|
170
170
|
|
package/email/attachments.js
CHANGED
|
@@ -10,6 +10,65 @@ const _config = require('../config'); // Reserved for future use
|
|
|
10
10
|
const { callGraphAPI } = require('../utils/graph-api');
|
|
11
11
|
const { ensureAuthenticated } = require('../auth');
|
|
12
12
|
|
|
13
|
+
const MAX_FILENAME_LENGTH = 200;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Reduce a sender-controlled attachment name to a safe basename.
|
|
17
|
+
* Strips any directory part (either separator), control and reserved
|
|
18
|
+
* characters, and leading dots, then caps the length while keeping the
|
|
19
|
+
* extension. Falls back to "attachment" when nothing usable remains.
|
|
20
|
+
* @param {string} name - Attachment name from Graph metadata
|
|
21
|
+
* @returns {string} - Filename safe to join onto an output directory
|
|
22
|
+
*/
|
|
23
|
+
function safeAttachmentFilename(name) {
|
|
24
|
+
const base = String(name || '')
|
|
25
|
+
.split(/[\\/]/)
|
|
26
|
+
.pop()
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
.replace(/[\x00-\x1f\x7f]/g, '')
|
|
29
|
+
.replace(/[<>:"|?*]/g, '_')
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/^\.+/, '');
|
|
32
|
+
|
|
33
|
+
if (!base) return 'attachment';
|
|
34
|
+
if (base.length <= MAX_FILENAME_LENGTH) return base;
|
|
35
|
+
|
|
36
|
+
const ext = path.extname(base).slice(0, 20);
|
|
37
|
+
return base.slice(0, MAX_FILENAME_LENGTH - ext.length) + ext;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write a buffer into outputDir without ever overwriting an existing entry
|
|
42
|
+
* or following a symlink: `wx` fails on any existing path (including a
|
|
43
|
+
* dangling symlink), so collisions get a numbered suffix instead.
|
|
44
|
+
* @param {string} outputDir - Target directory
|
|
45
|
+
* @param {string} filename - Safe basename from safeAttachmentFilename
|
|
46
|
+
* @param {Buffer} buffer - File contents
|
|
47
|
+
* @returns {string} - Absolute path actually written
|
|
48
|
+
*/
|
|
49
|
+
function writeUniqueFile(outputDir, filename, buffer) {
|
|
50
|
+
const root = path.resolve(outputDir);
|
|
51
|
+
const ext = path.extname(filename);
|
|
52
|
+
const stem = filename.slice(0, filename.length - ext.length);
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < 1000; i++) {
|
|
55
|
+
const candidate = path.join(
|
|
56
|
+
root,
|
|
57
|
+
i === 0 ? filename : `${stem}-${i}${ext}`
|
|
58
|
+
);
|
|
59
|
+
if (path.dirname(candidate) !== root) {
|
|
60
|
+
throw new Error('Refusing to write attachment outside outputDir');
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
fs.writeFileSync(candidate, buffer, { flag: 'wx' });
|
|
64
|
+
return candidate;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error.code !== 'EEXIST') throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Too many files named ${filename} in ${root}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
13
72
|
/**
|
|
14
73
|
* List attachments for a specific email
|
|
15
74
|
* @param {object} args - Tool arguments
|
|
@@ -177,13 +236,18 @@ async function handleDownloadAttachment(args) {
|
|
|
177
236
|
// of cwd so attachments don't silently land in the source tree
|
|
178
237
|
// when the caller forgets to pass outputDir. Auto-create the
|
|
179
238
|
// target directory.
|
|
239
|
+
// The filename is sender-controlled (GHSA-755c-c45g-69rv): reduce it
|
|
240
|
+
// to a safe basename and never overwrite or follow a symlink.
|
|
180
241
|
const outputDir = savePath || os.tmpdir();
|
|
181
242
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
182
|
-
const outputPath = path.join(outputDir, filename);
|
|
183
243
|
|
|
184
244
|
// Decode base64 and save to file
|
|
185
245
|
const buffer = Buffer.from(contentBytes, 'base64');
|
|
186
|
-
|
|
246
|
+
const outputPath = writeUniqueFile(
|
|
247
|
+
outputDir,
|
|
248
|
+
safeAttachmentFilename(filename),
|
|
249
|
+
buffer
|
|
250
|
+
);
|
|
187
251
|
|
|
188
252
|
const sizeKB = (buffer.length / 1024).toFixed(1);
|
|
189
253
|
|
package/email/conversations.js
CHANGED
|
@@ -15,6 +15,7 @@ const { getEmailFields } = require('../utils/field-presets');
|
|
|
15
15
|
const {
|
|
16
16
|
formatEmailContent,
|
|
17
17
|
formatEmailsAsCSV,
|
|
18
|
+
stripHtml,
|
|
18
19
|
VERBOSITY,
|
|
19
20
|
} = require('../utils/response-formatter');
|
|
20
21
|
// Note: buildFromFilter/buildToFilter from search.js use OData $filter which causes
|
|
@@ -542,16 +543,7 @@ async function handleExportConversation(args) {
|
|
|
542
543
|
// Body content
|
|
543
544
|
if (msg.body?.content) {
|
|
544
545
|
if (msg.body.contentType === 'html') {
|
|
545
|
-
|
|
546
|
-
const text = msg.body.content
|
|
547
|
-
.replace(/<br\s*\/?>/gi, '\n')
|
|
548
|
-
.replace(/<\/p>/gi, '\n\n')
|
|
549
|
-
.replace(/<[^>]+>/g, '')
|
|
550
|
-
.replace(/ /g, ' ')
|
|
551
|
-
.replace(/</g, '<')
|
|
552
|
-
.replace(/>/g, '>')
|
|
553
|
-
.replace(/&/g, '&');
|
|
554
|
-
mdContent.push(text.trim());
|
|
546
|
+
mdContent.push(stripHtml(msg.body.content));
|
|
555
547
|
} else {
|
|
556
548
|
mdContent.push(msg.body.content);
|
|
557
549
|
}
|
package/llms.txt
CHANGED
|
@@ -79,6 +79,6 @@ Requires an Azure app registration with Microsoft Graph delegated permissions. S
|
|
|
79
79
|
- [FAQ](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/docs/faq/faq.md): Frequently asked questions — install, accounts, permissions, tokens, send safety, updates, uninstall (also at <https://littlebearapps.com/help/outlook-assistant/faq/>)
|
|
80
80
|
- [CLAUDE.md](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CLAUDE.md): Quick reference for development
|
|
81
81
|
- [CONTRIBUTING](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CONTRIBUTING.md): Contribution guidelines
|
|
82
|
-
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
|
|
82
|
+
- [CHANGELOG](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/CHANGELOG.md): Version history (current: 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), HTML-to-text entity double-decoding fixed, `npm audit` at 0. Preceded by v3.11.1 — search and export correctness: a search term combined with a date or boolean filter was silently overwritten, so the request carried only the date window and the whole window came back reported as a filtered result; batch export named files `<date>_<subject>`, so a same-day reply chain overwrote itself on disk while the summary reported `Failed 0` — filenames now carry the time, collisions get a numeric suffix instead of clobbering, and a manifest maps each requested ID to the file actually written; a truncated local scan is now disclosed when it matched, not only when it returned nothing; `query` versus `searchExpression` and the 500-message `to` scan cap documented. Preceded by v3.11.0 — fixes & polish: `--version`/`--help` CLI flags (#68), `AADSTS7000215` explaining the Secret ID vs Secret Value mistake via one shared AADSTS hint table (#69), token-refresh round trip covered end to end (#72), and all 17 development-dependency advisories cleared; and v3.10.0 — search correctness: field-scoped `searchExpression` translated to OData filters and retried on personal accounts (#217), two-filter searches no longer returning the single-filter superset (#229), `from`/`to` filter values OData-escaped (#230), no-results guidance derived from what was actually attempted (#231))
|
|
83
83
|
- [ROADMAP](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/ROADMAP.md): Active milestones (v3.11.2 tool description audit, v3.8.x carry-over, v3.12.0+ new Graph APIs)
|
|
84
84
|
- [SECURITY](https://raw.githubusercontent.com/littlebearapps/outlook-assistant/main/SECURITY.md): Security policy, token handling, and MCP safety controls
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@littlebearapps/outlook-assistant",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.2",
|
|
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",
|
|
@@ -100,8 +100,8 @@
|
|
|
100
100
|
"minimatch": ">=3.1.3",
|
|
101
101
|
"hono": "^4.13.7",
|
|
102
102
|
"@hono/node-server": "^1.19.17",
|
|
103
|
-
"fast-uri": "^3.1.
|
|
104
|
-
"ip-address": "^10.7.
|
|
103
|
+
"fast-uri": "^3.1.8",
|
|
104
|
+
"ip-address": "^10.7.2",
|
|
105
105
|
"qs": "^6.16.0",
|
|
106
106
|
"body-parser": "^2.3.0"
|
|
107
107
|
}
|
package/utils/graph-api.js
CHANGED
|
@@ -5,6 +5,38 @@ 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
|
+
|
|
8
40
|
/**
|
|
9
41
|
* Makes a request to the Microsoft Graph API
|
|
10
42
|
* In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
|
|
@@ -36,6 +68,7 @@ async function callGraphAPI(
|
|
|
36
68
|
let finalUrl;
|
|
37
69
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
38
70
|
// Path is already a full URL (from pagination nextLink)
|
|
71
|
+
assertGraphUrl(path);
|
|
39
72
|
finalUrl = path;
|
|
40
73
|
} else {
|
|
41
74
|
// Build URL from path and queryParams
|
|
@@ -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
|
}
|