@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.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/dist/analyze.d.ts +129 -0
- package/dist/analyze.js +313 -0
- package/dist/analyze.js.map +1 -0
- package/dist/approval.d.ts +45 -0
- package/dist/approval.js +69 -0
- package/dist/approval.js.map +1 -0
- package/dist/attachments.d.ts +55 -0
- package/dist/attachments.js +270 -0
- package/dist/attachments.js.map +1 -0
- package/dist/audit.d.ts +17 -0
- package/dist/audit.js +33 -0
- package/dist/audit.js.map +1 -0
- package/dist/config.d.ts +75 -0
- package/dist/config.js +202 -0
- package/dist/config.js.map +1 -0
- package/dist/confirm.d.ts +59 -0
- package/dist/confirm.js +92 -0
- package/dist/confirm.js.map +1 -0
- package/dist/download.d.ts +23 -0
- package/dist/download.js +65 -0
- package/dist/download.js.map +1 -0
- package/dist/draft.d.ts +34 -0
- package/dist/draft.js +119 -0
- package/dist/draft.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -0
- package/dist/imap.d.ts +161 -0
- package/dist/imap.js +300 -0
- package/dist/imap.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/message.d.ts +51 -0
- package/dist/message.js +155 -0
- package/dist/message.js.map +1 -0
- package/dist/resources.d.ts +16 -0
- package/dist/resources.js +89 -0
- package/dist/resources.js.map +1 -0
- package/dist/result.d.ts +57 -0
- package/dist/result.js +193 -0
- package/dist/result.js.map +1 -0
- package/dist/schema.d.ts +42 -0
- package/dist/schema.js +99 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +64 -0
- package/dist/server.js.map +1 -0
- package/dist/stream.d.ts +9 -0
- package/dist/stream.js +25 -0
- package/dist/stream.js.map +1 -0
- package/dist/tool-filter.d.ts +45 -0
- package/dist/tool-filter.js +171 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/tools/catalogue.d.ts +46 -0
- package/dist/tools/catalogue.js +67 -0
- package/dist/tools/catalogue.js.map +1 -0
- package/dist/tools/read.d.ts +4 -0
- package/dist/tools/read.js +576 -0
- package/dist/tools/read.js.map +1 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +291 -0
- package/dist/tools/write.js.map +1 -0
- 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
|
+
[](https://github.com/ni-c/imap-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@ni-c/imap-mcp)
|
|
5
|
+
[](https://www.npmjs.com/package/@ni-c/imap-mcp)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://github.com/ni-c/imap-mcp/pkgs/container/imap-mcp)
|
|
9
|
+
[](https://imap-mcp.ni-c.de)
|
|
10
|
+
[](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
|
+
* `` 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;
|