@ni-c/imap-mcp 0.4.0 → 0.5.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/README.md +15 -6
- package/dist/analyze.d.ts +1 -0
- package/dist/analyze.js +54 -31
- package/dist/attachments.d.ts +1 -0
- package/dist/attachments.js +51 -4
- package/dist/config.d.ts +7 -0
- package/dist/config.js +126 -13
- package/dist/extract/ooxml.js +24 -17
- package/dist/imap.d.ts +48 -2
- package/dist/imap.js +133 -28
- package/dist/message.d.ts +11 -0
- package/dist/message.js +26 -3
- package/dist/resources.js +6 -2
- package/dist/result.js +20 -3
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +2 -0
- package/dist/server.js +15 -0
- package/dist/tools/read.js +67 -13
- package/dist/tools/write.js +8 -2
- package/package.json +9 -7
- package/dist/analyze.js.map +0 -1
- package/dist/attachments.js.map +0 -1
- package/dist/audit.js.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/download.js.map +0 -1
- package/dist/draft.js.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/extract/child.js.map +0 -1
- package/dist/extract/index.js.map +0 -1
- package/dist/extract/ooxml.js.map +0 -1
- package/dist/extract/pdf.js.map +0 -1
- package/dist/extract/types.js.map +0 -1
- package/dist/imap.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/message.js.map +0 -1
- package/dist/output-schema.js.map +0 -1
- package/dist/resources.js.map +0 -1
- package/dist/result.js.map +0 -1
- package/dist/schema.js.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/stream.js.map +0 -1
- package/dist/tools/annotations.js.map +0 -1
- package/dist/tools/catalogue.js.map +0 -1
- package/dist/tools/read.js.map +0 -1
- package/dist/tools/write.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
# imap-mcp
|
|
2
2
|
|
|
3
|
+
<!-- badges: start -->
|
|
4
|
+
|
|
3
5
|
[](https://github.com/ni-c/imap-mcp/actions/workflows/ci.yml)
|
|
6
|
+
[](https://scorecard.dev/viewer/?uri=github.com/ni-c/imap-mcp)
|
|
7
|
+
<a href="https://socket.dev/npm/package/@ni-c/imap-mcp"><img src="https://socket.dev/api/badge/npm/package/@ni-c/imap-mcp" alt="Socket supply-chain report" height="20"></a>
|
|
8
|
+
[](https://glama.ai/mcp/servers/ni-c/imap-mcp)
|
|
9
|
+
<br>
|
|
4
10
|
[](https://www.npmjs.com/package/@ni-c/imap-mcp)
|
|
5
|
-
[](https://imap-mcp.ni-c.de)
|
|
10
|
-
[](https://mcp-hub.ni-c.de)
|
|
11
|
+
[](https://github.com/ni-c/imap-mcp/pkgs/container/imap-mcp)
|
|
12
|
+
[](https://mcp-hub.ni-c.de)
|
|
13
|
+
<br>
|
|
14
|
+
[](https://imap-mcp.ni-c.de)
|
|
11
15
|
[](https://github.com/sponsors/ni-c)
|
|
16
|
+
<!-- badges: end -->
|
|
12
17
|
|
|
13
18
|
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for any IMAP
|
|
14
19
|
mailbox. It speaks IMAP rather than one vendor's API, so it works with whatever provider you
|
|
@@ -144,6 +149,10 @@ IMAP_ALLOW_TOOLS=list_new_messages,get_message,move_messages
|
|
|
144
149
|
IMAP_DENY_TOOLS=delete_messages
|
|
145
150
|
```
|
|
146
151
|
|
|
152
|
+
One boundary the list cannot draw: `move_messages` copies as well as moves (`mode: "copy"`),
|
|
153
|
+
and the two are one tool. Denying `move_messages` removes both; there is no way to keep moving
|
|
154
|
+
and forbid copying, or the other way round. Both modes ask for confirmation.
|
|
155
|
+
|
|
147
156
|
An entry that matches no tool aborts startup and names it, so a typo cannot silently hide a
|
|
148
157
|
tool — an absent tool is not something anyone traces back to an environment variable. A
|
|
149
158
|
filtered tool is never registered, so it is absent from `tools/list` and unknown to
|
package/dist/analyze.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export interface SecurityAssessment {
|
|
|
61
61
|
* and the fencing in {@link wrapUntrusted} is what carries the weight.
|
|
62
62
|
*/
|
|
63
63
|
export declare function htmlToText(html: string, maxChars?: number): string;
|
|
64
|
+
export declare function decodeCharacterReferences(text: string): string;
|
|
64
65
|
/**
|
|
65
66
|
* Removes the characters a human reader cannot see but the model can.
|
|
66
67
|
*
|
package/dist/analyze.js
CHANGED
|
@@ -338,38 +338,46 @@ export function htmlToText(html, maxChars = MAX_HTML_CHARS) {
|
|
|
338
338
|
}
|
|
339
339
|
i = gt + 1;
|
|
340
340
|
}
|
|
341
|
-
return (out
|
|
342
|
-
.join('')
|
|
343
|
-
.replace(/ /gi, ' ')
|
|
344
|
-
.replace(/</gi, '<')
|
|
345
|
-
.replace(/>/gi, '>')
|
|
346
|
-
.replace(/"/gi, '"')
|
|
347
|
-
.replace(/'/g, "'")
|
|
348
|
-
// Numeric character references. Common in HTML mail (`’` for a
|
|
349
|
-
// curly apostrophe) and near-universal in OOXML, where a German document
|
|
350
|
-
// may write every umlaut this way — without this they arrive as literal
|
|
351
|
-
// `ä`. Bounded digit counts so the pattern cannot be made to scan,
|
|
352
|
-
// and out-of-range values produce nothing rather than a guess.
|
|
353
|
-
.replace(/&#x([0-9a-f]{1,6});/gi, (match, hex) => fromCodePoint(parseInt(hex, 16), match))
|
|
354
|
-
.replace(/&#(\d{1,7});/g, (match, digits) => fromCodePoint(Number(digits), match))
|
|
355
|
-
// Last, so a decoded `&lt;` does not turn into a `<` the caller never
|
|
356
|
-
// received.
|
|
357
|
-
.replace(/&/gi, '&'));
|
|
341
|
+
return decodeCharacterReferences(out.join(''));
|
|
358
342
|
}
|
|
359
343
|
/**
|
|
360
|
-
*
|
|
344
|
+
* The character references a mail client decodes, decoded the way it does.
|
|
361
345
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
346
|
+
* One alternation, one pass. It used to be six `replace` calls in sequence,
|
|
347
|
+
* and a sequence decodes twice: `&#104;` became `h` in the hex pass
|
|
348
|
+
* and `h` in the decimal pass, a character no client ever shows. And the digit
|
|
349
|
+
* runs were bounded (`{1,7}`) with the semicolon required, while the HTML
|
|
350
|
+
* tokenizer reads *every* digit and takes the semicolon as optional — so
|
|
351
|
+
* `h` reached the model as eleven literal characters and the
|
|
352
|
+
* recipient as an `h`. Here a numeric reference is any digit run, with or
|
|
353
|
+
* without its semicolon; the named ones are the five HTML mail and OOXML use.
|
|
354
|
+
*
|
|
355
|
+
* Out-of-range values, zero and surrogates decode to U+FFFD, which is what a
|
|
356
|
+
* browser renders — a character, and not `''`, which would make `�` an
|
|
357
|
+
* invisible separator inside a word.
|
|
367
358
|
*/
|
|
368
|
-
|
|
359
|
+
const CHARACTER_REFERENCE = /&(?:#[xX]([0-9a-fA-F]+);?|#([0-9]+);?|(nbsp|lt|gt|quot|amp|apos);)/g;
|
|
360
|
+
export function decodeCharacterReferences(text) {
|
|
361
|
+
return text.replace(CHARACTER_REFERENCE, (_match, hex, decimal, name) => hex !== undefined
|
|
362
|
+
? fromCodePoint(parseInt(hex, 16))
|
|
363
|
+
: decimal !== undefined
|
|
364
|
+
? fromCodePoint(Number(decimal))
|
|
365
|
+
: (NAMED_REFERENCES.get(String(name).toLowerCase()) ?? _match));
|
|
366
|
+
}
|
|
367
|
+
const NAMED_REFERENCES = new Map([
|
|
368
|
+
['nbsp', ' '],
|
|
369
|
+
['lt', '<'],
|
|
370
|
+
['gt', '>'],
|
|
371
|
+
['quot', '"'],
|
|
372
|
+
['amp', '&'],
|
|
373
|
+
['apos', "'"],
|
|
374
|
+
]);
|
|
375
|
+
/** One character from a numeric reference, U+FFFD where no client has one. */
|
|
376
|
+
function fromCodePoint(value) {
|
|
369
377
|
if (!Number.isInteger(value) || value < 1 || value > 0x10ffff)
|
|
370
|
-
return
|
|
378
|
+
return String.fromCodePoint(0xfffd);
|
|
371
379
|
if (value >= 0xd800 && value <= 0xdfff)
|
|
372
|
-
return
|
|
380
|
+
return String.fromCodePoint(0xfffd);
|
|
373
381
|
return String.fromCodePoint(value);
|
|
374
382
|
}
|
|
375
383
|
/**
|
|
@@ -392,8 +400,13 @@ export function stripInvisible(input) {
|
|
|
392
400
|
* not the `Archive` they know.
|
|
393
401
|
*/
|
|
394
402
|
export function escapeInvisible(input) {
|
|
395
|
-
|
|
396
|
-
|
|
403
|
+
return input
|
|
404
|
+
.replace(INVISIBLE_CHARS, escapeCodePoint)
|
|
405
|
+
.replace(CONTROL_CHARS, escapeCodePoint);
|
|
406
|
+
}
|
|
407
|
+
/** One character as a Unicode escape, for `escapeInvisible`. */
|
|
408
|
+
function escapeCodePoint(match) {
|
|
409
|
+
return `\\u${match.codePointAt(0).toString(16).padStart(4, '0')}`;
|
|
397
410
|
}
|
|
398
411
|
/**
|
|
399
412
|
* Normalises text before it reaches the model: Unicode-folded, stripped of the
|
|
@@ -414,9 +427,13 @@ export function sanitizeText(input, maxChars = MAX_BODY_CHARS) {
|
|
|
414
427
|
.replace(/[ \t]+/g, ' ')
|
|
415
428
|
.replace(/\n{3,}/g, '\n\n')
|
|
416
429
|
.trim();
|
|
417
|
-
|
|
430
|
+
// `toWellFormed` after the cut, which can split a surrogate pair — and on
|
|
431
|
+
// the whole string either way, because a lone surrogate can arrive decoded
|
|
432
|
+
// out of a header. JSON carries one as an escape and a Python client then
|
|
433
|
+
// fails to encode it; U+FFFD is the honest rendering.
|
|
434
|
+
return (normalized.length > maxChars
|
|
418
435
|
? `${normalized.slice(0, maxChars)}\n… (truncated at ${maxChars} characters)`
|
|
419
|
-
: normalized;
|
|
436
|
+
: normalized).toWellFormed();
|
|
420
437
|
}
|
|
421
438
|
/**
|
|
422
439
|
* Names of the injection shapes present in `text`.
|
|
@@ -490,7 +507,13 @@ export function parseAuthResults(header, trustedAuthservId) {
|
|
|
490
507
|
const match = new RegExp(`\\b${name}=([a-z]+)`, 'i').exec(topmost);
|
|
491
508
|
return match?.[1]?.toLowerCase() ?? 'unknown';
|
|
492
509
|
};
|
|
493
|
-
|
|
510
|
+
// Bounded like a hostname, which is what an authserv-id is. Unbounded, the
|
|
511
|
+
// id went into the metadata block beside the fence — the one part of a
|
|
512
|
+
// `get_message` answer with its own budget and nothing array-shaped to
|
|
513
|
+
// shrink — and a header of sixty thousand letters made the whole message
|
|
514
|
+
// unreadable through this server. The verdict is what matters; an id past
|
|
515
|
+
// this length is not an id, and the header is reported as forgeable.
|
|
516
|
+
const authservId = /^\s*([A-Za-z0-9._-]{1,253})(?![A-Za-z0-9._-])/.exec(topmost ?? '')?.[1];
|
|
494
517
|
return {
|
|
495
518
|
spf: read('spf'),
|
|
496
519
|
dkim: read('dkim'),
|
package/dist/attachments.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { MessageStructureObject } from 'imapflow';
|
|
|
10
10
|
* about reading the two declarations side by side says which is which.
|
|
11
11
|
*/
|
|
12
12
|
export declare const EXECUTABLE_EXTENSIONS: Set<string>;
|
|
13
|
+
export declare function isMediaType(value: string): boolean;
|
|
13
14
|
export interface AttachmentCandidate {
|
|
14
15
|
partId: string;
|
|
15
16
|
filename: string;
|
package/dist/attachments.js
CHANGED
|
@@ -117,6 +117,22 @@ export const EXECUTABLE_EXTENSIONS = new Set([
|
|
|
117
117
|
* extensions render it as `invoice.pdf`.
|
|
118
118
|
*/
|
|
119
119
|
const DOUBLE_EXTENSION_BAIT = /\.(pdf|docx?|xlsx?|pptx?|txt|csv|jpe?g|png|gif|zip|rtf|odt|ods)\.[a-z0-9]{1,5}$/i;
|
|
120
|
+
/**
|
|
121
|
+
* The shape of a media type — RFC 6838's token characters, bounded on both
|
|
122
|
+
* sides of the slash.
|
|
123
|
+
*
|
|
124
|
+
* The declared type of a part is the sender's string, and it used to travel
|
|
125
|
+
* as it came: into `content_type`, into the refusal note that quotes it, and
|
|
126
|
+
* from there into the `get_message` metadata block outside the fence and into
|
|
127
|
+
* error results the budget never measures. A hundred kilobytes of it in one
|
|
128
|
+
* `Content-Type` header made the message unreadable. Anything that does not
|
|
129
|
+
* match here is not a media type, is never on the allowlist, and is reported
|
|
130
|
+
* as `application/octet-stream` with a note that says so without quoting it.
|
|
131
|
+
*/
|
|
132
|
+
const MEDIA_TYPE = /^[a-z0-9!#$&^_.+-]{1,127}\/[a-z0-9!#$&^_.+-]{1,127}$/;
|
|
133
|
+
export function isMediaType(value) {
|
|
134
|
+
return MEDIA_TYPE.test(value);
|
|
135
|
+
}
|
|
120
136
|
/**
|
|
121
137
|
* Strips a filename down to something safe to print and to reason about.
|
|
122
138
|
*
|
|
@@ -127,11 +143,30 @@ const DOUBLE_EXTENSION_BAIT = /\.(pdf|docx?|xlsx?|pptx?|txt|csv|jpe?g|png|gif|zi
|
|
|
127
143
|
export function sanitizeFilename(raw) {
|
|
128
144
|
if (raw === undefined || raw.trim() === '')
|
|
129
145
|
return '(unnamed)';
|
|
146
|
+
// Two orderings matter here, and both were wrong.
|
|
147
|
+
//
|
|
148
|
+
// The trim runs *before* the leading dots are taken off, and again after. The
|
|
149
|
+
// other way round, one space defeated the rule: ` .bashrc` has no dot in
|
|
150
|
+
// first position when the strip runs, and the trim that followed exposed it,
|
|
151
|
+
// so the name reached the model looking like a dotfile after all.
|
|
152
|
+
//
|
|
153
|
+
// And the strip never takes the dot that carries the extension. It used to:
|
|
154
|
+
// `.exe` came out as `exe`, `extensionOf` then answered `''`, and an empty
|
|
155
|
+
// extension makes `checkPolicy` skip the executable check entirely rather
|
|
156
|
+
// than fail it — so the blocklist was bypassed by naming the attachment
|
|
157
|
+
// `.exe`. That is the same failure `appref-ms` caused, reached by a different
|
|
158
|
+
// route. The lookahead keeps the leading dots whenever removing them would
|
|
159
|
+
// consume the last one; a name that has another dot further along loses them
|
|
160
|
+
// as before.
|
|
130
161
|
const cleaned = defuseAutoFetch(stripInvisible(raw.normalize('NFKC')))
|
|
131
162
|
.replace(/[/\\]/g, '_')
|
|
132
|
-
.
|
|
163
|
+
.trim()
|
|
164
|
+
.replace(/^\.+(?=.*\.)/, '')
|
|
133
165
|
.trim();
|
|
134
|
-
|
|
166
|
+
// A name of nothing but dots is not a name. It carries no extension either,
|
|
167
|
+
// so keeping it buys the check above nothing and only puts `...` in front of
|
|
168
|
+
// a reader.
|
|
169
|
+
if (cleaned === '' || /^\.+$/.test(cleaned))
|
|
135
170
|
return '(unnamed)';
|
|
136
171
|
return cleaned.length > MAX_FILENAME_LENGTH
|
|
137
172
|
? `${cleaned.slice(0, MAX_FILENAME_LENGTH)}…`
|
|
@@ -178,7 +213,10 @@ function walk(node, depth, found) {
|
|
|
178
213
|
walk(child, depth + 1, found);
|
|
179
214
|
return;
|
|
180
215
|
}
|
|
181
|
-
const
|
|
216
|
+
const declaredType = (node.type ?? 'application/octet-stream').toLowerCase();
|
|
217
|
+
const type = isMediaType(declaredType)
|
|
218
|
+
? declaredType
|
|
219
|
+
: 'application/octet-stream';
|
|
182
220
|
const disposition = node.disposition?.toLowerCase();
|
|
183
221
|
const declaredName = node.dispositionParameters?.filename ?? node.parameters?.name;
|
|
184
222
|
// A part is an attachment when it says so, or when it carries a filename, or
|
|
@@ -191,6 +229,9 @@ function walk(node, depth, found) {
|
|
|
191
229
|
return;
|
|
192
230
|
const filename = sanitizeFilename(declaredName);
|
|
193
231
|
const notes = [];
|
|
232
|
+
if (type !== declaredType) {
|
|
233
|
+
notes.push('the declared content type is not a valid media type and is reported as application/octet-stream');
|
|
234
|
+
}
|
|
194
235
|
if (declaredName !== undefined && DOUBLE_EXTENSION_BAIT.test(declaredName)) {
|
|
195
236
|
notes.push('filename has a double extension — it renders as a document but is not one');
|
|
196
237
|
}
|
|
@@ -198,7 +239,13 @@ function walk(node, depth, found) {
|
|
|
198
239
|
partId: node.part,
|
|
199
240
|
filename,
|
|
200
241
|
contentType: type,
|
|
201
|
-
size
|
|
242
|
+
// imapflow reads the size with `Number(value) || 0`, so a server that
|
|
243
|
+
// writes `1e400` hands over Infinity — which JSON renders as null, and
|
|
244
|
+
// null fails the `size: number` the output schema promises for the whole
|
|
245
|
+
// listing. A size that is not a safe non-negative integer is unknown.
|
|
246
|
+
size: Number.isSafeInteger(node.size) && node.size >= 0
|
|
247
|
+
? node.size
|
|
248
|
+
: undefined,
|
|
202
249
|
disposition,
|
|
203
250
|
allowed: true,
|
|
204
251
|
notes,
|
package/dist/config.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface ImapConfig {
|
|
|
31
31
|
* Where attachments may be written. Unset means this server never touches the
|
|
32
32
|
* filesystem — setting it is the opt-in, and it is the only source of the
|
|
33
33
|
* target directory. A caller cannot choose where bytes from a stranger land.
|
|
34
|
+
*
|
|
35
|
+
* Stored as the resolved real path of a directory that existed at startup.
|
|
36
|
+
* The value is printed by `get_server_info` and by every attachment listing,
|
|
37
|
+
* and `IMAP_DOWNLOAD_DIR` sits a few lines below `IMAP_PASSWORD` in every
|
|
38
|
+
* compose file — so a value that is not a directory is refused before it can
|
|
39
|
+
* be printed anywhere, and the refusal describes it by length, never by
|
|
40
|
+
* content.
|
|
34
41
|
*/
|
|
35
42
|
downloadDir: string | undefined;
|
|
36
43
|
maxDownloadBytes: number;
|
package/dist/config.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { isMediaType } from './attachments.js';
|
|
3
|
+
import { MAILBOX_CONTROL_CHARS } from './schema.js';
|
|
1
4
|
export const DEFAULT_ATTACHMENT_TYPES = [
|
|
2
5
|
'application/pdf',
|
|
3
6
|
'application/json',
|
|
@@ -36,6 +39,18 @@ const DEFAULT_MAX_EXTRACT_BYTES = 10 * 1024 * 1024;
|
|
|
36
39
|
*/
|
|
37
40
|
const MAX_MAX_EXTRACT_BYTES = 64 * 1024 * 1024;
|
|
38
41
|
const DEFAULT_SEEN_KEYWORD = 'AiSeen';
|
|
42
|
+
/** A hostname is at most 253 characters; an IPv6 literal far fewer. */
|
|
43
|
+
const MAX_HOST_LENGTH = 253;
|
|
44
|
+
/** IMAP allows 255 bytes of mailbox name; the tool parameter says the same. */
|
|
45
|
+
const MAX_MAILBOX_LENGTH = 255;
|
|
46
|
+
/** Matches the `keyword` tool parameter, which is the other place one is typed. */
|
|
47
|
+
const MAX_KEYWORD_LENGTH = 64;
|
|
48
|
+
const MAX_ATTACHMENT_TYPES = 64;
|
|
49
|
+
/**
|
|
50
|
+
* The rule the `mailbox` tool parameter applies, imported rather than spelled
|
|
51
|
+
* again: a second copy of a control-character class is how two of them drift.
|
|
52
|
+
*/
|
|
53
|
+
const CONTROL_CHARS = MAILBOX_CONTROL_CHARS;
|
|
39
54
|
/** Shown when the configuration is incomplete — at startup and on every call. */
|
|
40
55
|
export function missingConfigMessage(missing) {
|
|
41
56
|
return (`missing required environment variable(s): ${missing.join(', ')}\n` +
|
|
@@ -78,13 +93,20 @@ export function loadConfig(env = process.env) {
|
|
|
78
93
|
const elicitation = parseElicitation(env.ELICITATION);
|
|
79
94
|
if (host !== undefined)
|
|
80
95
|
assertSafeHost(host, 'IMAP_HOST');
|
|
96
|
+
// A user name is written into a LOGIN command and into the From header of
|
|
97
|
+
// every draft. Neither tolerates a line break, and neither is a place for a
|
|
98
|
+
// value that was meant for the line above it.
|
|
99
|
+
if (user !== undefined)
|
|
100
|
+
assertSingleLine(user, 'IMAP_USER');
|
|
101
|
+
const mailbox = env.IMAP_MAILBOX || 'INBOX';
|
|
102
|
+
assertMailboxName(mailbox, 'IMAP_MAILBOX');
|
|
81
103
|
const draftsMailbox = env.IMAP_DRAFTS_MAILBOX;
|
|
82
104
|
if (draftsMailbox !== undefined) {
|
|
83
|
-
|
|
105
|
+
assertMailboxName(draftsMailbox, 'IMAP_DRAFTS_MAILBOX');
|
|
84
106
|
}
|
|
85
107
|
const trustedAuthservId = env.IMAP_TRUSTED_AUTHSERV_ID?.trim() || undefined;
|
|
86
108
|
if (trustedAuthservId !== undefined) {
|
|
87
|
-
|
|
109
|
+
assertSafeHost(trustedAuthservId, 'IMAP_TRUSTED_AUTHSERV_ID');
|
|
88
110
|
}
|
|
89
111
|
const config = {
|
|
90
112
|
imap: {
|
|
@@ -94,14 +116,14 @@ export function loadConfig(env = process.env) {
|
|
|
94
116
|
password,
|
|
95
117
|
tls,
|
|
96
118
|
insecureTls: env.IMAP_INSECURE_TLS === 'true',
|
|
97
|
-
mailbox
|
|
119
|
+
mailbox,
|
|
98
120
|
seenKeyword: parseKeyword(env.IMAP_SEEN_KEYWORD),
|
|
99
121
|
draftsMailbox,
|
|
100
122
|
trustedAuthservId,
|
|
101
123
|
maxMessages: parseCount(env.IMAP_MAX_MESSAGES, DEFAULT_MAX_MESSAGES, 'IMAP_MAX_MESSAGES'),
|
|
102
124
|
maxAttachmentBytes: parseCount(env.IMAP_MAX_ATTACHMENT_BYTES, DEFAULT_MAX_ATTACHMENT_BYTES, 'IMAP_MAX_ATTACHMENT_BYTES'),
|
|
103
125
|
allowedAttachmentTypes: parseTypes(env.IMAP_ATTACHMENT_TYPES),
|
|
104
|
-
downloadDir: env.IMAP_DOWNLOAD_DIR,
|
|
126
|
+
downloadDir: parseDownloadDir(env.IMAP_DOWNLOAD_DIR),
|
|
105
127
|
maxDownloadBytes: parseCount(env.IMAP_MAX_DOWNLOAD_BYTES, DEFAULT_MAX_DOWNLOAD_BYTES, 'IMAP_MAX_DOWNLOAD_BYTES'),
|
|
106
128
|
maxExtractBytes: parseCount(env.IMAP_MAX_EXTRACT_BYTES, DEFAULT_MAX_EXTRACT_BYTES, 'IMAP_MAX_EXTRACT_BYTES', MAX_MAX_EXTRACT_BYTES),
|
|
107
129
|
},
|
|
@@ -159,10 +181,22 @@ export function parseElicitation(raw) {
|
|
|
159
181
|
return true;
|
|
160
182
|
if (value === 'false')
|
|
161
183
|
return false;
|
|
162
|
-
|
|
184
|
+
// Described, not quoted. The variable is unprefixed and sits in the same
|
|
185
|
+
// block as IMAP_PASSWORD in every compose file; what lands in it by mistake
|
|
186
|
+
// is exactly the value that must not be printed into the client's log.
|
|
187
|
+
console.error(`imap-mcp: ELICITATION must be "true" or "false" — got ${describeValue(raw ?? '')}. ` +
|
|
163
188
|
'Refusing to start rather than guess.');
|
|
164
189
|
process.exit(1);
|
|
165
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A configuration value for an error message: its length and nothing else.
|
|
193
|
+
*
|
|
194
|
+
* Every variable this file reads has a neighbour that is a secret, and the
|
|
195
|
+
* value that fails a shape check is the one most likely to be that neighbour.
|
|
196
|
+
*/
|
|
197
|
+
function describeValue(raw) {
|
|
198
|
+
return `a ${raw.length}-character value`;
|
|
199
|
+
}
|
|
166
200
|
function parsePort(raw, fallback, name) {
|
|
167
201
|
if (raw === undefined || raw === '')
|
|
168
202
|
return fallback;
|
|
@@ -200,19 +234,72 @@ function parseKeyword(raw) {
|
|
|
200
234
|
return DEFAULT_SEEN_KEYWORD;
|
|
201
235
|
if (raw === '')
|
|
202
236
|
return '';
|
|
203
|
-
|
|
204
|
-
|
|
237
|
+
// Bounded like the `keyword` tool parameter. The value is written into a
|
|
238
|
+
// tool description and into every `get_server_info` answer, so a length has
|
|
239
|
+
// to be a length and not whatever was pasted.
|
|
240
|
+
if (raw.length > MAX_KEYWORD_LENGTH || !/^[A-Za-z0-9$_.-]+$/.test(raw)) {
|
|
241
|
+
console.error('imap-mcp: IMAP_SEEN_KEYWORD must consist of letters, digits, $, _, . or -, ' +
|
|
242
|
+
`at most ${MAX_KEYWORD_LENGTH} of them (got ${describeValue(raw)})`);
|
|
205
243
|
process.exit(1);
|
|
206
244
|
}
|
|
207
245
|
return raw;
|
|
208
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* The attachment allowlist, one media type per entry.
|
|
249
|
+
*
|
|
250
|
+
* Every entry is answered back by `get_server_info` as `allowed_attachment_types`
|
|
251
|
+
* and compared against what messages declare. An entry that is not shaped like
|
|
252
|
+
* a media type can never match an attachment, so it is either a typo or a value
|
|
253
|
+
* meant for another variable — and in both cases the operator should hear
|
|
254
|
+
* about it at startup rather than read it in a tool result.
|
|
255
|
+
*/
|
|
209
256
|
function parseTypes(raw) {
|
|
210
257
|
if (raw === undefined || raw.trim() === '')
|
|
211
258
|
return DEFAULT_ATTACHMENT_TYPES;
|
|
212
|
-
|
|
259
|
+
const entries = raw
|
|
213
260
|
.split(',')
|
|
214
261
|
.map((t) => t.trim().toLowerCase())
|
|
215
262
|
.filter((t) => t !== '');
|
|
263
|
+
if (entries.length > MAX_ATTACHMENT_TYPES) {
|
|
264
|
+
console.error(`imap-mcp: IMAP_ATTACHMENT_TYPES lists ${entries.length} entries; at most ${MAX_ATTACHMENT_TYPES} are accepted`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
const bad = entries.findIndex((entry) => !isMediaType(entry));
|
|
268
|
+
if (bad >= 0) {
|
|
269
|
+
console.error(`imap-mcp: IMAP_ATTACHMENT_TYPES entry ${bad + 1} is not a media type ` +
|
|
270
|
+
`such as application/pdf (got ${describeValue(entries[bad])})`);
|
|
271
|
+
process.exit(1);
|
|
272
|
+
}
|
|
273
|
+
return entries;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The download directory, resolved and checked before anything can print it.
|
|
277
|
+
*
|
|
278
|
+
* The path is answered by `get_server_info` and by every attachment listing,
|
|
279
|
+
* so it has to be a path — an existing directory, resolved through symlinks so
|
|
280
|
+
* that the containment check in `download.ts` compares against the place files
|
|
281
|
+
* really land. A value that is not a directory ends the process, and the
|
|
282
|
+
* message says how long it was, not what it said.
|
|
283
|
+
*/
|
|
284
|
+
function parseDownloadDir(raw) {
|
|
285
|
+
if (raw === undefined)
|
|
286
|
+
return undefined;
|
|
287
|
+
const trimmed = raw.trim();
|
|
288
|
+
if (trimmed === '')
|
|
289
|
+
return undefined;
|
|
290
|
+
let resolved;
|
|
291
|
+
try {
|
|
292
|
+
resolved = realpathSync(trimmed);
|
|
293
|
+
if (!statSync(resolved).isDirectory())
|
|
294
|
+
throw new Error('not a directory');
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
console.error('imap-mcp: IMAP_DOWNLOAD_DIR must name an existing directory ' +
|
|
298
|
+
`(got ${describeValue(trimmed)} that does not resolve to one). ` +
|
|
299
|
+
'Create it first, or unset the variable to keep this server off the filesystem.');
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
return resolved;
|
|
216
303
|
}
|
|
217
304
|
/**
|
|
218
305
|
* Rejects anything that could break out of the line it is written on. IMAP is a
|
|
@@ -223,11 +310,13 @@ function assertSafeHost(value, name) {
|
|
|
223
310
|
// A hostname or IPv4 address — or an IPv6 address, which is the only place
|
|
224
311
|
// a colon is legal. Allowing ":" everywhere would silently accept
|
|
225
312
|
// "imap.example.net:993", which the error message promises to reject.
|
|
313
|
+
// The length is checked first: nothing below walks a value longer than a
|
|
314
|
+
// hostname can be.
|
|
226
315
|
const hostname = /^[A-Za-z0-9._-]+$/.test(value);
|
|
227
316
|
const ipv6 = /^\[?[0-9A-Fa-f:.]*:[0-9A-Fa-f:.]*\]?$/.test(value);
|
|
228
|
-
if (!hostname && !ipv6) {
|
|
317
|
+
if (value.length > MAX_HOST_LENGTH || (!hostname && !ipv6)) {
|
|
229
318
|
console.error(`imap-mcp: ${name} must be a plain hostname or IP address without ` +
|
|
230
|
-
|
|
319
|
+
`scheme, port, credentials or whitespace (got ${describeValue(value)})`);
|
|
231
320
|
process.exit(1);
|
|
232
321
|
}
|
|
233
322
|
}
|
|
@@ -238,6 +327,22 @@ function assertSingleLine(value, name) {
|
|
|
238
327
|
process.exit(1);
|
|
239
328
|
}
|
|
240
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* The rule the `mailbox` tool parameter enforces, for a name that arrives
|
|
332
|
+
* through the environment instead: bounded, no control characters, no LIST
|
|
333
|
+
* wildcards. The value is answered by every listing tool and printed on the
|
|
334
|
+
* startup line, so it has to look like a folder before it is printed anywhere.
|
|
335
|
+
*/
|
|
336
|
+
function assertMailboxName(value, name) {
|
|
337
|
+
if (value.length > MAX_MAILBOX_LENGTH ||
|
|
338
|
+
CONTROL_CHARS.test(value) ||
|
|
339
|
+
/[%*]/.test(value)) {
|
|
340
|
+
console.error(`imap-mcp: ${name} must be a mailbox name of at most ${MAX_MAILBOX_LENGTH} ` +
|
|
341
|
+
'characters without control characters or the wildcards % and * ' +
|
|
342
|
+
`(got ${describeValue(value)})`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
241
346
|
function isLoopbackHost(hostname) {
|
|
242
347
|
// URL.hostname keeps the brackets around an IPv6 literal, may carry a %zone
|
|
243
348
|
// suffix, and 'localhost.' with its root label is the same name as
|
|
@@ -245,11 +350,19 @@ function isLoopbackHost(hostname) {
|
|
|
245
350
|
// its bare '::1' branch could never match a hostname taken from a URL.
|
|
246
351
|
if (hostname === undefined)
|
|
247
352
|
return false;
|
|
248
|
-
|
|
353
|
+
let host = hostname
|
|
249
354
|
.toLowerCase()
|
|
250
355
|
.replace(/^\[|]$/g, '')
|
|
251
|
-
.replace(/%.*$/, '')
|
|
252
|
-
|
|
356
|
+
.replace(/%.*$/, '');
|
|
357
|
+
// Trailing root labels, walked from the end rather than matched with `\.+$`:
|
|
358
|
+
// that pattern is tried from every position of a run of dots and consumes
|
|
359
|
+
// the run each time, which is quadratic. The host is bounded to 253
|
|
360
|
+
// characters above, so this is a habit rather than a measured risk here —
|
|
361
|
+
// the same pattern on an unbounded value is the measured one.
|
|
362
|
+
let end = host.length;
|
|
363
|
+
while (end > 0 && host[end - 1] === '.')
|
|
364
|
+
end -= 1;
|
|
365
|
+
host = host.slice(0, end);
|
|
253
366
|
return (host === 'localhost' ||
|
|
254
367
|
host.endsWith('.localhost') ||
|
|
255
368
|
host.startsWith('127.') ||
|
package/dist/extract/ooxml.js
CHANGED
|
@@ -152,7 +152,7 @@ export async function extractZipDocument(kind, bytes, maxChars, toText) {
|
|
|
152
152
|
// the archive happens to list them.
|
|
153
153
|
const all = Object.keys(entries)
|
|
154
154
|
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
|
155
|
-
.
|
|
155
|
+
.toSorted((a, b) => slideNumber(a) - slideNumber(b));
|
|
156
156
|
if (all.length === 0)
|
|
157
157
|
return { ok: false, reason: 'not-a-document' };
|
|
158
158
|
const slides = all.slice(0, MAX_SLIDES);
|
|
@@ -213,7 +213,7 @@ function readXlsx(entries, strFromU8, maxChars) {
|
|
|
213
213
|
const order = sheetOrder(at('xl/workbook.xml'), at('xl/_rels/workbook.xml.rels'));
|
|
214
214
|
const available = Object.keys(entries)
|
|
215
215
|
.filter((name) => /^xl\/worksheets\/[^/]+\.xml$/.test(name))
|
|
216
|
-
.
|
|
216
|
+
.toSorted();
|
|
217
217
|
if (available.length === 0)
|
|
218
218
|
return undefined;
|
|
219
219
|
// The rels file is how a tab's name is tied to its file, and `sheet1.xml` is
|
|
@@ -590,7 +590,7 @@ function between(source, open, close) {
|
|
|
590
590
|
*/
|
|
591
591
|
function cell(value) {
|
|
592
592
|
const flat = value.replace(/[\t\r\n]+/g, ' ');
|
|
593
|
-
return flat.length > MAX_CELL_CHARS ? flat.slice(0, MAX_CELL_CHARS) : flat;
|
|
593
|
+
return (flat.length > MAX_CELL_CHARS ? flat.slice(0, MAX_CELL_CHARS) : flat).toWellFormed();
|
|
594
594
|
}
|
|
595
595
|
/**
|
|
596
596
|
* The five predefined XML entities and bounded numeric references.
|
|
@@ -601,27 +601,34 @@ function cell(value) {
|
|
|
601
601
|
* rather than defended-against attacks.
|
|
602
602
|
*/
|
|
603
603
|
function decodeEntities(value) {
|
|
604
|
-
return value
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
.replace(/&#x([0-9a-fA-F]{1,6});/g, (match, hex) => codePoint(parseInt(hex, 16), match))
|
|
610
|
-
.replace(/&#(\d{1,7});/g, (match, digits) => codePoint(Number(digits), match))
|
|
611
|
-
.replace(/&/g, '&');
|
|
604
|
+
return value.replace(ENTITY, (match, hex, decimal, name) => hex !== undefined
|
|
605
|
+
? codePoint(parseInt(hex, 16))
|
|
606
|
+
: decimal !== undefined
|
|
607
|
+
? codePoint(Number(decimal))
|
|
608
|
+
: (PREDEFINED.get(String(name)) ?? match));
|
|
612
609
|
}
|
|
613
610
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
611
|
+
* One alternation, one pass — the same shape as `decodeCharacterReferences`
|
|
612
|
+
* in `../analyze.ts`, and for the same reason: a sequence of `replace` calls
|
|
613
|
+
* decodes `&#104;` twice, and a bounded digit run leaves `h`
|
|
614
|
+
* standing where an XML parser reads an `h`. Duplicated rather than shared
|
|
617
615
|
* because this module is reached from the child, where a relative import of
|
|
618
616
|
* `../analyze.js` does not resolve.
|
|
619
617
|
*/
|
|
620
|
-
|
|
618
|
+
const ENTITY = /&(?:#[xX]([0-9a-fA-F]+);|#([0-9]+);|(lt|gt|quot|amp|apos);)/g;
|
|
619
|
+
const PREDEFINED = new Map([
|
|
620
|
+
['lt', '<'],
|
|
621
|
+
['gt', '>'],
|
|
622
|
+
['quot', '"'],
|
|
623
|
+
['amp', '&'],
|
|
624
|
+
['apos', "'"],
|
|
625
|
+
]);
|
|
626
|
+
/** One character from a numeric reference, U+FFFD where no parser has one. */
|
|
627
|
+
function codePoint(value) {
|
|
621
628
|
if (!Number.isInteger(value) || value < 1 || value > 0x10ffff)
|
|
622
|
-
return
|
|
629
|
+
return String.fromCodePoint(0xfffd);
|
|
623
630
|
if (value >= 0xd800 && value <= 0xdfff)
|
|
624
|
-
return
|
|
631
|
+
return String.fromCodePoint(0xfffd);
|
|
625
632
|
return String.fromCodePoint(value);
|
|
626
633
|
}
|
|
627
634
|
//# sourceMappingURL=ooxml.js.map
|