@ni-c/imap-mcp 0.3.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 (52) hide show
  1. package/README.md +35 -10
  2. package/dist/analyze.d.ts +33 -3
  3. package/dist/analyze.js +135 -23
  4. package/dist/attachments.d.ts +12 -0
  5. package/dist/attachments.js +52 -5
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +147 -14
  8. package/dist/extract/child.d.ts +1 -0
  9. package/dist/extract/child.js +83 -0
  10. package/dist/extract/index.d.ts +41 -0
  11. package/dist/extract/index.js +183 -0
  12. package/dist/extract/ooxml.d.ts +35 -0
  13. package/dist/extract/ooxml.js +634 -0
  14. package/dist/extract/pdf.d.ts +62 -0
  15. package/dist/extract/pdf.js +539 -0
  16. package/dist/extract/types.d.ts +56 -0
  17. package/dist/extract/types.js +13 -0
  18. package/dist/imap.d.ts +48 -2
  19. package/dist/imap.js +133 -28
  20. package/dist/message.d.ts +11 -0
  21. package/dist/message.js +26 -3
  22. package/dist/output-schema.d.ts +1 -0
  23. package/dist/output-schema.js +6 -0
  24. package/dist/resources.js +10 -3
  25. package/dist/result.d.ts +7 -1
  26. package/dist/result.js +32 -6
  27. package/dist/schema.d.ts +2 -0
  28. package/dist/schema.js +2 -0
  29. package/dist/server.js +15 -0
  30. package/dist/tools/read.js +473 -58
  31. package/dist/tools/write.js +18 -4
  32. package/package.json +11 -7
  33. package/dist/analyze.js.map +0 -1
  34. package/dist/attachments.js.map +0 -1
  35. package/dist/audit.js.map +0 -1
  36. package/dist/config.js.map +0 -1
  37. package/dist/download.js.map +0 -1
  38. package/dist/draft.js.map +0 -1
  39. package/dist/errors.js.map +0 -1
  40. package/dist/imap.js.map +0 -1
  41. package/dist/index.js.map +0 -1
  42. package/dist/message.js.map +0 -1
  43. package/dist/output-schema.js.map +0 -1
  44. package/dist/resources.js.map +0 -1
  45. package/dist/result.js.map +0 -1
  46. package/dist/schema.js.map +0 -1
  47. package/dist/server.js.map +0 -1
  48. package/dist/stream.js.map +0 -1
  49. package/dist/tools/annotations.js.map +0 -1
  50. package/dist/tools/catalogue.js.map +0 -1
  51. package/dist/tools/read.js.map +0 -1
  52. 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
@@ -116,6 +121,7 @@ guard. See [Asking a person](https://imap-mcp.ni-c.de/guide/approval).
116
121
  | `IMAP_MAX_MESSAGES` | no | `100` | Default page size |
117
122
  | `IMAP_MAX_ATTACHMENT_BYTES` | no | `1048576` | Ceiling for returning an attachment inline |
118
123
  | `IMAP_MAX_DOWNLOAD_BYTES` | no | `26214400` | Ceiling for writing one to disk |
124
+ | `IMAP_MAX_EXTRACT_BYTES` | no | `10485760` | Ceiling for reading a document's text; max `67108864` |
119
125
  | `IMAP_ATTACHMENT_TYPES` | no | see below | Comma-separated content-type allowlist |
120
126
  | `IMAP_DOWNLOAD_DIR` | no | — | Setting it allows saving attachments there |
121
127
  | `IMAP_INSECURE_TLS` | no | `false` | Exactly `true` accepts a self-signed certificate |
@@ -143,6 +149,10 @@ IMAP_ALLOW_TOOLS=list_new_messages,get_message,move_messages
143
149
  IMAP_DENY_TOOLS=delete_messages
144
150
  ```
145
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
+
146
156
  An entry that matches no tool aborts startup and names it, so a typo cannot silently hide a
147
157
  tool — an absent tool is not something anyone traces back to an environment variable. A
148
158
  filtered tool is never registered, so it is absent from `tools/list` and unknown to
@@ -155,8 +165,11 @@ answer — its `/hub` endpoint replaces every server's tools with six meta-tools
155
165
  The password is deleted from the process environment as soon as it is read, so it is not
156
166
  visible to child processes or in `/proc/<pid>/environ`.
157
167
 
158
- Without `IMAP_DOWNLOAD_DIR` this server never writes to the filesystem. The two size limits are
159
- separate on purpose: one protects the model's context window, the other protects your disk.
168
+ Without `IMAP_DOWNLOAD_DIR` this server never writes to the filesystem. The three size limits are
169
+ separate on purpose, because they answer three different questions:
170
+ `IMAP_MAX_ATTACHMENT_BYTES` protects the model's context window,
171
+ `IMAP_MAX_DOWNLOAD_BYTES` protects your disk, and `IMAP_MAX_EXTRACT_BYTES` bounds how much
172
+ hostile input one parser is handed. Raising any one of them is not a request to raise the others.
160
173
 
161
174
  The server starts without credentials on purpose — it completes the handshake and lists its
162
175
  tools, and every call then fails with setup instructions instead of reaching a server.
@@ -259,7 +272,7 @@ are in the [client guide](https://imap-mcp.ni-c.de/guide/clients#through-mcp-hub
259
272
  | `list_messages` | Lists and searches: sender, recipient, subject, body, date range, flags |
260
273
  | `list_new_messages` | Messages not handed over yet; marks them afterwards, `dry_run` to preview |
261
274
  | `get_message` | Headers and body, fenced untrusted, plus the security assessment; `include_thread` |
262
- | `get_attachments` | Without `part_id` lists them, with `part_id` reads or saves one |
275
+ | `get_attachments` | Without `part_id` lists them, with `part_id` reads, extracts or saves one |
263
276
 
264
277
  **Mailbox** — needs `IMAP_READ_ONLY=false`
265
278
 
@@ -326,7 +339,8 @@ size limit and magic-byte check as the tool — it is not a second, unguarded do
326
339
  ## Not exposed, on purpose
327
340
 
328
341
  No sending, no SMTP, no raw IMAP passthrough, no `APPEND` of arbitrary MIME, no HTML
329
- composition, no OAuth2. The first is the whole security argument (see `SECURITY.md`); the
342
+ composition, no OAuth2, and **no OCR** a scanned PDF has no text to extract and says so
343
+ rather than guessing. The first is the whole security argument (see `SECURITY.md`); the
330
344
  second would make every guard here optional; the last is planned but needs a test account
331
345
  before it ships.
332
346
 
@@ -346,6 +360,17 @@ knowing before concluding that a filtered install reaches less of the mailbox th
346
360
  saving to disk, where it would be more dangerous, not less.
347
361
  - **A `part_id` must come from a listing call**, so the body cannot be pulled out through the
348
362
  attachment tool and escape its framing.
363
+ - **Documents are parsed in a process that can be killed.** `mode: "text"` reads a PDF or
364
+ Office file with a bundled PDF.js and a ZIP reader — the only place this server parses a
365
+ binary a stranger sent. It runs in a child process with a heap limit and a timeout, its
366
+ stdout discarded rather than shared with the JSON-RPC transport, PDF.js's `eval` support
367
+ off, compressed streams measured against a ceiling _before_ PDF.js inflates them, and an
368
+ entry allowlist that decides what is decompressed _before_ the buffer is sized. Nothing in
369
+ that path touches the network or the filesystem.
370
+ - **Extracted text says what it is.** Extraction returns every text-drawing instruction in a
371
+ file, including text set below one point or drawn in the colour of the paper, and returns
372
+ nothing that was drawn as a picture. The result says so above the fence, because "the
373
+ document says X" is otherwise a claim the user has no way to check.
349
374
  - **Downloads cannot escape their directory.** The target comes only from the environment, the
350
375
  filename is sanitised, the resolved path is re-checked, and the file is opened with `wx` and
351
376
  mode `0600` — so nothing is overwritten and no planted symlink is followed.
package/dist/analyze.d.ts CHANGED
@@ -23,13 +23,33 @@ export interface SecurityAssessment {
23
23
  };
24
24
  }
25
25
  /**
26
- * Extracts readable text from HTML.
26
+ * Extracts readable text from HTML — and from the XML inside an OOXML or
27
+ * OpenDocument attachment, which is the same problem with different tag names.
28
+ *
29
+ * Reused there rather than reimplemented, and that is a security decision, not
30
+ * a tidiness one. The obvious `document.xml` reader is
31
+ * `/<w:t[^>]*>([\s\S]*?)<\/w:t>/g` — which is the exact shape of the bug
32
+ * recorded above this function: *n* start tokens each paying for a scan that
33
+ * never finds its end. A `document.xml` of `'<w:t '` repeated 200 000 times is
34
+ * a tiny, deflate-friendly ZIP entry. This walk is immune for the reason it was
35
+ * written, so the second parser is the one not to write.
36
+ *
37
+ * It is also why nothing here parses XML properly. A real parser resolves
38
+ * entities, and would hand a mail attachment billion-laughs expansion and an
39
+ * `<!ENTITY … SYSTEM "file:///etc/passwd">` that reads a file. Those are not
40
+ * defended against below; they are simply not implemented. `&lol9;` comes out
41
+ * as six literal characters, and it must stay that way — reaching for
42
+ * `fast-xml-parser` here would reintroduce all three at once.
27
43
  *
28
44
  * Deliberately not `mailparser`'s own `text` fallback: that keeps content the
29
45
  * recipient never sees. Anything hidden by inline CSS is a place to park an
30
46
  * instruction meant only for the model, so those elements are dropped before
31
47
  * the tags are stripped.
32
48
  *
49
+ * `maxChars` overrides the input slice. The default suits a mail body; a
50
+ * document part needs more, because OOXML spends most of its bytes on
51
+ * formatting and the readable text is a small fraction of it.
52
+ *
33
53
  * This is one pass over the input. The cursors below only ever move forward,
34
54
  * which is the property that makes the whole function linear no matter what the
35
55
  * sender writes: a start token that is never closed is answered once and then
@@ -40,7 +60,8 @@ export interface SecurityAssessment {
40
60
  * for the same reason it always was: nothing downstream trusts the stripping,
41
61
  * and the fencing in {@link wrapUntrusted} is what carries the weight.
42
62
  */
43
- export declare function htmlToText(html: string): string;
63
+ export declare function htmlToText(html: string, maxChars?: number): string;
64
+ export declare function decodeCharacterReferences(text: string): string;
44
65
  /**
45
66
  * Removes the characters a human reader cannot see but the model can.
46
67
  *
@@ -74,7 +95,16 @@ export declare function escapeInvisible(input: string): string;
74
95
  * normalising would miss it.
75
96
  */
76
97
  export declare function sanitizeText(input: string, maxChars?: number): string;
77
- /** Names of the injection shapes present in `text`. */
98
+ /**
99
+ * Names of the injection shapes present in `text`.
100
+ *
101
+ * Runs in this process on text the sender wrote, up to a million characters
102
+ * of it for an extracted document — after the parser child has exited, so no
103
+ * timeout covers it. Every pattern above therefore has to be linear on a
104
+ * hostile repetition of its own trigger, and `analyze.test.ts` times each one
105
+ * on such input. A new pattern gets a line in that test before it gets a line
106
+ * in the list.
107
+ */
78
108
  export declare function detectSuspicious(text: string): string[];
79
109
  /**
80
110
  * Words that mix Latin with Cyrillic or Greek letters.
package/dist/analyze.js CHANGED
@@ -45,9 +45,18 @@ const INJECTION_PATTERNS = [
45
45
  'role-injection',
46
46
  /(?:^|[-\u2014|>\])]\s{0,3})(system|assistant|developer)\s*:/im,
47
47
  ],
48
+ // Anchored at the start of the run. Without the lookbehind, `-{3,}` was
49
+ // tried from every position inside a run and backtracked once per possible
50
+ // length each time, which is quadratic on text that is nothing but hyphens:
51
+ // 40 000 of them took 1.5 s, and the million characters an extracted
52
+ // document may carry would have held this process — the whole server, its
53
+ // transport is stdio — for a quarter of an hour. A few kilobytes on the
54
+ // wire. With it, a run is one attempt from its first character and the
55
+ // positions inside it fail in constant time: a million hyphens in 5 ms,
56
+ // and a delimiter of any length is still found.
48
57
  [
49
58
  'fake-delimiter',
50
- /(-{3,}|={3,}|#{3,})\s*(begin|end|system|instruction|prompt)/i,
59
+ /(?<![-=#])(-{3,}|={3,}|#{3,})\s*(begin|end|system|instruction|prompt)/i,
51
60
  ],
52
61
  [
53
62
  'tool-coercion',
@@ -115,7 +124,16 @@ const MAX_HIDDEN_ELEMENT_CHARS = 10_000;
115
124
  */
116
125
  const CLOSER_SCAN_BUDGET_FACTOR = 4;
117
126
  const CLOSER_SCAN_BUDGET_FLOOR = 100_000;
118
- /** Elements whose content the recipient never reads. */
127
+ /**
128
+ * Elements whose content the recipient never reads.
129
+ *
130
+ * The `w:`-prefixed names are WordprocessingML, not HTML: this walk is also how
131
+ * the text of a .docx attachment is read. A Word field code
132
+ * (`INCLUDEPICTURE "http://…"`, a DDE command) is markup the reader never sees
133
+ * for exactly the same reason a `<script>` body is, and `w:delText` is text a
134
+ * tracked change deleted — shown struck through at most, and not at all in the
135
+ * view a document is read in.
136
+ */
119
137
  const NON_CONTENT_TAGS = new Set([
120
138
  'script',
121
139
  'style',
@@ -123,8 +141,16 @@ const NON_CONTENT_TAGS = new Set([
123
141
  'title',
124
142
  'noscript',
125
143
  'template',
144
+ 'w:instrtext',
145
+ 'w:deltext',
126
146
  ]);
127
- /** Closing tags that end a visual block, and so earn a line break. */
147
+ /**
148
+ * Closing tags that end a visual block, and so earn a line break.
149
+ *
150
+ * The namespaced names are the OOXML and OpenDocument paragraph, heading and
151
+ * row elements. Without them a whole .docx comes back as one line, because
152
+ * nothing else in a document part ever closes a block.
153
+ */
128
154
  const BLOCK_TAGS = new Set([
129
155
  'p',
130
156
  'div',
@@ -136,8 +162,19 @@ const BLOCK_TAGS = new Set([
136
162
  'h4',
137
163
  'h5',
138
164
  'h6',
165
+ 'w:p',
166
+ 'w:tr',
167
+ 'a:p',
168
+ 'text:p',
169
+ 'text:h',
170
+ 'table:table-row',
139
171
  ]);
140
- const TAG_NAME = /^<\/?([A-Za-z][A-Za-z0-9]*)/;
172
+ // The name may carry a namespace prefix and a hyphen: `</w:p>`, `</text:h>`,
173
+ // `</table:table-row>`. Without the colon and the hyphen this matched `w` for
174
+ // `</w:p>`, so every namespaced entry in the sets above would be dead code.
175
+ // HTML is unaffected — no HTML element name contains either character, and a
176
+ // custom element (`<my-widget>`) is in none of the sets under either reading.
177
+ const TAG_NAME = /^<\/?([A-Za-z][A-Za-z0-9:_.-]*)/;
141
178
  const BR_TAG = /^<br\s*\/?$/i;
142
179
  const STYLE_ATTRIBUTE = /style\s*=\s*("|')/gi;
143
180
  const HIDDEN_VALUE = /display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0|font-size\s*:\s*0/i;
@@ -164,13 +201,33 @@ function hasHiddenStyle(tag) {
164
201
  return false;
165
202
  }
166
203
  /**
167
- * Extracts readable text from HTML.
204
+ * Extracts readable text from HTML — and from the XML inside an OOXML or
205
+ * OpenDocument attachment, which is the same problem with different tag names.
206
+ *
207
+ * Reused there rather than reimplemented, and that is a security decision, not
208
+ * a tidiness one. The obvious `document.xml` reader is
209
+ * `/<w:t[^>]*>([\s\S]*?)<\/w:t>/g` — which is the exact shape of the bug
210
+ * recorded above this function: *n* start tokens each paying for a scan that
211
+ * never finds its end. A `document.xml` of `'<w:t '` repeated 200 000 times is
212
+ * a tiny, deflate-friendly ZIP entry. This walk is immune for the reason it was
213
+ * written, so the second parser is the one not to write.
214
+ *
215
+ * It is also why nothing here parses XML properly. A real parser resolves
216
+ * entities, and would hand a mail attachment billion-laughs expansion and an
217
+ * `<!ENTITY … SYSTEM "file:///etc/passwd">` that reads a file. Those are not
218
+ * defended against below; they are simply not implemented. `&lol9;` comes out
219
+ * as six literal characters, and it must stay that way — reaching for
220
+ * `fast-xml-parser` here would reintroduce all three at once.
168
221
  *
169
222
  * Deliberately not `mailparser`'s own `text` fallback: that keeps content the
170
223
  * recipient never sees. Anything hidden by inline CSS is a place to park an
171
224
  * instruction meant only for the model, so those elements are dropped before
172
225
  * the tags are stripped.
173
226
  *
227
+ * `maxChars` overrides the input slice. The default suits a mail body; a
228
+ * document part needs more, because OOXML spends most of its bytes on
229
+ * formatting and the readable text is a small fraction of it.
230
+ *
174
231
  * This is one pass over the input. The cursors below only ever move forward,
175
232
  * which is the property that makes the whole function linear no matter what the
176
233
  * sender writes: a start token that is never closed is answered once and then
@@ -181,8 +238,8 @@ function hasHiddenStyle(tag) {
181
238
  * for the same reason it always was: nothing downstream trusts the stripping,
182
239
  * and the fencing in {@link wrapUntrusted} is what carries the weight.
183
240
  */
184
- export function htmlToText(html) {
185
- const source = html.slice(0, MAX_HTML_CHARS);
241
+ export function htmlToText(html, maxChars = MAX_HTML_CHARS) {
242
+ const source = html.slice(0, maxChars);
186
243
  const out = [];
187
244
  // Forward-only cursors. Each call may advance them, never rewind them, so
188
245
  // across the whole document each scans the input at most once — the same
@@ -281,16 +338,47 @@ export function htmlToText(html) {
281
338
  }
282
339
  i = gt + 1;
283
340
  }
284
- return (out
285
- .join('')
286
- .replace(/&nbsp;/gi, ' ')
287
- .replace(/&lt;/gi, '<')
288
- .replace(/&gt;/gi, '>')
289
- .replace(/&quot;/gi, '"')
290
- .replace(/&#39;/g, "'")
291
- // Last, so a decoded `&amp;lt;` does not turn into a `<` the caller never
292
- // received.
293
- .replace(/&amp;/gi, '&'));
341
+ return decodeCharacterReferences(out.join(''));
342
+ }
343
+ /**
344
+ * The character references a mail client decodes, decoded the way it does.
345
+ *
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.
358
+ */
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) {
377
+ if (!Number.isInteger(value) || value < 1 || value > 0x10ffff)
378
+ return String.fromCodePoint(0xfffd);
379
+ if (value >= 0xd800 && value <= 0xdfff)
380
+ return String.fromCodePoint(0xfffd);
381
+ return String.fromCodePoint(value);
294
382
  }
295
383
  /**
296
384
  * Removes the characters a human reader cannot see but the model can.
@@ -312,8 +400,13 @@ export function stripInvisible(input) {
312
400
  * not the `Archive` they know.
313
401
  */
314
402
  export function escapeInvisible(input) {
315
- const escape = (match) => `\\u${match.codePointAt(0).toString(16).padStart(4, '0')}`;
316
- 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')}`;
317
410
  }
318
411
  /**
319
412
  * Normalises text before it reaches the model: Unicode-folded, stripped of the
@@ -334,11 +427,24 @@ export function sanitizeText(input, maxChars = MAX_BODY_CHARS) {
334
427
  .replace(/[ \t]+/g, ' ')
335
428
  .replace(/\n{3,}/g, '\n\n')
336
429
  .trim();
337
- 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
338
435
  ? `${normalized.slice(0, maxChars)}\n… (truncated at ${maxChars} characters)`
339
- : normalized;
436
+ : normalized).toWellFormed();
340
437
  }
341
- /** Names of the injection shapes present in `text`. */
438
+ /**
439
+ * Names of the injection shapes present in `text`.
440
+ *
441
+ * Runs in this process on text the sender wrote, up to a million characters
442
+ * of it for an extracted document — after the parser child has exited, so no
443
+ * timeout covers it. Every pattern above therefore has to be linear on a
444
+ * hostile repetition of its own trigger, and `analyze.test.ts` times each one
445
+ * on such input. A new pattern gets a line in that test before it gets a line
446
+ * in the list.
447
+ */
342
448
  export function detectSuspicious(text) {
343
449
  return INJECTION_PATTERNS.filter(([, pattern]) => pattern.test(text)).map(([name]) => name);
344
450
  }
@@ -401,7 +507,13 @@ export function parseAuthResults(header, trustedAuthservId) {
401
507
  const match = new RegExp(`\\b${name}=([a-z]+)`, 'i').exec(topmost);
402
508
  return match?.[1]?.toLowerCase() ?? 'unknown';
403
509
  };
404
- 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];
405
517
  return {
406
518
  spf: read('spf'),
407
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;
@@ -24,7 +25,18 @@ export interface AttachmentCandidate {
24
25
  }
25
26
  export interface AttachmentPolicy {
26
27
  allowedTypes: string[];
28
+ /**
29
+ * Size ceiling for the destination this check is for.
30
+ *
31
+ * Per destination, because there are three of them and they answer different
32
+ * questions: what may enter the model's context, what may be written to disk,
33
+ * what a parser may be handed. The caller picks the number; naming it here
34
+ * keeps the refusal pointing at the variable an operator would actually
35
+ * change.
36
+ */
27
37
  maxBytes: number;
38
+ /** Environment variable behind {@link maxBytes}, for the refusal text. */
39
+ maxBytesName: string;
28
40
  }
29
41
  /**
30
42
  * Strips a filename down to something safe to print and to reason about.
@@ -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,
@@ -225,7 +272,7 @@ export function checkPolicy(candidate, policy) {
225
272
  }
226
273
  if (candidate.size !== undefined && candidate.size > policy.maxBytes) {
227
274
  allowed = false;
228
- notes.push(`refused: declared size ${candidate.size} exceeds IMAP_MAX_ATTACHMENT_BYTES (${policy.maxBytes})`);
275
+ notes.push(`refused: declared size ${candidate.size} exceeds ${policy.maxBytesName} (${policy.maxBytes})`);
229
276
  }
230
277
  return { ...candidate, allowed, notes };
231
278
  }
package/dist/config.d.ts CHANGED
@@ -31,9 +31,27 @@ 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;
44
+ /**
45
+ * Ceiling on the bytes handed to the text-extraction worker.
46
+ *
47
+ * A third limit because it answers a third question. `maxAttachmentBytes`
48
+ * bounds what may enter the model's context — extraction does not, since only
49
+ * the extracted text comes back and `max_chars` bounds that. `maxDownloadBytes`
50
+ * bounds what may be written to the filesystem. This one bounds how much
51
+ * hostile input a parser is asked to chew on inside a thread that can be
52
+ * terminated, which is a memory question and neither of the other two.
53
+ */
54
+ maxExtractBytes: number;
37
55
  }
38
56
  export interface Config {
39
57
  imap: ImapConfig;