@ni-c/imap-mcp 0.2.0 → 0.3.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 +122 -31
  2. package/dist/analyze.d.ts +22 -3
  3. package/dist/analyze.js +202 -23
  4. package/dist/analyze.js.map +1 -1
  5. package/dist/attachments.d.ts +25 -0
  6. package/dist/attachments.js +20 -2
  7. package/dist/attachments.js.map +1 -1
  8. package/dist/audit.d.ts +7 -0
  9. package/dist/audit.js +11 -3
  10. package/dist/audit.js.map +1 -1
  11. package/dist/config.d.ts +21 -0
  12. package/dist/config.js +44 -5
  13. package/dist/config.js.map +1 -1
  14. package/dist/errors.js.map +1 -1
  15. package/dist/imap.js.map +1 -1
  16. package/dist/index.js +32 -5
  17. package/dist/index.js.map +1 -1
  18. package/dist/output-schema.d.ts +62 -0
  19. package/dist/output-schema.js +81 -0
  20. package/dist/output-schema.js.map +1 -0
  21. package/dist/resources.d.ts +1 -1
  22. package/dist/resources.js +1 -1
  23. package/dist/resources.js.map +1 -1
  24. package/dist/result.d.ts +32 -5
  25. package/dist/result.js +128 -24
  26. package/dist/result.js.map +1 -1
  27. package/dist/schema.d.ts +13 -1
  28. package/dist/schema.js +20 -2
  29. package/dist/schema.js.map +1 -1
  30. package/dist/server.d.ts +1 -1
  31. package/dist/server.js +30 -5
  32. package/dist/server.js.map +1 -1
  33. package/dist/tools/annotations.d.ts +32 -0
  34. package/dist/tools/annotations.js +33 -0
  35. package/dist/tools/annotations.js.map +1 -0
  36. package/dist/tools/catalogue.d.ts +2 -2
  37. package/dist/tools/read.d.ts +1 -1
  38. package/dist/tools/read.js +345 -50
  39. package/dist/tools/read.js.map +1 -1
  40. package/dist/tools/write.d.ts +3 -3
  41. package/dist/tools/write.js +165 -38
  42. package/dist/tools/write.js.map +1 -1
  43. package/package.json +15 -11
  44. package/dist/approval.d.ts +0 -45
  45. package/dist/approval.js +0 -69
  46. package/dist/approval.js.map +0 -1
  47. package/dist/confirm.d.ts +0 -59
  48. package/dist/confirm.js +0 -92
  49. package/dist/confirm.js.map +0 -1
  50. package/dist/tool-filter.d.ts +0 -45
  51. package/dist/tool-filter.js +0 -171
  52. package/dist/tool-filter.js.map +0 -1
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  [![license](https://img.shields.io/npm/l/%40ni-c%2Fimap-mcp)](LICENSE)
8
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
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)
10
11
  [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
11
12
 
12
13
  A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for any IMAP
@@ -63,6 +64,11 @@ dropped on a best-effort basis (the fencing, not the stripping, is what carries
63
64
  markdown image syntax — inline and reference style — is defused so a rendering client cannot be
64
65
  made to fetch a tracking URL.
65
66
 
67
+ That covers folder names too, and it did not always: a folder name is chosen by whoever created
68
+ the folder, which on a shared mailbox is not necessarily you. `list_mailboxes` returns the name
69
+ twice — `path` exactly as the server spelled it, because that is the handle every other tool
70
+ takes, and `display_name` cleaned up for reading, with a warning on the entry when the two differ.
71
+
66
72
  Alongside the message you get a server-side assessment: the SPF/DKIM/DMARC verdicts with the
67
73
  authserv-id they came from, which prompt-injection shapes matched, and which words mix Latin
68
74
  with Cyrillic or Greek letters. When something matches, the warning is the first thing in the
@@ -79,9 +85,11 @@ is reported as what it is: a claim, from a header anyone could have written.
79
85
  IMAP keyword (`AiSeen` by default), so `list_new_messages` returns each message once. The human
80
86
  `\Seen` state is never touched — everything is read with `BODY.PEEK`.
81
87
 
82
- **Deleting asks a person.** Where the client supports MCP elicitation, `delete_messages` and
83
- deleting a folder raise a real dialog that the model cannot answer on its behalf. Where it does
84
- not, they fall back to a two-call token — and say so, rather than implying somebody approved.
88
+ **Deleting and moving ask a person.** Where the client supports MCP elicitation, `delete_messages`,
89
+ `move_messages` and deleting a folder raise a real dialog that the model cannot answer on its
90
+ behalf. Where it does not, they fall back to a two-call token — and say so, rather than implying
91
+ somebody approved. `ELICITATION=false` takes that fallback deliberately; it never removes the
92
+ guard. See [Asking a person](https://imap-mcp.ni-c.de/guide/approval).
85
93
 
86
94
  ## Requirements
87
95
 
@@ -91,26 +99,27 @@ not, they fall back to a two-call token — and say so, rather than implying som
91
99
 
92
100
  ## Configuration
93
101
 
94
- | Variable | Required | Default | Description |
95
- | --------------------------- | -------- | ------------- | ---------------------------------------------------- |
96
- | `IMAP_HOST` | yes | — | Hostname of the IMAP server, e.g. `imap.example.net` |
97
- | `IMAP_USER` | yes | — | Account username, usually the address |
98
- | `IMAP_PASSWORD` | yes | — | Password or app-specific password |
99
- | `IMAP_PORT` | no | `993` / `143` | Defaults by TLS mode |
100
- | `IMAP_TLS` | no | `implicit` | `implicit`, `starttls` or `none` |
101
- | `IMAP_MAILBOX` | no | `INBOX` | Mailbox the message tools default to |
102
- | `IMAP_READ_ONLY` | no | **`true`** | Exactly `false` registers the five mailbox tools |
103
- | `IMAP_ALLOW_TOOLS` | no | — | Tool names, `list_*` prefixes or `essential` |
104
- | `IMAP_DENY_TOOLS` | no | — | Same syntax; subtracted from the allow list |
105
- | `IMAP_SEEN_KEYWORD` | no | `AiSeen` | Keyword for new-mail tracking; empty turns it off |
106
- | `IMAP_TRUSTED_AUTHSERV_ID` | no | — | The authserv-id your provider stamps; see below |
107
- | `IMAP_DRAFTS_MAILBOX` | no | auto | Overrides the folder found via the `\Drafts` flag |
108
- | `IMAP_MAX_MESSAGES` | no | `100` | Default page size |
109
- | `IMAP_MAX_ATTACHMENT_BYTES` | no | `1048576` | Ceiling for returning an attachment inline |
110
- | `IMAP_MAX_DOWNLOAD_BYTES` | no | `26214400` | Ceiling for writing one to disk |
111
- | `IMAP_ATTACHMENT_TYPES` | no | see below | Comma-separated content-type allowlist |
112
- | `IMAP_DOWNLOAD_DIR` | no | — | Setting it allows saving attachments there |
113
- | `IMAP_INSECURE_TLS` | no | `false` | Exactly `true` accepts a self-signed certificate |
102
+ | Variable | Required | Default | Description |
103
+ | --------------------------- | -------- | ------------- | ------------------------------------------------------------ |
104
+ | `IMAP_HOST` | yes | — | Hostname of the IMAP server, e.g. `imap.example.net` |
105
+ | `IMAP_USER` | yes | — | Account username, usually the address |
106
+ | `IMAP_PASSWORD` | yes | — | Password or app-specific password |
107
+ | `IMAP_PORT` | no | `993` / `143` | Defaults by TLS mode |
108
+ | `IMAP_TLS` | no | `implicit` | `implicit`, `starttls` or `none` |
109
+ | `IMAP_MAILBOX` | no | `INBOX` | Mailbox the message tools default to |
110
+ | `IMAP_READ_ONLY` | no | **`true`** | Exactly `false` registers the five mailbox tools |
111
+ | `IMAP_ALLOW_TOOLS` | no | — | Tool names, `list_*` prefixes or `essential` |
112
+ | `IMAP_DENY_TOOLS` | no | — | Same syntax; subtracted from the allow list |
113
+ | `IMAP_SEEN_KEYWORD` | no | `AiSeen` | Keyword for new-mail tracking; empty turns it off |
114
+ | `IMAP_TRUSTED_AUTHSERV_ID` | no | — | The authserv-id your provider stamps; see below |
115
+ | `IMAP_DRAFTS_MAILBOX` | no | auto | Overrides the folder found via the `\Drafts` flag |
116
+ | `IMAP_MAX_MESSAGES` | no | `100` | Default page size |
117
+ | `IMAP_MAX_ATTACHMENT_BYTES` | no | `1048576` | Ceiling for returning an attachment inline |
118
+ | `IMAP_MAX_DOWNLOAD_BYTES` | no | `26214400` | Ceiling for writing one to disk |
119
+ | `IMAP_ATTACHMENT_TYPES` | no | see below | Comma-separated content-type allowlist |
120
+ | `IMAP_DOWNLOAD_DIR` | no | — | Setting it allows saving attachments there |
121
+ | `IMAP_INSECURE_TLS` | no | `false` | Exactly `true` accepts a self-signed certificate |
122
+ | `ELICITATION` | no | `true` | `false` replaces the dialog with the token. **Not prefixed** |
114
123
 
115
124
  Booleans are compared against the literal string `true`; `1`, `yes` and `True` are not true.
116
125
  `IMAP_READ_ONLY` is the mirror image: only the literal `false` turns it off, so a typo leaves
@@ -204,6 +213,41 @@ bind mount has to be owned by it on the host: `-e IMAP_DOWNLOAD_DIR=/data -v
204
213
  "$PWD/attachments:/data"` with `chown 1000:1000 attachments`. Without
205
214
  `IMAP_DOWNLOAD_DIR` the container never writes anything.
206
215
 
216
+ ### Through mcp-hub
217
+
218
+ A client that cannot spawn a local process — ChatGPT connectors, Claude on the web,
219
+ Cursor, LibreChat — reaches imap-mcp through [mcp-hub](https://mcp-hub.ni-c.de): one
220
+ container serves many stdio MCP servers over Streamable HTTP, with an OAuth 2.1 login
221
+ behind a single password and long-lived tokens for the clients that cannot do OAuth. Its
222
+ `/hub` endpoint puts every server behind six meta-tools, so one connector reaches all of
223
+ them without N×tool schemas in the model's context, and it speaks both protocol revisions
224
+ — a question this server asks travels through it to the person at the far end.
225
+
226
+ Its `/config/mcp.json` uses Claude Code's format, so the entry is the one you already
227
+ have:
228
+
229
+ ```json
230
+ {
231
+ "mcpServers": {
232
+ "imap-mcp": {
233
+ "command": "npx",
234
+ "args": ["-y", "@ni-c/imap-mcp"],
235
+ "env": {
236
+ "IMAP_HOST": "imap.example.net",
237
+ "IMAP_USER": "me@example.net",
238
+ "IMAP_PASSWORD": "…",
239
+ "IMAP_ALLOW_TOOLS": "essential"
240
+ },
241
+ "denyTools": ["delete_messages"]
242
+ }
243
+ }
244
+ }
245
+ ```
246
+
247
+ `allowTools` and `denyTools` there are the hub's **own** per-server filter, which is not
248
+ the same thing as `*_ALLOW_TOOLS` in `env` — the difference, and the mistake it invites,
249
+ are in the [client guide](https://imap-mcp.ni-c.de/guide/clients#through-mcp-hub).
250
+
207
251
  ## Tools
208
252
 
209
253
  **Read** — always registered
@@ -219,13 +263,13 @@ bind mount has to be owned by it on the host: `-e IMAP_DOWNLOAD_DIR=/data -v
219
263
 
220
264
  **Mailbox** — needs `IMAP_READ_ONLY=false`
221
265
 
222
- | Tool | Confirmation |
223
- | ------------------- | ------------------------------------------------------ |
224
- | `set_message_flags` | none — flags are reversible, and `\Deleted` is refused |
225
- | `move_messages` | 🔒 for both `move` and `copy` |
226
- | `delete_messages` | 👤 asks the user, 🔒 where the client cannot |
227
- | `manage_mailbox` | 👤 for `delete`, 🔒 for `rename`, none for `create` |
228
- | `save_draft` | none — a draft does not leave the mailbox |
266
+ | Tool | Confirmation |
267
+ | ------------------- | --------------------------------------------------------- |
268
+ | `set_message_flags` | none — flags are reversible, and `\Deleted` is refused |
269
+ | `move_messages` | 👤 for both `move` and `copy`, 🔒 where the client cannot |
270
+ | `delete_messages` | 👤 asks the user, 🔒 where the client cannot |
271
+ | `manage_mailbox` | 👤 for `delete`, 🔒 for `rename`, none for `create` |
272
+ | `save_draft` | none — a draft does not leave the mailbox |
229
273
 
230
274
  👤 raises a dialog the model cannot answer · 🔒 needs a confirmation token: call once to
231
275
  receive one, then again with it.
@@ -240,6 +284,41 @@ it. Use `delete_messages`, which asks.
240
284
  Neither a confirmation nor a dialog quotes a mailbox name inside its own sentence — folder
241
285
  names come from the account, which on a shared mailbox means a colleague chose them.
242
286
 
287
+ ### Structured output
288
+
289
+ Every tool declares an `outputSchema` and answers with `structuredContent`
290
+ alongside the text block, so a client can use the result without parsing prose:
291
+
292
+ ```jsonc
293
+ {
294
+ "untrusted": true,
295
+ "source": "imap",
296
+ "mailbox": "INBOX",
297
+ "total_matching": 214,
298
+ "offset": 0,
299
+ "returned": 25,
300
+ "next_offset": 25,
301
+ "messages": [{ "uid": 4711, "subject": "…", "from": "…", "seen": false }],
302
+ }
303
+ ```
304
+
305
+ Every tool that reports anything out of the mailbox carries `untrusted: true`
306
+ and `source: "imap"` as fields — a sender display name, a folder name a
307
+ colleague chose and an attachment filename are all attacker-controllable, and
308
+ they reach the model through the listing tools long before anyone opens a
309
+ message. Only `get_server_info` and the five write tools are without it: those
310
+ report this server's own configuration, or what it just did with the uids it was
311
+ given.
312
+
313
+ `get_message` and a text attachment keep the per-call nonce fence in the text
314
+ block — the structured half states the same fields, so a client is not made to
315
+ parse the fence to find them. An image attachment keeps its bytes in the content
316
+ block, where a client renders them, rather than repeating the base64.
317
+
318
+ A refusal is now an **error result**: an attachment the policy rejects, one whose
319
+ bytes are an executable whatever it claimed, one too large to inline. Each was a
320
+ plain result that read like an answer.
321
+
243
322
  Attachments are also available as MCP resources at `imap://message/{uid}/part/{partId}`, which
244
323
  matters where the server has no useful filesystem. The resource path runs the same allowlist,
245
324
  size limit and magic-byte check as the tool — it is not a second, unguarded door.
@@ -282,6 +361,11 @@ knowing before concluding that a filtered install reaches less of the mailbox th
282
361
  `SECURITY.md` has the trust model, what these measures do _not_ cover, and how to report a
283
362
  vulnerability.
284
363
 
364
+ ## Documentation
365
+
366
+ The full guide, tool reference and security notes live at
367
+ **[imap-mcp.ni-c.de](https://imap-mcp.ni-c.de)** (source in [`docs/`](docs/)).
368
+
285
369
  ## Development
286
370
 
287
371
  ```bash
@@ -303,6 +387,13 @@ network. For a live server to point the real thing at, see
303
387
  The release workflow publishes to npm (Trusted Publishing, with provenance), creates
304
388
  the GitHub release from the CHANGELOG section and updates the MCP Registry entry.
305
389
 
390
+ ## Contributing
391
+
392
+ Issues, discussions and pull requests are welcome — see
393
+ [CONTRIBUTING.md](CONTRIBUTING.md). For vulnerabilities please use
394
+ [private reporting](https://github.com/ni-c/imap-mcp/security/advisories/new)
395
+ rather than a public issue; the policy is in [SECURITY.md](SECURITY.md).
396
+
306
397
  ## License
307
398
 
308
- MIT © Willi Thiel
399
+ [MIT](LICENSE) © Willi Thiel
package/dist/analyze.d.ts CHANGED
@@ -28,9 +28,17 @@ export interface SecurityAssessment {
28
28
  * Deliberately not `mailparser`'s own `text` fallback: that keeps content the
29
29
  * recipient never sees. Anything hidden by inline CSS is a place to park an
30
30
  * instruction meant only for the model, so those elements are dropped before
31
- * the tags are stripped. Best effort, not a guarantee: nested same-name tags
32
- * end the non-greedy match early, and elements hidden via a stylesheet class
33
- * are not recognised at all.
31
+ * the tags are stripped.
32
+ *
33
+ * This is one pass over the input. The cursors below only ever move forward,
34
+ * which is the property that makes the whole function linear no matter what the
35
+ * sender writes: a start token that is never closed is answered once and then
36
+ * never looked for again, instead of restarting a bounded scan at every
37
+ * occurrence. Removal itself stays best effort — nested same-name tags end a
38
+ * block early, elements hidden via a stylesheet class are not recognised at
39
+ * all, and anything past the scan budget is left in place. That is acceptable
40
+ * for the same reason it always was: nothing downstream trusts the stripping,
41
+ * and the fencing in {@link wrapUntrusted} is what carries the weight.
34
42
  */
35
43
  export declare function htmlToText(html: string): string;
36
44
  /**
@@ -40,6 +48,17 @@ export declare function htmlToText(html: string): string;
40
48
  * body, because it is rendered next to one and read with the same eyes.
41
49
  */
42
50
  export declare function stripInvisible(input: string): string;
51
+ /**
52
+ * The same characters, written out as escapes instead of removed.
53
+ *
54
+ * For the places where a string has to stay recognisable as the exact thing
55
+ * that was asked for — a confirmation dialog, an audit line. Stripping alone
56
+ * says "this is not what it looked like" and then shows something that looks
57
+ * like an ordinary name; this shows which characters were in it, so a person
58
+ * deciding whether to move mail into `Archive<U+202E>` can see that the folder is
59
+ * not the `Archive` they know.
60
+ */
61
+ export declare function escapeInvisible(input: string): string;
43
62
  /**
44
63
  * Normalises text before it reaches the model: Unicode-folded, stripped of the
45
64
  * characters a human reader cannot see, auto-fetch markup defused, and
package/dist/analyze.js CHANGED
@@ -86,46 +86,211 @@ const INJECTION_PATTERNS = [
86
86
  ],
87
87
  ];
88
88
  /**
89
- * Cap on the HTML handed to the removal regexes, and on the span a single one
90
- * of them may swallow. Both exist for the same reason: an unbounded `[\s\S]*?`
91
- * scanning for a closing tag that never comes is quadratic, and a crafted
92
- * 2 MB body full of unclosed tags turns that into minutes of CPU. Bounding the
93
- * scan makes the worst case a nuisance instead of a hang, at the cost that a
94
- * hidden element larger than the bound is no longer removed which is why
95
- * this pass is best effort and the fencing in {@link wrapUntrusted} is what
96
- * actually carries the weight.
89
+ * Cap on the HTML this pass looks at, and on the span a single element may
90
+ * swallow.
91
+ *
92
+ * Neither of these is what keeps the pass cheap, and the previous version of
93
+ * this comment claimed otherwise. Removing an element means scanning forward
94
+ * for a closing token, and a bound on *that* scan bounds one factor of a
95
+ * product whose other factor is the number of scans an input can start. A body
96
+ * of `'<style '` repeated 73 000 times is 512 000 legal bytes that start 73 000
97
+ * bounded scans and finish none of them: the removal regexes this used to be
98
+ * built from took 33 seconds on it, on a single-threaded process whose
99
+ * transport is stdio. The scan below is a single left-to-right walk instead, so
100
+ * the number of start tokens no longer multiplies anything.
97
101
  */
98
102
  const MAX_HTML_CHARS = 512_000;
99
103
  const MAX_REMOVED_BLOCK_CHARS = 50_000;
100
104
  const MAX_HIDDEN_ELEMENT_CHARS = 10_000;
101
- const HTML_COMMENT = new RegExp(`<!--[\\s\\S]{0,${MAX_REMOVED_BLOCK_CHARS}}?-->`, 'g');
102
- const NON_CONTENT_ELEMENT = new RegExp(`<(script|style|head|title|noscript|template)\\b[\\s\\S]{0,${MAX_REMOVED_BLOCK_CHARS}}?<\\/\\1>`, 'gi');
103
- const HIDDEN_ELEMENT = new RegExp(`<([a-z0-9]+)\\b[^>]*style\\s*=\\s*("|')[^"']*(display\\s*:\\s*none|visibility\\s*:\\s*hidden|opacity\\s*:\\s*0|font-size\\s*:\\s*0)[^"']*\\2[^>]*>[\\s\\S]{0,${MAX_HIDDEN_ELEMENT_CHARS}}?<\\/\\1>`, 'gi');
105
+ /**
106
+ * Total characters the walk may spend looking for closing tags, across the
107
+ * whole document.
108
+ *
109
+ * This is the cap on the product. Each successful removal costs the length of
110
+ * what it removed, and removals do not overlap, so an honest document never
111
+ * comes close; only an input that starts removals it never closes can exhaust
112
+ * it. Once it is gone the remaining elements are stripped as ordinary tags and
113
+ * their content stays visible — best effort degrading to less effort, never to
114
+ * a stalled server.
115
+ */
116
+ const CLOSER_SCAN_BUDGET_FACTOR = 4;
117
+ const CLOSER_SCAN_BUDGET_FLOOR = 100_000;
118
+ /** Elements whose content the recipient never reads. */
119
+ const NON_CONTENT_TAGS = new Set([
120
+ 'script',
121
+ 'style',
122
+ 'head',
123
+ 'title',
124
+ 'noscript',
125
+ 'template',
126
+ ]);
127
+ /** Closing tags that end a visual block, and so earn a line break. */
128
+ const BLOCK_TAGS = new Set([
129
+ 'p',
130
+ 'div',
131
+ 'tr',
132
+ 'li',
133
+ 'h1',
134
+ 'h2',
135
+ 'h3',
136
+ 'h4',
137
+ 'h5',
138
+ 'h6',
139
+ ]);
140
+ const TAG_NAME = /^<\/?([A-Za-z][A-Za-z0-9]*)/;
141
+ const BR_TAG = /^<br\s*\/?$/i;
142
+ const STYLE_ATTRIBUTE = /style\s*=\s*("|')/gi;
143
+ const HIDDEN_VALUE = /display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0|font-size\s*:\s*0/i;
144
+ /**
145
+ * Whether a start tag carries an inline style that hides it.
146
+ *
147
+ * Written as "find the attribute, then look inside its value" rather than as
148
+ * one pattern spanning both, because the one-pattern form has to guess where
149
+ * the value ends and backtrack when it guesses wrong. Here each style value is
150
+ * inspected once and the walk over the tag never turns around.
151
+ */
152
+ function hasHiddenStyle(tag) {
153
+ STYLE_ATTRIBUTE.lastIndex = 0;
154
+ for (let match = STYLE_ATTRIBUTE.exec(tag); match !== null; match = STYLE_ATTRIBUTE.exec(tag)) {
155
+ const quote = match[1];
156
+ const start = match.index + match[0].length;
157
+ const end = tag.indexOf(quote, start);
158
+ if (end < 0)
159
+ return false;
160
+ if (HIDDEN_VALUE.test(tag.slice(start, end)))
161
+ return true;
162
+ STYLE_ATTRIBUTE.lastIndex = end;
163
+ }
164
+ return false;
165
+ }
104
166
  /**
105
167
  * Extracts readable text from HTML.
106
168
  *
107
169
  * Deliberately not `mailparser`'s own `text` fallback: that keeps content the
108
170
  * recipient never sees. Anything hidden by inline CSS is a place to park an
109
171
  * instruction meant only for the model, so those elements are dropped before
110
- * the tags are stripped. Best effort, not a guarantee: nested same-name tags
111
- * end the non-greedy match early, and elements hidden via a stylesheet class
112
- * are not recognised at all.
172
+ * the tags are stripped.
173
+ *
174
+ * This is one pass over the input. The cursors below only ever move forward,
175
+ * which is the property that makes the whole function linear no matter what the
176
+ * sender writes: a start token that is never closed is answered once and then
177
+ * never looked for again, instead of restarting a bounded scan at every
178
+ * occurrence. Removal itself stays best effort — nested same-name tags end a
179
+ * block early, elements hidden via a stylesheet class are not recognised at
180
+ * all, and anything past the scan budget is left in place. That is acceptable
181
+ * for the same reason it always was: nothing downstream trusts the stripping,
182
+ * and the fencing in {@link wrapUntrusted} is what carries the weight.
113
183
  */
114
184
  export function htmlToText(html) {
115
- return html
116
- .slice(0, MAX_HTML_CHARS)
117
- .replace(HTML_COMMENT, ' ')
118
- .replace(NON_CONTENT_ELEMENT, ' ')
119
- .replace(HIDDEN_ELEMENT, ' ')
120
- .replace(/<br\s*\/?>/gi, '\n')
121
- .replace(/<\/(p|div|tr|li|h[1-6])>/gi, '\n')
122
- .replace(/<[^>]+>/g, ' ')
185
+ const source = html.slice(0, MAX_HTML_CHARS);
186
+ const out = [];
187
+ // Forward-only cursors. Each call may advance them, never rewind them, so
188
+ // across the whole document each scans the input at most once — the same
189
+ // reason a `-->` that does not exist costs one pass rather than one per
190
+ // `<!--`.
191
+ let nextGt = source.indexOf('>');
192
+ let nextCommentEnd = source.indexOf('-->');
193
+ const gtFrom = (from) => {
194
+ while (nextGt >= 0 && nextGt < from)
195
+ nextGt = source.indexOf('>', nextGt + 1);
196
+ return nextGt;
197
+ };
198
+ const commentEndFrom = (from) => {
199
+ while (nextCommentEnd >= 0 && nextCommentEnd < from) {
200
+ nextCommentEnd = source.indexOf('-->', nextCommentEnd + 1);
201
+ }
202
+ return nextCommentEnd;
203
+ };
204
+ let budget = source.length * CLOSER_SCAN_BUDGET_FACTOR + CLOSER_SCAN_BUDGET_FLOOR;
205
+ /** Where `</name>` starts, or -1 when nothing closes this element in time. */
206
+ const closingTagFrom = (name, from, window) => {
207
+ if (budget <= 0)
208
+ return -1;
209
+ const stop = Math.min(source.length, from + window);
210
+ const needle = `</${name}>`;
211
+ let at = source.indexOf('</', from);
212
+ while (at >= 0 && at + needle.length <= stop) {
213
+ if (source.slice(at, at + needle.length).toLowerCase() === needle) {
214
+ budget -= at - from;
215
+ return at;
216
+ }
217
+ at = source.indexOf('</', at + 2);
218
+ }
219
+ // Charged against how far the search actually looked, not against the
220
+ // window: a document with no `</` at all sends every one of these to the
221
+ // end of the input, and a budget that only counted the window would let an
222
+ // attacker buy those scans at a fiftieth of their price.
223
+ budget -= (at < 0 ? source.length : Math.min(at, stop)) - from;
224
+ return -1;
225
+ };
226
+ let i = 0;
227
+ while (i < source.length) {
228
+ const lt = source.indexOf('<', i);
229
+ if (lt < 0) {
230
+ out.push(source.slice(i));
231
+ break;
232
+ }
233
+ if (lt > i)
234
+ out.push(source.slice(i, lt));
235
+ if (source.startsWith('<!--', lt)) {
236
+ const end = commentEndFrom(lt + 4);
237
+ if (end >= 0 && end - lt <= MAX_REMOVED_BLOCK_CHARS) {
238
+ out.push(' ');
239
+ i = end + 3;
240
+ }
241
+ else {
242
+ // Nothing closes it anywhere ahead, so it is text that looks like
243
+ // markup rather than markup. Emitting it keeps the reader honest.
244
+ out.push('<');
245
+ i = lt + 1;
246
+ }
247
+ continue;
248
+ }
249
+ const gt = gtFrom(lt + 1);
250
+ if (gt < 0) {
251
+ // No `>` in the rest of the document: everything from here is text.
252
+ out.push(source.slice(lt));
253
+ break;
254
+ }
255
+ const tag = source.slice(lt, gt);
256
+ const name = TAG_NAME.exec(tag)?.[1]?.toLowerCase();
257
+ const closing = tag.startsWith('</');
258
+ if (name !== undefined && !closing) {
259
+ const window = NON_CONTENT_TAGS.has(name)
260
+ ? MAX_REMOVED_BLOCK_CHARS
261
+ : hasHiddenStyle(tag)
262
+ ? MAX_HIDDEN_ELEMENT_CHARS
263
+ : 0;
264
+ if (window > 0) {
265
+ const end = closingTagFrom(name, gt + 1, window);
266
+ if (end >= 0) {
267
+ out.push(' ');
268
+ i = end + name.length + 3;
269
+ continue;
270
+ }
271
+ }
272
+ }
273
+ if (BR_TAG.test(tag)) {
274
+ out.push('\n');
275
+ }
276
+ else if (closing && name !== undefined && BLOCK_TAGS.has(name)) {
277
+ out.push('\n');
278
+ }
279
+ else {
280
+ out.push(' ');
281
+ }
282
+ i = gt + 1;
283
+ }
284
+ return (out
285
+ .join('')
123
286
  .replace(/&nbsp;/gi, ' ')
124
287
  .replace(/&lt;/gi, '<')
125
288
  .replace(/&gt;/gi, '>')
126
289
  .replace(/&quot;/gi, '"')
127
290
  .replace(/&#39;/g, "'")
128
- .replace(/&amp;/gi, '&');
291
+ // Last, so a decoded `&amp;lt;` does not turn into a `<` the caller never
292
+ // received.
293
+ .replace(/&amp;/gi, '&'));
129
294
  }
130
295
  /**
131
296
  * Removes the characters a human reader cannot see but the model can.
@@ -136,6 +301,20 @@ export function htmlToText(html) {
136
301
  export function stripInvisible(input) {
137
302
  return input.replace(INVISIBLE_CHARS, '').replace(CONTROL_CHARS, '');
138
303
  }
304
+ /**
305
+ * The same characters, written out as escapes instead of removed.
306
+ *
307
+ * For the places where a string has to stay recognisable as the exact thing
308
+ * that was asked for — a confirmation dialog, an audit line. Stripping alone
309
+ * says "this is not what it looked like" and then shows something that looks
310
+ * like an ordinary name; this shows which characters were in it, so a person
311
+ * deciding whether to move mail into `Archive<U+202E>` can see that the folder is
312
+ * not the `Archive` they know.
313
+ */
314
+ 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);
317
+ }
139
318
  /**
140
319
  * Normalises text before it reaches the model: Unicode-folded, stripped of the
141
320
  * characters a human reader cannot see, auto-fetch markup defused, and
@@ -1 +1 @@
1
- {"version":3,"file":"analyze.js","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"analyze.js","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,15 @@
1
1
  import type { MessageStructureObject } from 'imapflow';
2
+ /**
3
+ * Extensions that are executable somewhere. The list is long on purpose: this
4
+ * is a refusal list applied on top of the content-type allowlist, and the cost
5
+ * of an extra entry is one unusual attachment that has to be fetched by other
6
+ * means.
7
+ *
8
+ * Exported so a test can walk it. An entry {@link extensionOf} cannot produce
9
+ * is not a stricter list, it is a longer one that refuses less — and nothing
10
+ * about reading the two declarations side by side says which is which.
11
+ */
12
+ export declare const EXECUTABLE_EXTENSIONS: Set<string>;
2
13
  export interface AttachmentCandidate {
3
14
  partId: string;
4
15
  filename: string;
@@ -23,6 +34,20 @@ export interface AttachmentPolicy {
23
34
  * separators is trying to be read as something it is not.
24
35
  */
25
36
  export declare function sanitizeFilename(raw: string | undefined): string;
37
+ /**
38
+ * The trailing extension of a filename, lowercased, or `''`.
39
+ *
40
+ * The character class and the length have to cover every entry of
41
+ * {@link EXECUTABLE_EXTENSIONS}, or the blocklist has entries that can never be
42
+ * read out of a name. Both used to be too narrow: `appref-ms` carries a hyphen
43
+ * and `application` is eleven characters, so both returned `''` — and an empty
44
+ * extension makes {@link checkPolicy} skip the executable check entirely rather
45
+ * than fail it. `Rechnung-2026.appref-ms` declared as `application/xml` (which
46
+ * is in the default type allowlist, and which a ClickOnce manifest genuinely
47
+ * is) therefore reached the download directory under its own name with no note
48
+ * against it. The property test over the whole set is what keeps the two in
49
+ * step from here on.
50
+ */
26
51
  export declare function extensionOf(filename: string): string;
27
52
  /**
28
53
  * Walks the MIME tree and returns every part that is an attachment.
@@ -9,8 +9,12 @@ const MAX_FILENAME_LENGTH = 120;
9
9
  * is a refusal list applied on top of the content-type allowlist, and the cost
10
10
  * of an extra entry is one unusual attachment that has to be fetched by other
11
11
  * means.
12
+ *
13
+ * Exported so a test can walk it. An entry {@link extensionOf} cannot produce
14
+ * is not a stricter list, it is a longer one that refuses less — and nothing
15
+ * about reading the two declarations side by side says which is which.
12
16
  */
13
- const EXECUTABLE_EXTENSIONS = new Set([
17
+ export const EXECUTABLE_EXTENSIONS = new Set([
14
18
  'ade',
15
19
  'adp',
16
20
  'app',
@@ -133,8 +137,22 @@ export function sanitizeFilename(raw) {
133
137
  ? `${cleaned.slice(0, MAX_FILENAME_LENGTH)}…`
134
138
  : cleaned;
135
139
  }
140
+ /**
141
+ * The trailing extension of a filename, lowercased, or `''`.
142
+ *
143
+ * The character class and the length have to cover every entry of
144
+ * {@link EXECUTABLE_EXTENSIONS}, or the blocklist has entries that can never be
145
+ * read out of a name. Both used to be too narrow: `appref-ms` carries a hyphen
146
+ * and `application` is eleven characters, so both returned `''` — and an empty
147
+ * extension makes {@link checkPolicy} skip the executable check entirely rather
148
+ * than fail it. `Rechnung-2026.appref-ms` declared as `application/xml` (which
149
+ * is in the default type allowlist, and which a ClickOnce manifest genuinely
150
+ * is) therefore reached the download directory under its own name with no note
151
+ * against it. The property test over the whole set is what keeps the two in
152
+ * step from here on.
153
+ */
136
154
  export function extensionOf(filename) {
137
- const match = /\.([A-Za-z0-9]{1,10})$/.exec(filename);
155
+ const match = /\.([A-Za-z0-9-]{1,16})$/.exec(filename);
138
156
  return match?.[1]?.toLowerCase() ?? '';
139
157
  }
140
158
  /**