This guide takes you from a clean checkout to a registered MCP server: install,
configure a workspace, store the Bridge password, optionally pin Bridge's TLS
certificate, enable sending, and register the server with your MCP client. For why
the design works the way it does, see README.md and the full threat
model in SECURITY_MODEL.md.
ProtonBound is tailored to Proton Mail via Proton Bridge, but Bridge simply exposes mail over local IMAP/SMTP — the same configuration works against any IMAP/SMTP provider. The steps below assume Bridge; substitute your provider's host, port, username and password where noted.
- Proton Bridge installed, running, and signed in. Add your account and note the per-account IMAP username and Bridge password (Bridge → account → Mailbox configuration). This is the Bridge password, not your Proton login password.
- Python 3.11+ and uv.
git clone <this-repo> code-protonbound
cd code-protonbound
uv syncRun the tests (no Proton Bridge needed — scope logic is pure):
uv run pytestNo uv? Use a plain venv + pip instead
python -m venv .venv
# Linux/macOS:
source .venv/bin/activate
# Windows PowerShell:
.\.venv\Scripts\Activate.ps1
pip install -e . pytest
pytest
protonbound --workspace workspaces/my-clients.yaml # after configuring (see below)Requires a real Python 3.11+ on PATH (the Microsoft Store stub won't work).
Copy the example and edit it:
# Linux/macOS
cp workspaces/example-clients.yaml workspaces/my-clients.yaml# Windows (PowerShell)
Copy-Item workspaces/example-clients.yaml workspaces/my-clients.yamlEdit workspaces/my-clients.yaml: your username, and under mail: your scope,
permission tier, and the real names of your Drafts/Trash mailboxes as Bridge reports
them. See workspaces/example-clients.yaml for a
fully commented example. One server instance serves exactly one workspace.
Account options (all under account:):
| Field | Default | Description |
|---|---|---|
username |
(required) | Bridge IMAP/SMTP login — your primary Proton address |
imap_host |
127.0.0.1 |
Bridge host |
imap_port |
1143 |
Bridge IMAP port |
smtp_host |
127.0.0.1 |
Bridge SMTP host (used only when allow_smtp: true) |
smtp_port |
1025 |
Bridge SMTP port (used only when allow_smtp: true) |
from_address |
same as username |
Sender identity for drafts and outbound mail (use to send as an alias) |
bridge_cert_sha256 |
(unset) | SHA-256 fingerprint to pin Bridge's TLS cert (see §5) |
Each workspace is a single committed YAML file, workspaces/<name>.yaml
(name/description/account plus a mail: section for permission, scope, and write
targets). Scope is deny-by-default and combines (AND) up to three filters — a
message is in scope only if all apply:
- it is in an allowed source mailbox (
scope.sources); - if
require_starred: true, it is starred; - if
scope.addressesis set, one of those addresses appears in From/To/Cc or the delivery headers (Delivered-To/X-Original-To/Envelope-To— this is how BCC'd mail to your aliases is matched).
Preferred — keep the secret in your OS keyring (Windows Credential Manager / macOS Keychain / Linux Secret Service), keyed by the workspace's IMAP username:
uv run protonbound --set-password --workspace workspaces/my-clients.yaml
# prompts for the Bridge password and stores it; never written to a file or env varAt runtime the server reads the keyring first and falls back to the
PROTONBOUND_BRIDGE_PASSWORD environment variable if the keyring has no entry — so the
env var still works for headless/CI use where no keyring backend exists.
Proton Bridge listens on localhost with a self-signed TLS certificate. Pinning it by SHA-256 fingerprint means the server will refuse to connect — and therefore refuse to send your credentials — if Bridge ever presents a different certificate (e.g. due to a local TLS interception proxy).
First, capture the current fingerprint:
uv run protonbound --show-cert --workspace workspaces/my-clients.yaml
# prints: Bridge TLS cert SHA-256: ab:cd:ef:...Then add the fingerprint to your workspace file under account::
account:
username: you@pm.me
bridge_cert_sha256: "ab:cd:ef:..." # colons optional; 64 hex charsOn the next launch the server validates the cert before authenticating. If it
mismatches, the connection is aborted with a clear error. The same fingerprint pins
both transports: the IMAP read connection and — when allow_smtp: true — the SMTP
send connection, which is checked after STARTTLS and fails closed before your Bridge
password is transmitted.
permission |
Reads | Drafts & housekeeping | Sends |
|---|---|---|---|
readonly |
✅ | ❌ | ❌ by default |
read-write |
✅ | ✅ | ❌ by default |
This table shows what each tier permits, not what is exposed. The actual tool
surface is deny-first: a tool appears only if it is listed in tools: and its
tier/flag prerequisite is met (see the tools: allow-list below).
Sending is a separate opt-in flag, independent of the permission tier:
mail: flag |
Default | Effect |
|---|---|---|
allow_delete: true |
false |
Enables delete_message (needs write_targets.trash) |
allow_local_attachments: true |
false |
Allows attaching local files to drafts |
allow_smtp: true |
false |
Registers send_draft and loads smtplib |
The set of tools exposed to the model is deny-first: it is exactly the tools:
list and nothing else — not even get_workspace_info unless you list it. The field
defaults to an empty list, so a workspace that names no tools exposes none (the safe
default).
mail:
permission: read-write
allow_smtp: true
scope:
sources: [Folders/Outbox-Queue]
write_targets:
drafts: Drafts
tools: # the surface is exactly these four tools
- digest
- get_thread
- draft_reply
- send_draftThe list narrows; it never grants. A tool may be listed only if its prerequisite is
enabled — write tools need permission: read-write, delete_message needs
allow_delete, send_draft needs allow_smtp — and the config fails to load otherwise
(an unknown tool name is rejected too, so a typo fails loudly). So the exposed surface
is always (tier/flag-permitted) ∩ (tools list): the capability gates still apply, and
the list picks which permitted tools the model actually sees. The committed
workspaces/example-clients.yaml lists the full read + draft/housekeeping surface as a
starting point — trim it to expose less.
By default ProtonBound has no send capability: smtplib is never imported and
send_draft is never registered. To enable it for a workspace, add allow_smtp: true
to the mail: section:
mail:
permission: read-write
allow_smtp: true
scope:
sources: [Folders/Outbox-Queue]
write_targets:
drafts: DraftsWhen enabled the agent gets a single send tool, send_draft, which takes only an
opaque draft_id — there is deliberately no content-taking send tool. Sending is the
second stage of a two-step, draft-first flow:
- Compose with
save_draft/draft_reply/update_draft. The message lands in Drafts, where the user can review it in their normal Proton client. - Call
send_draft(draft_id)to send exactly that draft, byte-for-byte. There is noto/bodyparameter to divert, so what goes out is what was reviewed. On success the draft is removed from Drafts (Proton stores the Sent copy server-side).
Because send_draft only references an existing draft by id, a prompt-injection cannot
smuggle an arbitrary recipient or body through the send call itself — it can at most send
a draft that already exists and is visible for review. The tool description also
instructs the agent to obtain explicit user confirmation before calling it. Bcc
recipients saved on the draft are honoured (they receive the mail) but the Bcc header
is stripped from the transmitted message, as normal for a sent email.
Two independent guards prevent a hijacked agent from sending without allow_smtp: true:
- Registration gate — the tool is never registered when
allow_smtp: false, so the agent has no knowledge of any send capability. - Runtime guard — the first line of
send_draftraisesPermissionErrorifallow_smtpisfalseat call time, independent of how the function was reached.
Bridge SMTP connection settings default to 127.0.0.1:1025 and can be overridden under
account: with smtp_host / smtp_port.
A send-enabled workspace can only send as one of its own in-scope addresses. When
scope.addresses is set, the configured sender (account.from_address, else
username) must be one of those aliases — this is checked at launch (the workspace fails
to load otherwise) and re-checked at the moment of each send. So a career workspace
cannot send from a comedy alias even if hijacked, and the model has no parameter to
override the sender. With no scope.addresses configured there is nothing to bind
against, so the single configured identity is used unrestricted.
For a guarantee that doesn't depend on config at all, delete the send module:
rm src/protonbound/smtp.pysmtp.py is the only file in the package that imports smtplib. With it gone there is
no send code to run, so sending is structurally impossible regardless of any allow_smtp
setting — a stronger statement than a config flag, since it can't be flipped back by
editing YAML.
The server is built to treat this as a supported state, not a crash:
- At startup it checks for the module with
importlib.util.find_spec— which locates without importing, so the check itself never pulls insmtplib. If the module is absent, the server runs exactly as ifallow_smtp: false: the send tool is not registered, the agent-facing instructions say it can never send, andget_workspace_inforeportscan_send: false. - If a workspace still has
allow_smtp: truewhile the module is gone, the server starts normally and prints a one-line notice on stderr explaining that sending is disabled. - If the file is removed while the server is running, an attempted send fails closed
with a clear
PermissionErrorrather than a rawModuleNotFoundError.
To restore sending, put smtp.py back (e.g. git checkout -- src/protonbound/smtp.py)
and restart. This makes a third, physical layer beneath the registration gate and
runtime guard above.
Proton applies your account signature only when you compose in a Proton client; Bridge does not add it to mail composed through IMAP/SMTP here. So define a signature per workspace if you want one:
mail:
signature: |
Jane Doe
Acme Co — contact@example.comWhen set, draft_reply / save_draft / update_draft take an append_signature flag
(default true) that appends it below the body under the RFC 3676 -- delimiter.
(send_draft needs no such flag — it sends a draft whose signature was already applied
when it was composed.) The text is added verbatim by code — the model never authors
or edits the signature, it only chooses whether to include it (e.g. omitting it on a
terse internal reply). With no signature configured the flag is a no-op.
PROTONBOUND_BRIDGE_PASSWORD="<bridge-password>" \
uv run protonbound --workspace workspaces/my-clients.yamlOr with the interactive inspector:
PROTONBOUND_WORKSPACE=workspaces/my-clients.yaml \
PROTONBOUND_BRIDGE_PASSWORD="<bridge-password>" \
uv run mcp dev src/protonbound/__main__.pyOn Windows PowerShell, set the env vars first:
$env:PROTONBOUND_BRIDGE_PASSWORD = "<bridge-password>"
uv run protonbound --workspace workspaces/my-clients.yamlAdd one entry per workspace to your client config (e.g. Claude Desktop's
claude_desktop_config.json, or a project .mcp.json):
{
"mcpServers": {
"protonbound-clients": {
"command": "uv",
"args": ["run", "protonbound", "--workspace", "workspaces/my-clients.yaml"],
"env": { "PROTONBOUND_BRIDGE_PASSWORD": "<bridge-password>" }
}
}
}On Windows, use the absolute path to the workspace and ensure
uvis onPATH(or give the full path touv.exe). Bridge's default ports (IMAP 1143) are identical on both OSes.
The --inspect flag launches a developer tool that shows exactly the payloads the LLM
receives — including <untrusted-email-content> fencing, defanged injection attempts,
and opaque message tokens — without routing real mail through an AI agent.
One-shot mode (pipe-friendly with --raw):
# List threads
uv run protonbound --workspace workspaces/my-clients.yaml --inspect threads --limit 5
# Search and see the fenced body payloads
uv run protonbound --workspace workspaces/my-clients.yaml --inspect search "invoice"
# View a specific message by its opaque token
uv run protonbound --workspace workspaces/my-clients.yaml --inspect message <token>
# Bare JSON for piping to jq
uv run protonbound --workspace workspaces/my-clients.yaml --inspect --raw search "q" | jq .
# Connection and session token stats
uv run protonbound --workspace workspaces/my-clients.yaml --inspect statusInteractive REPL (omit the command):
uv run protonbound --workspace workspaces/my-clients.yaml --inspect
inspect> threads
inspect> search --from alice@example.com --days 7
inspect> thread <thread_id>
inspect> message <message_id>
inspect> status
inspect> help
inspect> quitAvailable commands mirror the MCP tool surface 1-to-1: info, folders, threads,
thread, message, search, status. Token IDs issued by threads/search are
valid for thread/message within the same session, subject to the same opaque-id
whitelist the LLM operates under.