@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.
Files changed (45) hide show
  1. package/README.md +15 -6
  2. package/dist/analyze.d.ts +1 -0
  3. package/dist/analyze.js +54 -31
  4. package/dist/attachments.d.ts +1 -0
  5. package/dist/attachments.js +51 -4
  6. package/dist/config.d.ts +7 -0
  7. package/dist/config.js +126 -13
  8. package/dist/extract/ooxml.js +24 -17
  9. package/dist/imap.d.ts +48 -2
  10. package/dist/imap.js +133 -28
  11. package/dist/message.d.ts +11 -0
  12. package/dist/message.js +26 -3
  13. package/dist/resources.js +6 -2
  14. package/dist/result.js +20 -3
  15. package/dist/schema.d.ts +2 -0
  16. package/dist/schema.js +2 -0
  17. package/dist/server.js +15 -0
  18. package/dist/tools/read.js +67 -13
  19. package/dist/tools/write.js +8 -2
  20. package/package.json +9 -7
  21. package/dist/analyze.js.map +0 -1
  22. package/dist/attachments.js.map +0 -1
  23. package/dist/audit.js.map +0 -1
  24. package/dist/config.js.map +0 -1
  25. package/dist/download.js.map +0 -1
  26. package/dist/draft.js.map +0 -1
  27. package/dist/errors.js.map +0 -1
  28. package/dist/extract/child.js.map +0 -1
  29. package/dist/extract/index.js.map +0 -1
  30. package/dist/extract/ooxml.js.map +0 -1
  31. package/dist/extract/pdf.js.map +0 -1
  32. package/dist/extract/types.js.map +0 -1
  33. package/dist/imap.js.map +0 -1
  34. package/dist/index.js.map +0 -1
  35. package/dist/message.js.map +0 -1
  36. package/dist/output-schema.js.map +0 -1
  37. package/dist/resources.js.map +0 -1
  38. package/dist/result.js.map +0 -1
  39. package/dist/schema.js.map +0 -1
  40. package/dist/server.js.map +0 -1
  41. package/dist/stream.js.map +0 -1
  42. package/dist/tools/annotations.js.map +0 -1
  43. package/dist/tools/catalogue.js.map +0 -1
  44. package/dist/tools/read.js.map +0 -1
  45. 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
  [![CI](https://img.shields.io/github/actions/workflow/status/ni-c/imap-mcp/ci.yml?branch=main&label=CI)](https://github.com/ni-c/imap-mcp/actions/workflows/ci.yml)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ni-c/imap-mcp/badge)](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
+ [![Glama score](https://glama.ai/mcp/servers/ni-c/imap-mcp/badges/score.svg)](https://glama.ai/mcp/servers/ni-c/imap-mcp)
9
+ <br>
4
10
  [![npm version](https://img.shields.io/npm/v/%40ni-c%2Fimap-mcp)](https://www.npmjs.com/package/@ni-c/imap-mcp)
5
- [![npm downloads](https://img.shields.io/npm/dm/%40ni-c%2Fimap-mcp)](https://www.npmjs.com/package/@ni-c/imap-mcp)
6
- [![node](https://img.shields.io/node/v/%40ni-c%2Fimap-mcp)](https://nodejs.org)
7
- [![license](https://img.shields.io/npm/l/%40ni-c%2Fimap-mcp)](LICENSE)
8
- [![container](https://img.shields.io/badge/ghcr.io-ni--c%2Fimap--mcp-blue)](https://github.com/ni-c/imap-mcp/pkgs/container/imap-mcp)
9
- [![docs](https://img.shields.io/badge/docs-imap--mcp.ni--c.de-informational)](https://imap-mcp.ni-c.de)
10
- [![HTTP • via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-6f42c1)](https://mcp-hub.ni-c.de)
11
+ [![container image](https://img.shields.io/badge/ghcr.io-ni--c%2Fimap--mcp-4f46e5?logo=docker&logoColor=white)](https://github.com/ni-c/imap-mcp/pkgs/container/imap-mcp)
12
+ [![HTTP via mcp-hub](https://img.shields.io/badge/HTTP-via%20mcp--hub-4f46e5?logo=modelcontextprotocol&logoColor=white)](https://mcp-hub.ni-c.de)
13
+ <br>
14
+ [![docs](https://img.shields.io/badge/docs-imap--mcp.ni--c.de-4f46e5?logo=readthedocs&logoColor=white)](https://imap-mcp.ni-c.de)
11
15
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](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(/&nbsp;/gi, ' ')
344
- .replace(/&lt;/gi, '<')
345
- .replace(/&gt;/gi, '>')
346
- .replace(/&quot;/gi, '"')
347
- .replace(/&#39;/g, "'")
348
- // Numeric character references. Common in HTML mail (`&#8217;` 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
- // `&#228;`. 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 `&amp;lt;` does not turn into a `<` the caller never
356
- // received.
357
- .replace(/&amp;/gi, '&'));
341
+ return decodeCharacterReferences(out.join(''));
358
342
  }
359
343
  /**
360
- * One character from a numeric reference, or the reference itself.
344
+ * The character references a mail client decodes, decoded the way it does.
361
345
  *
362
- * Returning the original text for anything out of range is the conservative
363
- * half: a reference nobody can render is better left visible than turned into a
364
- * replacement character that reads as content. Surrogates are excluded because
365
- * a lone one is not a character and only makes the string harder to handle
366
- * downstream.
346
+ * One alternation, one pass. It used to be six `replace` calls in sequence,
347
+ * and a sequence decodes twice: `&#x26;#104;` became `&#104;` 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
+ * `&#0000000104;` 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 `&#0;` an
357
+ * invisible separator inside a word.
367
358
  */
368
- function fromCodePoint(value, original) {
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 original;
378
+ return String.fromCodePoint(0xfffd);
371
379
  if (value >= 0xd800 && value <= 0xdfff)
372
- return original;
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
- const escape = (match) => `\\u${match.codePointAt(0).toString(16).padStart(4, '0')}`;
396
- return input.replace(INVISIBLE_CHARS, escape).replace(CONTROL_CHARS, escape);
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
- return normalized.length > maxChars
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
- const authservId = /^\s*([A-Za-z0-9._-]+)/.exec(topmost ?? '')?.[1];
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'),
@@ -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;
@@ -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
- .replace(/^\.+/, '')
163
+ .trim()
164
+ .replace(/^\.+(?=.*\.)/, '')
133
165
  .trim();
134
- if (cleaned === '')
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 type = (node.type ?? 'application/octet-stream').toLowerCase();
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: node.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
- assertSingleLine(draftsMailbox, 'IMAP_DRAFTS_MAILBOX');
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
- assertSingleLine(trustedAuthservId, 'IMAP_TRUSTED_AUTHSERV_ID');
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: env.IMAP_MAILBOX || 'INBOX',
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
- console.error(`imap-mcp: ELICITATION must be "true" or "false" got "${raw}". ` +
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
- if (!/^[A-Za-z0-9$_.-]+$/.test(raw)) {
204
- console.error('imap-mcp: IMAP_SEEN_KEYWORD must consist of letters, digits, $, _, . or -');
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
- return raw
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
- 'scheme, port, credentials or whitespace');
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
- const host = hostname
353
+ let host = hostname
249
354
  .toLowerCase()
250
355
  .replace(/^\[|]$/g, '')
251
- .replace(/%.*$/, '')
252
- .replace(/\.+$/, '');
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.') ||
@@ -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
- .sort((a, b) => slideNumber(a) - slideNumber(b));
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
- .sort();
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
- .replace(/&lt;/g, '<')
606
- .replace(/&gt;/g, '>')
607
- .replace(/&quot;/g, '"')
608
- .replace(/&apos;/g, "'")
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(/&amp;/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
- * Same rule as `fromCodePoint` in `../analyze.ts`, and deliberately the same
615
- * answer: a reference nobody can render stays visible rather than becoming a
616
- * replacement character that reads as content. Duplicated rather than shared
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 `&#x26;#104;` twice, and a bounded digit run leaves `&#0000000104;`
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
- function codePoint(value, original) {
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 original;
629
+ return String.fromCodePoint(0xfffd);
623
630
  if (value >= 0xd800 && value <= 0xdfff)
624
- return original;
631
+ return String.fromCodePoint(0xfffd);
625
632
  return String.fromCodePoint(value);
626
633
  }
627
634
  //# sourceMappingURL=ooxml.js.map