@rndmjoker/proton-mcp 0.4.4

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 (141) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +405 -0
  3. package/dist/activity.d.ts +49 -0
  4. package/dist/activity.js +72 -0
  5. package/dist/activity.js.map +1 -0
  6. package/dist/bridge/certificate.d.ts +75 -0
  7. package/dist/bridge/certificate.js +176 -0
  8. package/dist/bridge/certificate.js.map +1 -0
  9. package/dist/bridge/connection.d.ts +99 -0
  10. package/dist/bridge/connection.js +244 -0
  11. package/dist/bridge/connection.js.map +1 -0
  12. package/dist/bridge/errors.d.ts +35 -0
  13. package/dist/bridge/errors.js +56 -0
  14. package/dist/bridge/errors.js.map +1 -0
  15. package/dist/config.d.ts +92 -0
  16. package/dist/config.js +142 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials/availability.d.ts +62 -0
  19. package/dist/credentials/availability.js +107 -0
  20. package/dist/credentials/availability.js.map +1 -0
  21. package/dist/credentials/encrypted-file.d.ts +50 -0
  22. package/dist/credentials/encrypted-file.js +156 -0
  23. package/dist/credentials/encrypted-file.js.map +1 -0
  24. package/dist/credentials/keyring.d.ts +48 -0
  25. package/dist/credentials/keyring.js +97 -0
  26. package/dist/credentials/keyring.js.map +1 -0
  27. package/dist/credentials/plain-file.d.ts +24 -0
  28. package/dist/credentials/plain-file.js +78 -0
  29. package/dist/credentials/plain-file.js.map +1 -0
  30. package/dist/credentials/probe-keyring.d.ts +11 -0
  31. package/dist/credentials/probe-keyring.js +57 -0
  32. package/dist/credentials/probe-keyring.js.map +1 -0
  33. package/dist/credentials/session.d.ts +15 -0
  34. package/dist/credentials/session.js +21 -0
  35. package/dist/credentials/session.js.map +1 -0
  36. package/dist/credentials/store.d.ts +80 -0
  37. package/dist/credentials/store.js +94 -0
  38. package/dist/credentials/store.js.map +1 -0
  39. package/dist/in-flight.d.ts +33 -0
  40. package/dist/in-flight.js +78 -0
  41. package/dist/in-flight.js.map +1 -0
  42. package/dist/mail/actions.d.ts +116 -0
  43. package/dist/mail/actions.js +265 -0
  44. package/dist/mail/actions.js.map +1 -0
  45. package/dist/mail/attachments.d.ts +39 -0
  46. package/dist/mail/attachments.js +86 -0
  47. package/dist/mail/attachments.js.map +1 -0
  48. package/dist/mail/compose.d.ts +264 -0
  49. package/dist/mail/compose.js +395 -0
  50. package/dist/mail/compose.js.map +1 -0
  51. package/dist/mail/drafts.d.ts +138 -0
  52. package/dist/mail/drafts.js +449 -0
  53. package/dist/mail/drafts.js.map +1 -0
  54. package/dist/mail/ids.d.ts +78 -0
  55. package/dist/mail/ids.js +135 -0
  56. package/dist/mail/ids.js.map +1 -0
  57. package/dist/mail/labels.d.ts +61 -0
  58. package/dist/mail/labels.js +139 -0
  59. package/dist/mail/labels.js.map +1 -0
  60. package/dist/mail/markup.d.ts +91 -0
  61. package/dist/mail/markup.js +265 -0
  62. package/dist/mail/markup.js.map +1 -0
  63. package/dist/mail/messages.d.ts +147 -0
  64. package/dist/mail/messages.js +219 -0
  65. package/dist/mail/messages.js.map +1 -0
  66. package/dist/mail/quote.d.ts +65 -0
  67. package/dist/mail/quote.js +157 -0
  68. package/dist/mail/quote.js.map +1 -0
  69. package/dist/mail/search.d.ts +66 -0
  70. package/dist/mail/search.js +107 -0
  71. package/dist/mail/search.js.map +1 -0
  72. package/dist/mail/send.d.ts +61 -0
  73. package/dist/mail/send.js +132 -0
  74. package/dist/mail/send.js.map +1 -0
  75. package/dist/mime/parse.d.ts +110 -0
  76. package/dist/mime/parse.js +194 -0
  77. package/dist/mime/parse.js.map +1 -0
  78. package/dist/server.d.ts +13 -0
  79. package/dist/server.js +387 -0
  80. package/dist/server.js.map +1 -0
  81. package/dist/session.d.ts +86 -0
  82. package/dist/session.js +220 -0
  83. package/dist/session.js.map +1 -0
  84. package/dist/settings.d.ts +52 -0
  85. package/dist/settings.js +80 -0
  86. package/dist/settings.js.map +1 -0
  87. package/dist/tools/actions.d.ts +21 -0
  88. package/dist/tools/actions.js +133 -0
  89. package/dist/tools/actions.js.map +1 -0
  90. package/dist/tools/capabilities.d.ts +52 -0
  91. package/dist/tools/capabilities.js +96 -0
  92. package/dist/tools/capabilities.js.map +1 -0
  93. package/dist/tools/confirm.d.ts +84 -0
  94. package/dist/tools/confirm.js +212 -0
  95. package/dist/tools/confirm.js.map +1 -0
  96. package/dist/tools/drafts.d.ts +21 -0
  97. package/dist/tools/drafts.js +199 -0
  98. package/dist/tools/drafts.js.map +1 -0
  99. package/dist/tools/failures.d.ts +46 -0
  100. package/dist/tools/failures.js +157 -0
  101. package/dist/tools/failures.js.map +1 -0
  102. package/dist/tools/format.d.ts +55 -0
  103. package/dist/tools/format.js +220 -0
  104. package/dist/tools/format.js.map +1 -0
  105. package/dist/tools/guidance.d.ts +38 -0
  106. package/dist/tools/guidance.js +226 -0
  107. package/dist/tools/guidance.js.map +1 -0
  108. package/dist/tools/interface.d.ts +28 -0
  109. package/dist/tools/interface.js +63 -0
  110. package/dist/tools/interface.js.map +1 -0
  111. package/dist/tools/labels.d.ts +21 -0
  112. package/dist/tools/labels.js +92 -0
  113. package/dist/tools/labels.js.map +1 -0
  114. package/dist/tools/list-folders.d.ts +14 -0
  115. package/dist/tools/list-folders.js +70 -0
  116. package/dist/tools/list-folders.js.map +1 -0
  117. package/dist/tools/messages.d.ts +9 -0
  118. package/dist/tools/messages.js +221 -0
  119. package/dist/tools/messages.js.map +1 -0
  120. package/dist/tools/send.d.ts +29 -0
  121. package/dist/tools/send.js +236 -0
  122. package/dist/tools/send.js.map +1 -0
  123. package/dist/tools/sign-in-required.d.ts +94 -0
  124. package/dist/tools/sign-in-required.js +168 -0
  125. package/dist/tools/sign-in-required.js.map +1 -0
  126. package/dist/web/layout.d.ts +75 -0
  127. package/dist/web/layout.js +590 -0
  128. package/dist/web/layout.js.map +1 -0
  129. package/dist/web/pages.d.ts +79 -0
  130. package/dist/web/pages.js +284 -0
  131. package/dist/web/pages.js.map +1 -0
  132. package/dist/web/sections.d.ts +105 -0
  133. package/dist/web/sections.js +207 -0
  134. package/dist/web/sections.js.map +1 -0
  135. package/dist/web/security.d.ts +122 -0
  136. package/dist/web/security.js +180 -0
  137. package/dist/web/security.js.map +1 -0
  138. package/dist/web/server.d.ts +153 -0
  139. package/dist/web/server.js +622 -0
  140. package/dist/web/server.js.map +1 -0
  141. package/package.json +70 -0
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 RndmJoker
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 OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,405 @@
1
+ # proton-mcp
2
+
3
+ An MCP server for Proton Mail. It talks to a locally running Proton Mail Bridge and makes your mailbox available to AI assistants such as Claude.
4
+
5
+ **Status: under construction.** Reading works and can be used, and so does signing in through the browser. Labels, moving between folders, read state, trash, drafts and sending all work. Every send asks you first, and that question is enforced by the server rather than requested in a prompt. See [Status](#status) for the details.
6
+
7
+ ## Why everything runs locally
8
+
9
+ Proton Mail Bridge only listens on `127.0.0.1`. It cannot be reached from outside, and that is by design: your messages are decrypted on your own machine.
10
+
11
+ This server therefore runs on your machine as well, started by the AI client as a child process. Your mail leaves your machine only as far as you show content to the assistant yourself. There is no remote access and no service in between.
12
+
13
+ ## Status
14
+
15
+ ### What works
16
+
17
+ | Tool | What it does |
18
+ | :--- | :--- |
19
+ | `list_folders` | Lists folders and labels, grouped by kind |
20
+ | `list_messages` | Headers of a mailbox, newest first, with paging |
21
+ | `search_messages` | Full text, subject, sender, recipient, date range, read state, star, size |
22
+ | `get_message` | One message as readable text, HTML converted, budgeted |
23
+ | `get_attachment` | One attachment by index, textual types only |
24
+ | `add_label` | Applies an existing label. The message stays in its folder |
25
+ | `remove_label` | Removes a label. Folder, other labels and the message itself are untouched |
26
+ | `move_messages` | Moves messages into a folder. Labels survive the move |
27
+ | `set_flags` | Marks read or unread and sets or clears the star |
28
+ | `trash_messages` | Moves messages to the trash, which is what deletion means here |
29
+ | `create_draft` | Writes a draft. Nothing is sent |
30
+ | `update_draft` | Replaces a draft, keeping its id |
31
+ | `reply_draft` | Prepares a reply as a draft, with the original quoted |
32
+ | `forward_draft` | Prepares a forward as a draft, attachments and all |
33
+ | `list_drafts` | The drafts, newest first |
34
+ | `send_message` | Composes and sends. Asks you first, always |
35
+ | `send_reply` | Replies and sends, keeping the conversation intact |
36
+ | `send_forward` | Forwards and sends, attachments and all |
37
+ | `send_draft` | Sends a draft that is already written |
38
+
39
+ Every tool that composes a message takes either `text` or `html`, never both: Proton drops the plain text half of a message that carries markup, so the half you confirmed would be the half that never arrived.
40
+
41
+ The server also tells an assistant how it works, so the rules are not something a model has to infer from failures. A short text travels with every connection naming the four things that go wrong quietly, and two longer guides can be read on demand: `proton-mcp://guide/writing` for composing messages and `proton-mcp://guide/bridge` for the measured behaviour of the Bridge itself.
42
+
43
+ Underneath: a held IMAP connection that recovers from a Bridge restart, stable identifiers based on the Message-ID, HTML to text conversion, filtering of the public key Proton attaches to every sent message, and a character budget so a single message cannot exhaust a context window.
44
+
45
+ There is also a **local web interface**, on `127.0.0.1` only, running for as long as the server does. It is divided into sections, reached from a rail on the left:
46
+
47
+ | Section | What is there |
48
+ | :--- | :--- |
49
+ | Overview | Connection, address, where the password is kept, read-only mode, both Bridge ports, uptime and counters. State only: every control lives in the section it acts on |
50
+ | Folders and labels | On its own page, only when asked. They belong to the whole account, so a folder named after a bank gives that away on its own |
51
+ | Bridge | The ports, the pinned certificate, and a button that tries the connection. Ports are stored in `~/.config/proton-mcp/settings.json`; an explicit environment variable wins and the form disappears |
52
+ | Credentials | Where the Bridge password is kept, and signing out, which clears it from every store at once |
53
+ | Activity | What is running and for how long, plus the last 50 finished calls. Tool names and timings only, never arguments |
54
+
55
+ Before signing in there are two more: the sign-in form, where the address and Bridge password go into the browser rather than into a client configuration or a conversation, with four places to keep the password and the cost of each stated; and the unlock page, which after a restart asks for the master password alone, since the Bridge password is already on disk.
56
+
57
+ **None of it uses JavaScript.** The Content-Security-Policy forbids it, so navigation is links and anything interactive is a form control. It follows the system light or dark setting, which needs no script either.
58
+
59
+ When a tool is called before anyone has signed in, the server does not ask for the password in the conversation. It returns the address of that page, and on clients that support URL elicitation it asks the client to open it directly.
60
+
61
+ ### What does not exist yet
62
+
63
+ This table says what the server cannot do today. The issue behind each entry says what is planned and how far it has got.
64
+
65
+ | Missing | Tracked in |
66
+ | :--- | :--- |
67
+ | Attachments composed from files on this machine | [#17](https://github.com/RndmJoker/proton-mcp/issues/17) |
68
+ | Choosing per tool what an assistant may do | [#19](https://github.com/RndmJoker/proton-mcp/issues/19) |
69
+ | Choosing how much markup a message may carry | [#24](https://github.com/RndmJoker/proton-mcp/issues/24) |
70
+ | A fixed extra recipient the assistant cannot remove | [#18](https://github.com/RndmJoker/proton-mcp/issues/18) |
71
+
72
+ **Binary attachments** are missing from that table on purpose. They are not an unfinished feature waiting for its turn: `get_attachment` refuses anything that is not text and says why. Handing one over would mean either base64 in the context window, which is unusable, or writing decrypted content to your disk, which this server does not do. Changing that needs a decision first, not an implementation, so there is nothing to track yet.
73
+
74
+ ### What will never exist
75
+
76
+ These limits come from the Bridge itself and cannot be worked around:
77
+
78
+ - **No calendar, no contacts.** Neither flows through the Bridge.
79
+ - **No Proton-specific features.** No message expiration, no password-protected messages to outside recipients.
80
+ - **No access to filters, forwarding rules or account settings.** This is also a deliberate security boundary, see below.
81
+ - **No server-side threading or sorting.** The Bridge supports neither `THREAD` nor `SORT`, so ordering happens in this server, which costs one date read per message before a page can be cut out.
82
+ - **No permanent deletion.** Deleted will mean moved to trash, on purpose.
83
+
84
+ ## Requirements
85
+
86
+ - **A paid Proton plan.** The Bridge is not included in the free tier.
87
+ - **Proton Mail Bridge**, installed, running and unlocked. The official application expects a desktop. On a machine without one, [proton-mail-bridge-docker](https://github.com/RndmJoker/proton-mail-bridge-docker) runs it in a container instead. That is a way to get a bridge, not a way to put one somewhere else: it decrypts your mail wherever it runs, so a bridge on another machine means your mail is decrypted on that machine. The section above still applies, and the container's own readme says the same.
88
+ - **Node.js 24 or newer.**
89
+ - **The Bridge password**, which is not your Proton account password. The Bridge generates one per account. You find it in the Bridge application under the account, or in a terminal via `protonmail-bridge --cli` and then `info`.
90
+
91
+ ## Installing
92
+
93
+ ### Hand it to your assistant
94
+
95
+ If you already have an AI assistant with a shell, the shortest route is to let it do the work:
96
+
97
+ ```
98
+ Fetch https://raw.githubusercontent.com/RndmJoker/proton-mcp/main/prompt.md and follow it
99
+ ```
100
+
101
+ It checks the prerequisites, registers the server with your client and tells you what to do next. **It never asks for your Bridge password** and cannot: that goes into the local web interface, in your browser, and nowhere else.
102
+
103
+ ### From npm
104
+
105
+ ```bash
106
+ npx @rndmjoker/proton-mcp
107
+ ```
108
+
109
+ That is also the command to register with your client, so nothing has to be installed permanently. The published package carries provenance, so npm can show which commit and which workflow built it.
110
+
111
+ To register it with Claude Code:
112
+
113
+ ```bash
114
+ claude mcp add proton-mcp -- npx -y @rndmjoker/proton-mcp
115
+ ```
116
+
117
+ For Claude Desktop, Cursor and anything else that reads a JSON file:
118
+
119
+ ```json
120
+ {
121
+ "mcpServers": {
122
+ "proton-mcp": {
123
+ "command": "npx",
124
+ "args": ["-y", "@rndmjoker/proton-mcp"]
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ Nothing else belongs in that entry. Credentials are not passed here, see [Signing in](#signing-in).
131
+
132
+ ### From this repository
133
+
134
+ To run the current development state, or to change something. A setup script does the work on either platform.
135
+
136
+ ### Linux and macOS
137
+
138
+ ```bash
139
+ git clone https://github.com/RndmJoker/proton-mcp.git
140
+ cd proton-mcp
141
+ ./scripts/setup.sh
142
+ ```
143
+
144
+ ### Windows
145
+
146
+ ```powershell
147
+ git clone https://github.com/RndmJoker/proton-mcp.git
148
+ cd proton-mcp
149
+ .\scripts\setup.ps1
150
+ ```
151
+
152
+ The script checks that Node.js is new enough, installs the dependencies, builds the server, runs the tests, and creates the credentials file with placeholders if it does not exist yet. It prints the exact line to register the server with your client. It never touches an existing credentials file.
153
+
154
+ ### Updating
155
+
156
+ Same script, one flag:
157
+
158
+ ```bash
159
+ ./scripts/setup.sh --update # Linux and macOS
160
+ ```
161
+
162
+ ```powershell
163
+ .\scripts\setup.ps1 -Update # Windows
164
+ ```
165
+
166
+ That pulls the latest commits, then installs, builds and tests again. If you have uncommitted changes in the working copy, the script stops instead of discarding them.
167
+
168
+ ### Signing in
169
+
170
+ **Nothing needs to be entered before the first start.** Register the server with your client, then ask the assistant anything about your mail. It will answer with a `127.0.0.1` address, or open it for you if your client supports that. Sign in there, in your browser, and repeat the request.
171
+
172
+ The page asks for two things: your Proton address and the **Bridge password**, which is the one the Bridge generates and not your Proton account password.
173
+
174
+ Then it asks where the password should be kept. All four options are supported and none is a fallback; what differs is the drawback you accept:
175
+
176
+ | Option | On disk | You type something | Suits |
177
+ | :--- | :--- | :--- | :--- |
178
+ | **System keyring** | Protected by the operating system | Rarely | A desktop machine you use yourself. The ordinary case, and the default when it works |
179
+ | **Encrypted file** | Encrypted with your master password | Every server start | Servers, containers, headless machines. The only option that behaves the same everywhere |
180
+ | **This session only** | Nothing | Every server start | A shared machine, or a one-off look |
181
+ | **Plain file** | Unencrypted, readable by your account | Never | Automation, where nobody can type a password |
182
+
183
+ The interface probes the keyring at runtime rather than guessing from the operating system, because the same Linux with and without a graphical session is a different machine as far as a keyring is concerned. An option that cannot work is shown greyed out with the actual reason.
184
+
185
+ If you pick the encrypted file, every later start shows an unlock page asking for that master password alone. Forget it and nothing anywhere can recover the file; the page offers to throw it away so you can sign in afresh.
186
+
187
+ #### Without a browser
188
+
189
+ Environment variables still work and take precedence over anything stored:
190
+
191
+ ```
192
+ BRIDGE_USER=your.address@example.com
193
+ BRIDGE_PASS=the-password-the-bridge-generated
194
+ ```
195
+
196
+ Put them in `~/.config/proton-mcp/env` (on Windows `%USERPROFILE%\.config\proton-mcp\env`), which the setup script creates with placeholders and which **the server reads itself**. Do not put them in your AI client's configuration, where they would sit in plain text and be read on every start. The file is created readable only by you, and the server warns on startup if that stops being true.
197
+
198
+ All variables are documented in `.env.example`.
199
+
200
+ ### Registering it with a client
201
+
202
+ **Claude Code:**
203
+
204
+ ```bash
205
+ claude mcp add proton -- node /absolute/path/to/proton-mcp/dist/server.js
206
+ ```
207
+
208
+ **Any client that reads a JSON configuration** (Claude Desktop, Cursor and others):
209
+
210
+ ```json
211
+ {
212
+ "mcpServers": {
213
+ "proton": {
214
+ "command": "node",
215
+ "args": ["/absolute/path/to/proton-mcp/dist/server.js"]
216
+ }
217
+ }
218
+ }
219
+ ```
220
+
221
+ Use an absolute path; `~` is not expanded by every client. The setup script prints the correct line for your machine.
222
+
223
+ ### Checking that it works
224
+
225
+ Without any client, by speaking the protocol directly:
226
+
227
+ ```bash
228
+ printf '%s\n' \
229
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"1"}}}' \
230
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
231
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_folders","arguments":{"kind":"system"}}}' \
232
+ | node dist/server.js
233
+ ```
234
+
235
+ You should see your system mailboxes. If instead you get a message saying the Bridge is probably not running, start and unlock the Bridge and try again. The server explains what to do in its error messages rather than printing error codes.
236
+
237
+ ## Security
238
+
239
+ The content of someone else's email is text written by strangers, and it is handed to a language model that can call tools. A crafted message may try to instruct the assistant. This server assumes that will happen:
240
+
241
+ - **Message content is marked as untrusted** in every answer, enclosed in explicit markers and separated from anything the server itself says.
242
+ - **Nothing is filtered out of the content** and no attempt is made to detect attacks. Such filters pretend to offer safety they cannot deliver.
243
+ - **Sending will always require your confirmation**, enforced in the server rather than in a system prompt. There will be no switch to turn it off. Until sending exists at all, there is nothing to confirm.
244
+ - **There will be no permanent deletion**, and no tools for filters, forwarding, account or key settings. A hijacked session must not be able to leave behind lasting access.
245
+ - **HTML is never passed through raw** and external content is never fetched. Tracking pixels stay ineffective.
246
+ - **No message ever touches your disk.** No cache, no index, no copies. Anything this server wrote down would be your mail in plain text, outside the encryption you pay Proton for.
247
+
248
+ ### Writing to your mailbox
249
+
250
+ Every tool that changes something is bounded on purpose:
251
+
252
+ - **`PROTON_MCP_READ_ONLY=true` refuses every write**, and the interface reports the mode it is in. Reading keeps working.
253
+ - **Nothing is ever deleted for good.** `trash_messages` moves messages to the trash, where Proton keeps them and where `move_messages` can take them back out. There is no tool that empties the trash.
254
+ - **Removing a label is an expunge inside the label's own mailbox.** The same command aimed at a folder would delete mail, so the code refuses any path outside `Labels/`, checked again in the line immediately before the expunge. A label name containing a path separator is rejected rather than repaired.
255
+ - **Labels are never created**, only applied. Create them in Proton itself.
256
+ - **A label is not a destination for `move_messages`.** Moving a message into a label was measured to apply the label and leave the message where it is, so the tool refuses it instead of reporting a move that did not happen.
257
+ - **A batch touches at most 50 messages**, and every one of them is accounted for in the answer, including the ones that failed and why.
258
+
259
+ **A draft is the safe default.** The assistant prepares, you decide.
260
+
261
+ **Messages can be plain text or formatted.** A formatted one may use headings, paragraphs, emphasis, lists, tables, links and images, with colour, font, alignment, spacing and borders. Anything outside that set is refused with a reason rather than quietly removed, because a message that was silently altered is no longer the message anyone agreed to.
262
+
263
+ Formatting changes what a confirmation has to show, and that is the interesting part. In plain text a link's visible text and its target are the same string, so showing you the body shows you everything. In markup they are two strings, which is the shape of every phishing mail ever written. So the confirmation for a formatted message adds two blocks that are never shortened:
264
+
265
+ - **Every address in the message**, links and images alike, each with the text it is shown as.
266
+ - **Text a recipient can read that the body preview does not show.** Measured: an image's alt text never appears in the converted body, and mail clients block remote images by default, so the alt text is frequently what the recipient actually reads. That was a way past the confirmation, and it is closed.
267
+
268
+ The set of permitted markup leaves out `style`, `script`, `noscript`, `textarea` and `head` by not listing them. Those are the elements whose content the preview and a mail client disagree about, and a list of what is allowed refuses them without anyone having had to think of them first. Attachments from files on this machine are deliberately not supported: a path named by a model, read by the server and carried out by the next confirmation is not a feature, it is a way out for anything on your disk. Forwarding loses nothing all the same, because the original message travels along whole.
269
+
270
+ One thing worth knowing about drafts: Proton rewrites the thread headers of anything it stores. A reply is built with `In-Reply-To` and `References`, and what comes back has neither, only Proton's own internal thread id. Threading a stored draft is therefore Proton's business rather than this server's.
271
+
272
+ Writes need a moment to settle. A move takes roughly fifteen seconds to reconcile with Proton, and until then the Bridge lists the message in both places, so the tools say so rather than reading back an intermediate state and calling it the result. Flags are the exception: those hold immediately.
273
+
274
+ ### Sending, and the question you cannot switch off
275
+
276
+ Mail is text written by strangers, handed to a model that can call tools. A message can politely ask to be forwarded somewhere, and other projects answer that with a line in a system prompt. **A system prompt is not a security boundary.** This one lives in the server:
277
+
278
+ - **Nothing is sent without a person saying yes.** The first call never sends. It returns a question through your client showing the final recipients separated into To, Cc and Bcc, the subject, and the first lines. Only the second call, carrying your answer, sends.
279
+ - **There is no setting that turns the question off.** Not an environment variable, not an argument, not a mode.
280
+ - **A client that cannot ask cannot send.** If your client does not support form elicitation, sending is refused and you are pointed at drafts instead. That is deliberately different from the sign-in prompt, which falls back to text: the worst outcome there is an inconvenience, and here it is a message that cannot be recalled. What counts as "can ask" is the capability your client declares, read from the request when the protocol revision carries it there and from the handshake otherwise.
281
+ - **Your yes covers one specific message.** A fingerprint of the sender, every recipient, the subject and the body is sealed with an HMAC whose key exists only in the running process, and it travels with the question. On the way back the fingerprint is recomputed from what is being asked for now. If a single address was added in between, nothing is sent. A confirmation is also bound to the tool that asked, so one cannot be reused for another.
282
+ - **Blind copies stay blind.** They travel in the envelope and never appear in the headers the recipients see. Verified against a real delivery.
283
+ - **The Bridge password is protected on the way out too.** SMTP is verified against the same pinned certificate as IMAP, and STARTTLS is required rather than merely attempted.
284
+
285
+ Replying and forwarding exist as sending tools of their own, not just as drafts. The reason is measured: Proton rewrites the thread headers of anything it stores, so a reply written to a draft and sent later loses the chain, while a reply handed straight to SMTP keeps its `In-Reply-To` and stays in the conversation.
286
+
287
+ After a send, nothing checks its own work. The Bridge took about six and a half seconds to accept a message and the message appeared over IMAP about three seconds after that, so an immediate look would describe a state that is not the outcome.
288
+
289
+ ### What lands on your disk
290
+
291
+ Only these, all under `~/.config/proton-mcp/` (on Windows `%USERPROFILE%\.config\proton-mcp\`) and all written readable by your account alone:
292
+
293
+ | File | When | What is in it |
294
+ | :--- | :--- | :--- |
295
+ | `known-certificate.json` | On the first connection | The Bridge's certificate and its fingerprint |
296
+ | `settings.json` | When you change the ports in the interface | Two port numbers |
297
+ | `credentials.enc` | Only if you chose the encrypted file | Your Bridge password, encrypted with your master password |
298
+ | `env` | Only if you put it there yourself | Your Bridge password in plain text |
299
+
300
+ Choosing the system keyring puts the password where the operating system keeps your other passwords, and nothing of it in a file. Choosing "this session only" writes nothing at all.
301
+
302
+ **No mail, no subjects, no sender addresses, no folder names, no search terms.** The activity list in the interface records tool names and timings, in memory only, and is gone when the server stops.
303
+
304
+ ### The web interface
305
+
306
+ It binds to `127.0.0.1` and there is no option to change that. It is not remote access and cannot be made into it. Four defences, each against a specific attack:
307
+
308
+ - **A token in the URL**, generated per process and never written down. Any other program on your machine can reach `127.0.0.1`; without the token it can do nothing here.
309
+ - **A Host header check.** A page on the internet can point its own domain at `127.0.0.1` and have your browser send requests there, which the browser then treats as same-origin with that domain. Only loopback names are accepted. This is DNS rebinding, and it is the attack local servers most often forget.
310
+ - **An Origin check and a per-action CSRF token** on everything that changes something, so a token for one action cannot be replayed against another.
311
+ - **A strict Content-Security-Policy.** Nothing may be loaded from anywhere and nothing can reach the network. The interface also serves no files from disk at all, which removes path traversal as a category rather than guarding against it.
312
+
313
+ It shows no message content, by design. It is for configuration and state, not a mail client.
314
+
315
+ ### What you actually expose
316
+
317
+ In Proton, folders and labels belong to the **account**, not to an individual address. If you connect the server using a secondary address, the **complete folder structure of your account** is still visible, even though the messages inside belong to the other address and stay hidden.
318
+
319
+ The names alone say something. A folder `Banking/Revolut` reveals your bank, `Tax` your dealings with the tax office, a company name your employer. Separating both content **and** structure requires a separate Proton account, not just a second address.
320
+
321
+ ### The Bridge certificate
322
+
323
+ The Bridge generates its own certificate and keeps it inside its encrypted vault rather than on disk, so it cannot be trusted in advance. Switching verification off would mean anything that manages to listen on the port gets your Bridge password.
324
+
325
+ Instead the certificate is **pinned on first use**. The first connection records its fingerprint in `~/.config/proton-mcp/known-certificate.json` and reports it on stderr. The web interface shows it too, along with the date it was recorded. Every connection after that is verified against that one certificate, which is stricter than the public trust store: a certificate signed by a recognised authority would not do.
326
+
327
+ If the certificate ever changes, the server refuses to connect and says so:
328
+
329
+ ```
330
+ The certificate of the Bridge at 127.0.0.1:1143 has changed. Refusing to connect.
331
+
332
+ expected: 93:87:F5:3D:...
333
+ received: 11:22:33:44:...
334
+ ```
335
+
336
+ Reinstalled the Bridge or moved machines? Then delete the file and the next connection records the new certificate. Changed nothing? Then do not delete it, because something else may be listening on that port.
337
+
338
+ The one moment this cannot protect you is the very first connection, which is what trust on first use means. If that matters to you, compare the fingerprint the server prints against the certificate the Bridge shows.
339
+
340
+ ## Development
341
+
342
+ | Area | Choice |
343
+ | :--- | :--- |
344
+ | Language | TypeScript, Node.js 24 or newer |
345
+ | MCP | `@modelcontextprotocol/server` v2, specification 2026-07-28 |
346
+ | IMAP | `imapflow` |
347
+ | MIME | `mailparser`, `html-to-text` |
348
+ | SMTP | `nodemailer`, for sending once it exists |
349
+ | Schemas | `zod` |
350
+ | Tests | Vitest, `node` environment |
351
+
352
+ Every dependency is MIT or MIT-0, except `deepmerge-ts` which is BSD-3-Clause. All permissive.
353
+
354
+ ```bash
355
+ npm run build # compile into dist/
356
+ npm test # run the tests once
357
+ npm run test:watch # run the tests in watch mode
358
+ npm run test:coverage # with coverage report
359
+ npm run typecheck # type check only, no output
360
+ ```
361
+
362
+ The tests run without a Bridge, so they work in CI. Real credentials and real mail content have no place in test files.
363
+
364
+ ### Layout
365
+
366
+ ```
367
+ src/
368
+ ├── bridge/ connection handling, certificate pinning, error translation
369
+ ├── credentials/ the four places a password can be kept, and the runtime probe
370
+ ├── mail/ listing, reading, searching, identifiers, attachments
371
+ ├── mime/ parsing, HTML to text, budgeting
372
+ ├── tools/ the MCP tools and their output formatting
373
+ ├── web/ the local interface and its security layer
374
+ ├── activity.ts what is running and what ran, in memory only
375
+ ├── session.ts whether we are signed in, and where from
376
+ ├── settings.ts the Bridge ports, when changed in the interface
377
+ └── server.ts entry point and transport
378
+ ```
379
+
380
+ The web interface has one property worth knowing before touching it: `Referrer-Policy` must not be `no-referrer`. Under that policy browsers send `Origin: null`, which the CSRF defence rightly refuses, and the interface then refuses its own forms. The tests set the Origin header themselves and cannot catch it. There is a comment in `src/web/security.ts` and a test asserting the cause.
381
+
382
+ ### Checks before a pull request
383
+
384
+ The CI in `.github/workflows/ci.yml` runs the tests, the type check, the build, an audit of the dependencies and a scan for accidentally committed secrets. All of it runs locally too:
385
+
386
+ ```bash
387
+ npm test
388
+ npx tsc --noEmit
389
+ npm run build
390
+ npm audit --omit=dev
391
+ ```
392
+
393
+ ### Measured behaviour of the Bridge
394
+
395
+ Some of what this server does looks odd until you know what the Bridge actually does, as opposed to what a standard IMAP server would do. The comments in `src/bridge/`, `src/mail/` and `src/mime/` name the measurement behind each decision. The three that surprise people most:
396
+
397
+ - `FETCH 1:*` on an empty mailbox is answered with `BAD no such message` instead of an empty result, so a message count has to be checked first.
398
+ - UIDs are per mailbox and change when a message is moved. The Message-ID is the only stable handle.
399
+ - A median message costs roughly 16000 tokens in its raw form and a fraction of that as text, which is why nothing raw is ever passed through.
400
+
401
+ Worth reading before changing anything in those directories.
402
+
403
+ ## License
404
+
405
+ MIT
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What the server has been asked to do lately.
3
+ *
4
+ * The status page needs to answer two questions that nothing else can: is
5
+ * something running right now, and what happened before. Without this a user
6
+ * watching a slow full-text search has no way to tell it apart from a hung
7
+ * server.
8
+ *
9
+ * ## What is deliberately not recorded
10
+ *
11
+ * **No arguments.** Not the mailbox, not the message id, and above all not the
12
+ * search text. A search term is very nearly mail content, a message id names one
13
+ * specific message, and a mailbox path can say `Folders/Banking/Revolut`. The
14
+ * interface is for configuration and state, not a window into the mailbox, and a
15
+ * record of what was searched for would quietly turn it into one.
16
+ *
17
+ * **Nothing on disk.** This lives in memory and dies with the process. A log
18
+ * file would be a record of someone's mail habits sitting outside Proton's
19
+ * encryption, which is exactly what this project refuses to create.
20
+ *
21
+ * The tool name, the time and whether it worked are enough for the job.
22
+ */
23
+ /** How many finished calls are kept. Older ones fall off the end. */
24
+ export declare const HISTORY_LIMIT = 50;
25
+ export interface RunningCall {
26
+ /** Distinguishes concurrent calls to the same tool. */
27
+ id: number;
28
+ tool: string;
29
+ startedAt: number;
30
+ }
31
+ export interface FinishedCall {
32
+ tool: string;
33
+ startedAt: number;
34
+ /** How long it took, in milliseconds. */
35
+ durationMs: number;
36
+ outcome: 'ok' | 'failed';
37
+ }
38
+ /** Records the start of a call and returns its handle. */
39
+ export declare function begin(tool: string, now?: number): number;
40
+ /** Records the end of a call. Unknown handles are ignored rather than thrown at. */
41
+ export declare function finish(id: number, outcome: FinishedCall['outcome'], now?: number): void;
42
+ /** The calls in progress, oldest first: the one to worry about is at the top. */
43
+ export declare function inProgress(): RunningCall[];
44
+ /** The finished calls, newest first. */
45
+ export declare function recent(): FinishedCall[];
46
+ /** When the first tool call arrived, or undefined if none ever did. */
47
+ export declare function firstCall(): number | undefined;
48
+ /** Resets everything. For tests only. */
49
+ export declare function _reset(): void;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * What the server has been asked to do lately.
3
+ *
4
+ * The status page needs to answer two questions that nothing else can: is
5
+ * something running right now, and what happened before. Without this a user
6
+ * watching a slow full-text search has no way to tell it apart from a hung
7
+ * server.
8
+ *
9
+ * ## What is deliberately not recorded
10
+ *
11
+ * **No arguments.** Not the mailbox, not the message id, and above all not the
12
+ * search text. A search term is very nearly mail content, a message id names one
13
+ * specific message, and a mailbox path can say `Folders/Banking/Revolut`. The
14
+ * interface is for configuration and state, not a window into the mailbox, and a
15
+ * record of what was searched for would quietly turn it into one.
16
+ *
17
+ * **Nothing on disk.** This lives in memory and dies with the process. A log
18
+ * file would be a record of someone's mail habits sitting outside Proton's
19
+ * encryption, which is exactly what this project refuses to create.
20
+ *
21
+ * The tool name, the time and whether it worked are enough for the job.
22
+ */
23
+ /** How many finished calls are kept. Older ones fall off the end. */
24
+ export const HISTORY_LIMIT = 50;
25
+ let nextId = 1;
26
+ const running = new Map();
27
+ /** Newest first, so the page does not have to reverse it. */
28
+ let history = [];
29
+ /** Set the first time a tool call arrives, which is when a client showed up. */
30
+ let firstCallAt;
31
+ /** Records the start of a call and returns its handle. */
32
+ export function begin(tool, now = Date.now()) {
33
+ const id = nextId++;
34
+ running.set(id, { id, tool, startedAt: now });
35
+ firstCallAt ??= now;
36
+ return id;
37
+ }
38
+ /** Records the end of a call. Unknown handles are ignored rather than thrown at. */
39
+ export function finish(id, outcome, now = Date.now()) {
40
+ const call = running.get(id);
41
+ if (!call)
42
+ return;
43
+ running.delete(id);
44
+ history.unshift({
45
+ tool: call.tool,
46
+ startedAt: call.startedAt,
47
+ durationMs: now - call.startedAt,
48
+ outcome,
49
+ });
50
+ if (history.length > HISTORY_LIMIT)
51
+ history.length = HISTORY_LIMIT;
52
+ }
53
+ /** The calls in progress, oldest first: the one to worry about is at the top. */
54
+ export function inProgress() {
55
+ return [...running.values()].sort((a, b) => a.startedAt - b.startedAt);
56
+ }
57
+ /** The finished calls, newest first. */
58
+ export function recent() {
59
+ return [...history];
60
+ }
61
+ /** When the first tool call arrived, or undefined if none ever did. */
62
+ export function firstCall() {
63
+ return firstCallAt;
64
+ }
65
+ /** Resets everything. For tests only. */
66
+ export function _reset() {
67
+ nextId = 1;
68
+ running.clear();
69
+ history = [];
70
+ firstCallAt = undefined;
71
+ }
72
+ //# sourceMappingURL=activity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activity.js","sourceRoot":"","sources":["../src/activity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,qEAAqE;AACrE,MAAM,CAAC,MAAM,aAAa,GAAG,EAAE,CAAA;AAiB/B,IAAI,MAAM,GAAG,CAAC,CAAA;AACd,MAAM,OAAO,GAAG,IAAI,GAAG,EAAuB,CAAA;AAC9C,6DAA6D;AAC7D,IAAI,OAAO,GAAmB,EAAE,CAAA;AAChC,gFAAgF;AAChF,IAAI,WAA+B,CAAA;AAEnC,0DAA0D;AAC1D,MAAM,UAAU,KAAK,CAAC,IAAY,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IAClD,MAAM,EAAE,GAAG,MAAM,EAAE,CAAA;IACnB,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC,CAAA;IAC7C,WAAW,KAAK,GAAG,CAAA;IACnB,OAAO,EAAE,CAAA;AACX,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,MAAM,CAAC,EAAU,EAAE,OAAgC,EAAE,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE;IACnF,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IAC5B,IAAI,CAAC,IAAI;QAAE,OAAM;IACjB,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAA;IAClB,OAAO,CAAC,OAAO,CAAC;QACd,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,UAAU,EAAE,GAAG,GAAG,IAAI,CAAC,SAAS;QAChC,OAAO;KACR,CAAC,CAAA;IACF,IAAI,OAAO,CAAC,MAAM,GAAG,aAAa;QAAE,OAAO,CAAC,MAAM,GAAG,aAAa,CAAA;AACpE,CAAC;AAED,iFAAiF;AACjF,MAAM,UAAU,UAAU;IACxB,OAAO,CAAC,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC,CAAA;AACxE,CAAC;AAED,wCAAwC;AACxC,MAAM,UAAU,MAAM;IACpB,OAAO,CAAC,GAAG,OAAO,CAAC,CAAA;AACrB,CAAC;AAED,uEAAuE;AACvE,MAAM,UAAU,SAAS;IACvB,OAAO,WAAW,CAAA;AACpB,CAAC;AAED,yCAAyC;AACzC,MAAM,UAAU,MAAM;IACpB,MAAM,GAAG,CAAC,CAAA;IACV,OAAO,CAAC,KAAK,EAAE,CAAA;IACf,OAAO,GAAG,EAAE,CAAA;IACZ,WAAW,GAAG,SAAS,CAAA;AACzB,CAAC"}
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Pinning the Bridge's self-signed certificate.
3
+ *
4
+ * The problem: the Bridge generates its own certificate and keeps it inside its
5
+ * encrypted vault rather than on disk, so it cannot be trusted in advance. The
6
+ * lazy answer is to switch verification off and never look again, which means
7
+ * anything that manages to listen on the port gets the Bridge password.
8
+ *
9
+ * The answer here is trust on first use. On the first connection the certificate
10
+ * is fetched and its fingerprint recorded. From then on it is pinned: Node
11
+ * verifies against that one certificate and nothing else. A changed certificate
12
+ * stops the server with an explanation instead of being waved through.
13
+ *
14
+ * Fetching needs a STARTTLS handshake of our own, because imapflow does not hand
15
+ * the certificate out. Verified against Bridge 03.25.00: checkServerIdentity is
16
+ * never invoked while verification is off, and the certificate is not attached
17
+ * to the verification error either.
18
+ */
19
+ /** What is known about a pinned certificate. */
20
+ export interface PinnedCertificate {
21
+ host: string;
22
+ port: number;
23
+ /** SHA-256 fingerprint, the value that is compared. */
24
+ fingerprint256: string;
25
+ /** The certificate itself, handed to Node as the only trusted authority. */
26
+ pem: string;
27
+ /** Subject line, for the message shown when something changes. */
28
+ subject: string;
29
+ validTo: string;
30
+ /** When it was first recorded, so a user can tell whether it was them. */
31
+ firstSeen: string;
32
+ }
33
+ /** Where the pinned certificate is stored. Next to the credentials file. */
34
+ export declare function pinPath(env?: NodeJS.ProcessEnv): string;
35
+ /** Turns a DER certificate into the PEM form Node expects as a CA. */
36
+ export declare function toPem(der: Buffer): string;
37
+ /**
38
+ * Fetches the certificate by doing the STARTTLS dance manually.
39
+ *
40
+ * Deliberately minimal: it reads the greeting, sends STARTTLS, waits for the OK
41
+ * and upgrades. It does not log in, so no credentials are involved at this
42
+ * point.
43
+ */
44
+ export declare function fetchCertificate(host: string, port: number, timeoutMs?: number): Promise<{
45
+ fingerprint256: string;
46
+ pem: string;
47
+ subject: string;
48
+ validTo: string;
49
+ }>;
50
+ /** Reads the pinned certificate. Absence means nothing has been pinned yet. */
51
+ export declare function loadPinned(path: string): PinnedCertificate | undefined;
52
+ /** Records a certificate as trusted. */
53
+ export declare function savePinned(path: string, entry: PinnedCertificate): void;
54
+ /** The message shown when the certificate no longer matches. */
55
+ export declare function mismatchMessage(pinned: PinnedCertificate, actual: {
56
+ fingerprint256: string;
57
+ subject: string;
58
+ }, path: string): string;
59
+ /** What fetching a certificate yields. */
60
+ export type FetchedCertificate = Awaited<ReturnType<typeof fetchCertificate>>;
61
+ export interface EnsureOptions {
62
+ path?: string;
63
+ onFirstUse?: (entry: PinnedCertificate) => void;
64
+ /** Replaces the fetch step. Used by tests, which have no Bridge. */
65
+ fetch?: (host: string, port: number) => Promise<FetchedCertificate>;
66
+ /** Fixed timestamp for the record. Used by tests. */
67
+ now?: () => Date;
68
+ }
69
+ /**
70
+ * Makes sure the certificate is known, and returns it for use as a CA.
71
+ *
72
+ * On first use it records what it finds, which is the only moment this is
73
+ * open to a machine-in-the-middle. Every later connection is pinned.
74
+ */
75
+ export declare function ensurePinned(host: string, port: number, options?: EnsureOptions): Promise<PinnedCertificate>;