@ni-c/imap-mcp 0.2.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +308 -0
  3. package/dist/analyze.d.ts +129 -0
  4. package/dist/analyze.js +313 -0
  5. package/dist/analyze.js.map +1 -0
  6. package/dist/approval.d.ts +45 -0
  7. package/dist/approval.js +69 -0
  8. package/dist/approval.js.map +1 -0
  9. package/dist/attachments.d.ts +55 -0
  10. package/dist/attachments.js +270 -0
  11. package/dist/attachments.js.map +1 -0
  12. package/dist/audit.d.ts +17 -0
  13. package/dist/audit.js +33 -0
  14. package/dist/audit.js.map +1 -0
  15. package/dist/config.d.ts +75 -0
  16. package/dist/config.js +202 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/confirm.d.ts +59 -0
  19. package/dist/confirm.js +92 -0
  20. package/dist/confirm.js.map +1 -0
  21. package/dist/download.d.ts +23 -0
  22. package/dist/download.js +65 -0
  23. package/dist/download.js.map +1 -0
  24. package/dist/draft.d.ts +34 -0
  25. package/dist/draft.js +119 -0
  26. package/dist/draft.js.map +1 -0
  27. package/dist/errors.d.ts +15 -0
  28. package/dist/errors.js +24 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/imap.d.ts +161 -0
  31. package/dist/imap.js +300 -0
  32. package/dist/imap.js.map +1 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +36 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/message.d.ts +51 -0
  37. package/dist/message.js +155 -0
  38. package/dist/message.js.map +1 -0
  39. package/dist/resources.d.ts +16 -0
  40. package/dist/resources.js +89 -0
  41. package/dist/resources.js.map +1 -0
  42. package/dist/result.d.ts +57 -0
  43. package/dist/result.js +193 -0
  44. package/dist/result.js.map +1 -0
  45. package/dist/schema.d.ts +42 -0
  46. package/dist/schema.js +99 -0
  47. package/dist/schema.js.map +1 -0
  48. package/dist/server.d.ts +8 -0
  49. package/dist/server.js +64 -0
  50. package/dist/server.js.map +1 -0
  51. package/dist/stream.d.ts +9 -0
  52. package/dist/stream.js +25 -0
  53. package/dist/stream.js.map +1 -0
  54. package/dist/tool-filter.d.ts +45 -0
  55. package/dist/tool-filter.js +171 -0
  56. package/dist/tool-filter.js.map +1 -0
  57. package/dist/tools/catalogue.d.ts +46 -0
  58. package/dist/tools/catalogue.js +67 -0
  59. package/dist/tools/catalogue.js.map +1 -0
  60. package/dist/tools/read.d.ts +4 -0
  61. package/dist/tools/read.js +576 -0
  62. package/dist/tools/read.js.map +1 -0
  63. package/dist/tools/write.d.ts +5 -0
  64. package/dist/tools/write.js +291 -0
  65. package/dist/tools/write.js.map +1 -0
  66. package/package.json +70 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Willi Thiel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # imap-mcp
2
+
3
+ [![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)
4
+ [![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
+ [![sponsor](https://img.shields.io/badge/sponsor-ni--c-ea4aaa?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/ni-c)
11
+
12
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server for any IMAP
13
+ mailbox. It speaks IMAP rather than one vendor's API, so it works with whatever provider you
14
+ already have.
15
+
16
+ Lets MCP clients like Claude Code, Claude Desktop or Codex read and search your mail, organise
17
+ it into folders, save attachments and draft replies — with every message fenced as untrusted
18
+ content, and the write tools off unless you turn them on.
19
+
20
+ Eleven tools, not fifty: a mail account is a workflow, not an API surface, so related
21
+ operations are folded into one tool with a mode rather than split across many. And eleven is
22
+ the ceiling, not the floor — `IMAP_ALLOW_TOOLS=essential` registers a curated six instead, and
23
+ under the read-only default that narrows to four. See
24
+ [choosing which tools load](#choosing-which-tools-load).
25
+
26
+ <!-- <picture> is resolved against the colour scheme of the page showing it, so GitHub
27
+ picks the variant that matches its own theme toggle. npm strips <picture> and
28
+ <source> when it sanitises the README and keeps the <img>, which is why that
29
+ fallback carries its own dark card. The URLs are absolute because relative ones
30
+ are simply invisible on the npm package page. -->
31
+ <picture>
32
+ <source media="(prefers-color-scheme: dark)" srcset="https://imap-mcp.ni-c.de/architecture-dark.svg">
33
+ <source media="(prefers-color-scheme: light)" srcset="https://imap-mcp.ni-c.de/architecture-light.svg">
34
+ <img src="https://imap-mcp.ni-c.de/architecture.svg" alt="An MCP client talking to imap-mcp over stdio, which connects to an IMAP server over TLS and returns message bodies fenced as untrusted content" width="800">
35
+ </picture>
36
+
37
+ <img src="https://imap-mcp.ni-c.de/demo.gif" alt="Listing the tools registered under the read-only default, listing an inbox, and reading a phishing message — which comes back with the injection shapes named first, the body fenced line by line, and the tracking beacon defused" width="800">
38
+
39
+ ## What makes it different
40
+
41
+ **It cannot send mail. That is the feature.** An agent with access to private data, exposure to
42
+ untrusted content, and a channel to the outside world is exploitable by anyone who can put a
43
+ message in the inbox — the pattern that produced
44
+ [EchoLeak](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-32711), where one
45
+ crafted email exfiltrated internal data from Microsoft 365 Copilot with no user interaction.
46
+ This server has the first two and deliberately not the third. `save_draft` writes the reply
47
+ into your Drafts folder; you send it from your own mail client. No amount of clever text in a
48
+ message can make this server post anything anywhere.
49
+
50
+ **Writes are off until you turn them on.** With only `IMAP_HOST`, `IMAP_USER` and
51
+ `IMAP_PASSWORD` set, the server registers six read tools and nothing else. The mailbox tools
52
+ appear with `IMAP_READ_ONLY=false` — note the default is `true`, the opposite of the other
53
+ servers in this family, because this one reaches a mailbox. Tools that are off are not registered at all — a
54
+ capability the model cannot see is one it cannot be talked into using.
55
+
56
+ **Mail is treated as hostile input, because it is.** Anyone in the world can put text in your
57
+ inbox. Message bodies are fenced between markers carrying a per-call random nonce, _and_ every
58
+ line inside them is prefixed with that nonce, so the "this is data" signal does not stop at the
59
+ edges of a long forwarded thread. A reminder follows the block, because otherwise the last
60
+ instruction-shaped sentence in the model's context is the attacker's. Zero-width characters and
61
+ directional overrides are stripped before the model sees anything, hidden HTML elements are
62
+ dropped on a best-effort basis (the fencing, not the stripping, is what carries the weight), and
63
+ markdown image syntax — inline and reference style — is defused so a rendering client cannot be
64
+ made to fetch a tracking URL.
65
+
66
+ Alongside the message you get a server-side assessment: the SPF/DKIM/DMARC verdicts with the
67
+ authserv-id they came from, which prompt-injection shapes matched, and which words mix Latin
68
+ with Cyrillic or Greek letters. When something matches, the warning is the first thing in the
69
+ result rather than a field buried in JSON.
70
+
71
+ Those verdicts carry a `forgeable` flag, and by default it is always `true`. A sender can write
72
+ an `Authentication-Results` header of their own, and if your provider does not add one, theirs
73
+ is the only one there — nothing inside the message distinguishes the two. Set
74
+ `IMAP_TRUSTED_AUTHSERV_ID` to the id your provider stamps (it is the first token of the header
75
+ on any message you already have) and only that id counts as authentic. Until you do, `spf=pass`
76
+ is reported as what it is: a claim, from a header anyone could have written.
77
+
78
+ **"New mail" that actually works.** The server tags messages it has handed over with a custom
79
+ IMAP keyword (`AiSeen` by default), so `list_new_messages` returns each message once. The human
80
+ `\Seen` state is never touched — everything is read with `BODY.PEEK`.
81
+
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.
85
+
86
+ ## Requirements
87
+
88
+ - Node.js 22 or newer
89
+ - An IMAP account. Providers with two-factor authentication generally need an app-specific
90
+ password.
91
+
92
+ ## Configuration
93
+
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 |
114
+
115
+ Booleans are compared against the literal string `true`; `1`, `yes` and `True` are not true.
116
+ `IMAP_READ_ONLY` is the mirror image: only the literal `false` turns it off, so a typo leaves
117
+ the write tools unregistered.
118
+
119
+ > **`IMAP_ALLOW_WRITE` is gone.** It has been replaced by `IMAP_READ_ONLY`, and an installation
120
+ > that still sets it **refuses to start**. Silently ignoring a removed security variable is the
121
+ > worst of the options: whoever set it once believes it is still in force. The default is
122
+ > unchanged — writes are still off unless you ask for them.
123
+
124
+ ### Choosing which tools load
125
+
126
+ `IMAP_ALLOW_TOOLS` and `IMAP_DENY_TOOLS` take comma-separated tool names; a trailing `*`
127
+ matches a whole family. `essential` is a curated preset of six — `list_mailboxes`,
128
+ `list_new_messages`, `list_messages`, `get_message`, `set_message_flags` and `move_messages`.
129
+ Four of those are read tools, so it stays useful under the read-only default.
130
+
131
+ ```sh
132
+ IMAP_ALLOW_TOOLS=essential
133
+ IMAP_ALLOW_TOOLS=list_new_messages,get_message,move_messages
134
+ IMAP_DENY_TOOLS=delete_messages
135
+ ```
136
+
137
+ An entry that matches no tool aborts startup and names it, so a typo cannot silently hide a
138
+ tool — an absent tool is not something anyone traces back to an environment variable. A
139
+ filtered tool is never registered, so it is absent from `tools/list` and unknown to
140
+ `tools/call` alike, exactly like a write tool under `IMAP_READ_ONLY`.
141
+
142
+ It covers **tools**. The attachment resources this server also exposes are not filtered.
143
+
144
+ If you run several of these servers at once, [mcp-hub](https://mcp-hub.ni-c.de) is the other
145
+ answer — its `/hub` endpoint replaces every server's tools with six meta-tools.
146
+ The password is deleted from the process environment as soon as it is read, so it is not
147
+ visible to child processes or in `/proc/<pid>/environ`.
148
+
149
+ Without `IMAP_DOWNLOAD_DIR` this server never writes to the filesystem. The two size limits are
150
+ separate on purpose: one protects the model's context window, the other protects your disk.
151
+
152
+ The server starts without credentials on purpose — it completes the handshake and lists its
153
+ tools, and every call then fails with setup instructions instead of reaching a server.
154
+
155
+ ## Installation
156
+
157
+ ### Claude Code
158
+
159
+ ```sh
160
+ claude mcp add imap-mcp \
161
+ -e IMAP_HOST=imap.example.net -e IMAP_USER=you@example.net -e IMAP_PASSWORD=… \
162
+ -- npx -y @ni-c/imap-mcp
163
+ ```
164
+
165
+ ### Claude Desktop
166
+
167
+ ```json
168
+ {
169
+ "mcpServers": {
170
+ "imap-mcp": {
171
+ "command": "npx",
172
+ "args": ["-y", "@ni-c/imap-mcp"],
173
+ "env": {
174
+ "IMAP_HOST": "imap.example.net",
175
+ "IMAP_USER": "you@example.net",
176
+ "IMAP_PASSWORD": "…"
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
183
+ ### Codex
184
+
185
+ ```toml
186
+ [mcp_servers.imap-mcp]
187
+ command = "npx"
188
+ args = ["-y", "@ni-c/imap-mcp"]
189
+ env = { IMAP_HOST = "imap.example.net", IMAP_USER = "you@example.net", IMAP_PASSWORD = "…" }
190
+ ```
191
+
192
+ ### Docker
193
+
194
+ ```sh
195
+ docker run --rm -i \
196
+ -e IMAP_HOST=imap.example.net \
197
+ -e IMAP_USER=you@example.net \
198
+ -e IMAP_PASSWORD=… \
199
+ ghcr.io/ni-c/imap-mcp
200
+ ```
201
+
202
+ Saving attachments needs a writable directory, and the image runs as uid 1000 — so a
203
+ bind mount has to be owned by it on the host: `-e IMAP_DOWNLOAD_DIR=/data -v
204
+ "$PWD/attachments:/data"` with `chown 1000:1000 attachments`. Without
205
+ `IMAP_DOWNLOAD_DIR` the container never writes anything.
206
+
207
+ ## Tools
208
+
209
+ **Read** — always registered
210
+
211
+ | Tool | What it does |
212
+ | ------------------- | ---------------------------------------------------------------------------------------- |
213
+ | `get_server_info` | Capabilities, permanent flags, whether the keyword is storable, which tool groups are on |
214
+ | `list_mailboxes` | Every folder with message and unseen counts and its special-use role |
215
+ | `list_messages` | Lists and searches: sender, recipient, subject, body, date range, flags |
216
+ | `list_new_messages` | Messages not handed over yet; marks them afterwards, `dry_run` to preview |
217
+ | `get_message` | Headers and body, fenced untrusted, plus the security assessment; `include_thread` |
218
+ | `get_attachments` | Without `part_id` lists them, with `part_id` reads or saves one |
219
+
220
+ **Mailbox** — needs `IMAP_READ_ONLY=false`
221
+
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 |
229
+
230
+ 👤 raises a dialog the model cannot answer · 🔒 needs a confirmation token: call once to
231
+ receive one, then again with it.
232
+
233
+ `copy` is confirmed as well as `move`, because the thing that cannot be taken back is not
234
+ the deletion — it is the disclosure. A destination is a free-form folder name, and on a
235
+ shared account or a public namespace one call hands every message to everyone who can read
236
+ it, leaving the source folder untouched. For the same reason `set_message_flags` refuses to
237
+ add `\Deleted`: it is half a deletion, and the next client to close the mailbox may finish
238
+ it. Use `delete_messages`, which asks.
239
+
240
+ Neither a confirmation nor a dialog quotes a mailbox name inside its own sentence — folder
241
+ names come from the account, which on a shared mailbox means a colleague chose them.
242
+
243
+ Attachments are also available as MCP resources at `imap://message/{uid}/part/{partId}`, which
244
+ matters where the server has no useful filesystem. The resource path runs the same allowlist,
245
+ size limit and magic-byte check as the tool — it is not a second, unguarded door.
246
+
247
+ ## Not exposed, on purpose
248
+
249
+ No sending, no SMTP, no raw IMAP passthrough, no `APPEND` of arbitrary MIME, no HTML
250
+ composition, no OAuth2. The first is the whole security argument (see `SECURITY.md`); the
251
+ second would make every guard here optional; the last is planned but needs a test account
252
+ before it ships.
253
+
254
+ And one thing the tool filter does not cover: **attachment resources**. `IMAP_ALLOW_TOOLS`
255
+ narrows `tools/list`, not `resources/list`, so a server with a narrow allow list still serves
256
+ those. `IMAP_DOWNLOAD_DIR` and the content-type allowlist are what constrain them — worth
257
+ knowing before concluding that a filtered install reaches less of the mailbox than it does.
258
+
259
+ ## Safety
260
+
261
+ - **Every result carrying mailbox content is marked untrusted**, message bodies additionally
262
+ fenced with a per-call nonce and marked line by line.
263
+ - **Attachments pass two independent gates.** The declaration is checked against a
264
+ content-type allowlist, an executable-extension refusal list and a size ceiling; the bytes
265
+ are then checked against magic numbers. An executable renamed to `.pdf` and declared
266
+ `application/pdf` clears every declaration check and fails on its bytes — including when
267
+ saving to disk, where it would be more dangerous, not less.
268
+ - **A `part_id` must come from a listing call**, so the body cannot be pulled out through the
269
+ attachment tool and escape its framing.
270
+ - **Downloads cannot escape their directory.** The target comes only from the environment, the
271
+ filename is sanitised, the resolved path is re-checked, and the file is opened with `wx` and
272
+ mode `0600` — so nothing is overwritten and no planted symlink is followed.
273
+ - **Mailbox names, flags and addresses are refused if they contain line breaks.** IMAP is a
274
+ line protocol and a draft is a mail header; a CR is an injection primitive, not a typo.
275
+ - **TLS is never disabled globally.** `IMAP_INSECURE_TLS` is scoped to the connection it names;
276
+ `NODE_TLS_REJECT_UNAUTHORIZED` appears nowhere.
277
+ - **Every change to the mailbox is logged to stderr** with the UIDs and folder — never the
278
+ subject. stderr is the one channel the model does not read.
279
+ - **Responses are bounded.** Whole items are dropped rather than the JSON being sliced, and the
280
+ truncation notice comes first so the recovery hint survives.
281
+
282
+ `SECURITY.md` has the trust model, what these measures do _not_ cover, and how to report a
283
+ vulnerability.
284
+
285
+ ## Development
286
+
287
+ ```bash
288
+ npm install
289
+ npm test
290
+ npm run build
291
+ ```
292
+
293
+ The test suite runs against an in-memory IMAP fake, so it needs no server and no
294
+ network. For a live server to point the real thing at, see
295
+ [CONTRIBUTING.md](CONTRIBUTING.md) — it starts a throwaway mailbox in a container.
296
+
297
+ ## Releasing
298
+
299
+ 1. Add the CHANGELOG entry and bump `package.json`.
300
+ 2. `npm run lint && npm run build && npm run test:coverage`
301
+ 3. Commit, then push a signed tag: `git tag -s vX.Y.Z -m "vX.Y.Z" && git push origin main vX.Y.Z`
302
+
303
+ The release workflow publishes to npm (Trusted Publishing, with provenance), creates
304
+ the GitHub release from the CHANGELOG section and updates the MCP Registry entry.
305
+
306
+ ## License
307
+
308
+ MIT © Willi Thiel
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Cap on a rendered message body. A single mail can carry megabytes of quoted
3
+ * history; past this point it stops informing the model and starts crowding out
4
+ * everything else in the context.
5
+ */
6
+ export declare const MAX_BODY_CHARS = 50000;
7
+ export interface SecurityAssessment {
8
+ /** Names of the injection shapes that matched, empty when none did. */
9
+ suspicious: string[];
10
+ /** Mixed-script words, a homoglyph-spoofing signal. Capped for brevity. */
11
+ scriptMix: string[];
12
+ auth: {
13
+ spf: string;
14
+ dkim: string;
15
+ dmarc: string;
16
+ /** authserv-id of the header the verdicts were read from. */
17
+ authservId: string | undefined;
18
+ /**
19
+ * True when the header could not be attributed to the account's own
20
+ * provider — in which case the sender may have written it.
21
+ */
22
+ forgeable: boolean;
23
+ };
24
+ }
25
+ /**
26
+ * Extracts readable text from HTML.
27
+ *
28
+ * Deliberately not `mailparser`'s own `text` fallback: that keeps content the
29
+ * recipient never sees. Anything hidden by inline CSS is a place to park an
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.
34
+ */
35
+ export declare function htmlToText(html: string): string;
36
+ /**
37
+ * Removes the characters a human reader cannot see but the model can.
38
+ *
39
+ * Shared with the attachment code: a filename gets the same treatment as a
40
+ * body, because it is rendered next to one and read with the same eyes.
41
+ */
42
+ export declare function stripInvisible(input: string): string;
43
+ /**
44
+ * Normalises text before it reaches the model: Unicode-folded, stripped of the
45
+ * characters a human reader cannot see, auto-fetch markup defused, and
46
+ * length-capped.
47
+ *
48
+ * The defusing belongs here, at the boundary, rather than at the call sites
49
+ * that happen to render a body. Every string this function takes was written by
50
+ * whoever sent the message, and a subject is as good a place to park
51
+ * `![](https://attacker.example/p?s=)` as a body is — better, because a subject
52
+ * is short, quoted back by the model constantly, and was landing in the JSON of
53
+ * every listing untouched. NFKC runs first on purpose: a fullwidth `![]()`
54
+ * subject folds *into* valid markdown image syntax, so defusing before
55
+ * normalising would miss it.
56
+ */
57
+ export declare function sanitizeText(input: string, maxChars?: number): string;
58
+ /** Names of the injection shapes present in `text`. */
59
+ export declare function detectSuspicious(text: string): string[];
60
+ /**
61
+ * Words that mix Latin with Cyrillic or Greek letters.
62
+ *
63
+ * `paypal` written with a Cyrillic \u0430 renders identically to the real
64
+ * thing. NFKC does not fold those together — nothing does, they are genuinely
65
+ * different letters — so the only defence is to point at the word and say so.
66
+ */
67
+ export declare function detectScriptMix(text: string): string[];
68
+ /**
69
+ * Reads the SPF/DKIM/DMARC verdict out of the `Authentication-Results` header.
70
+ *
71
+ * The header is not inherently trustworthy: a sender can include one of their
72
+ * own, and only a receiving server that filters inbound copies guarantees the
73
+ * verdicts are its own. Only the *topmost* header is read — a receiving server
74
+ * that adds its own prepends it, so a forged copy further down is ignored.
75
+ *
76
+ * That alone is not enough, and what used to sit here is worth naming because
77
+ * it looked like a defence. The authserv-id was compared against the account's
78
+ * own domain, and a match reported `forgeable: false`. But a sender knows the
79
+ * account's domain — they just addressed mail to it — so on any account whose
80
+ * provider does not add an Authentication-Results header of its own (common on
81
+ * small Postfix/Dovecot setups, and on any mailbox where filtering happens
82
+ * elsewhere) the sender's header was the topmost one, and
83
+ * `Authentication-Results: mail.example.net; spf=pass; dkim=pass; dmarc=pass`
84
+ * bought a spoofed message the server's own vouching. The heuristic gave its
85
+ * strongest answer in exactly the case it could not verify.
86
+ *
87
+ * Nothing in the message can settle this, so the operator does:
88
+ * `IMAP_TRUSTED_AUTHSERV_ID` names the id their provider stamps. Set, it is the
89
+ * only id that yields `forgeable: false`. Unset, every verdict is reported as
90
+ * forgeable — noisier, and the honest reading of "pass, says a header anyone
91
+ * could have written".
92
+ */
93
+ export declare function parseAuthResults(header: string | undefined, trustedAuthservId?: string): SecurityAssessment['auth'];
94
+ /** Runs every signal over the rendered text plus the headers. */
95
+ export declare function assess(text: string, authHeader: string | undefined, trustedAuthservId?: string): SecurityAssessment;
96
+ /**
97
+ * Neutralises the markup a rendering client would fetch on its own.
98
+ *
99
+ * This is the EchoLeak channel (CVE-2025-32711): the injected instruction tells
100
+ * the model to put a URL in its answer, the client renders the answer as
101
+ * markdown, and fetching the image ships whatever is in the query string to the
102
+ * attacker. No click, no warning. Breaking the image syntax stops the automatic
103
+ * fetch; the URL itself stays readable, because a human may well want to see
104
+ * where it pointed.
105
+ */
106
+ export declare function defuseAutoFetch(text: string): string;
107
+ /**
108
+ * Wraps message content in a delimiter the message itself cannot forge, and
109
+ * marks every line of it as untrusted.
110
+ *
111
+ * Three separate mechanisms, because each covers a different failure:
112
+ *
113
+ * - The **random nonce** in the markers cannot be reproduced by text written
114
+ * before this call happened, so a message cannot close the block early and
115
+ * continue in the server's voice.
116
+ * - The **per-line prefix** is datamarking. A delimiter only signals provenance
117
+ * at the two edges; once the model is a hundred lines deep in a forwarded
118
+ * thread, nothing on the page still says "this is data". Research measures
119
+ * datamarking above plain delimiting for exactly that reason. Per line rather
120
+ * than per word keeps the cost at a few tokens per line instead of doubling
121
+ * the section, and leaves the text readable.
122
+ * - The **reminder after the block** answers the recency effect: without it the
123
+ * last instruction-shaped sentence in the context is the attacker's.
124
+ *
125
+ * None of this is a guarantee. Measured, delimiting takes a typical model from
126
+ * roughly 61% to 90% resistance — a real improvement and nowhere near a wall.
127
+ * The load-bearing defence is that this server has no way to send mail.
128
+ */
129
+ export declare function wrapUntrusted(body: string): string;