A local, AgentMail-style REST API for Proton Mail. An AI agent can read, search, reply to and send mail through it without ever holding the mailbox password.
It is not a Proton product, a hosted inbox service, or a way around Bridge. It is a small service that runs on your machine, in front of the official Proton Mail Bridge.
- Why
- Features
- How it works
- Requirements
- Quick start
- Command line
- HTTP API
- Giving an agent less access
- Sending safely
- Attachments
- Events
- Configuration
- Security
- Agent skill
- Development
- Contributing · Contributors · License
An agent that reads email can be steered by the email it reads. Handing it IMAP and SMTP credentials means a single hostile message can reach the whole mailbox.
Proton Mail Bridge exposes a Proton account as local IMAP and SMTP, which is all-or-nothing. This service sits between the agent and Bridge and gives the agent something narrower: a small HTTP API behind a bearer token. The Bridge login stays in a file the agent never sees, and the service can be restricted to specific folders, to read-only, and to specific sender addresses.
The API follows the shape of hosted AgentMail (/inboxes/{id}/messages, threads, send, reply, events), so an agent that already knows that interface needs no new tools.
| proton-agent-mail | Hosted AgentMail | Raw IMAP/SMTP in the agent | |
|---|---|---|---|
| Works with Proton | Yes, through official Bridge | No | Yes |
| Agent sees the mailbox password | No | Not applicable | Usually |
| REST interface agents already know | Yes | Yes | No |
| Mail leaves your machine | Only through Bridge | Stored in a cloud service | Depends |
| Can limit an agent to part of a mailbox | Yes | Not applicable | No |
- AgentMail-shaped REST. List, read, threads, send, reply, label changes, events.
- Credential isolation. The agent holds a bearer token. The Bridge login lives in a
0600file read only by the service. - Scoped access. Pin the folders an agent may touch, make the service read-only, and restrict which addresses it may send from.
- Safe sending. One send is one SMTP attempt, with a circuit breaker and refused-recipient memory, so a Bridge refusal cannot turn into a lockout.
- Quarantined attachments. Every inbound and outbound attachment is scanned (ClamAV) before it is released.
- Native client. Python's own IMAP and SMTP over loopback. No subprocess, no third-party mail client, no dependencies.
- Loopback only. Refuses to bind elsewhere unless you explicitly allow it.
human ── logs in once ──▶ Proton Mail Bridge
agent ── Bearer token ──▶ proton-agent-mail 127.0.0.1:18765
│
BridgeClient (imaplib + smtplib)
│
Bridge 127.0.0.1:1143 / 1025
│
Proton Mail
- Python 3.11 or newer. There are no Python dependencies.
- Proton Mail Bridge, installed and signed in. Bridge needs a paid Proton plan.
- For attachments: ClamAV (
clamdscanorclamscan) or Docker. With no scanner available, attachments are refused rather than released unscanned.
git clone https://github.com/PabloTheThinker/proton-agent-mail.git
cd proton-agent-mail
pip install -e .
# 1. Start setup. It launches Bridge if needed and waits for you to sign in.
proton-agent-mail setup
# 2. In another terminal, sign in to Bridge yourself. Never paste these into a chat.
protonmail-bridge -c
# >>> login
# Setup finishes once Bridge reports IMAP credentials. It writes the Bridge login
# and a bearer token, both mode 0600, and never prints either.
# 3. Serve the API.
export PROTON_AGENT_TOKEN_FILE="$HOME/.config/proton-agent-mail.token"
export PROTON_AGENT_FROM="You <you@your-domain>"
proton-agent-mail serve
# 4. Check it.
proton-agent-mail health
proton-agent-mail listIf Bridge is not installed, setup exits with status 2 and prints the official download URL. It does not install anything from unofficial sources.
| Command | Purpose |
|---|---|
setup |
Wait for the Bridge login, store credentials and a token |
serve |
Run the API on 127.0.0.1:18765 |
health |
Service status, backend, send breaker, scope |
token |
Print a new random bearer token |
list [--limit N] |
Message envelopes only, no bodies |
read ID [--mark-read] |
Read one message (does not mark it read unless asked) |
threads [--limit N] |
Thread list |
send --to --subject --body |
Send a message |
reply ID --body [--to] [--subject] |
Reply in thread |
mark ID --add/--remove LABEL |
Labels: read, unread, replied, starred, flagged |
attachments ID [--folder F] |
List a message's attachments |
fetch ID [--folder F] [--index N] |
Download into quarantine, scan, release if clean |
events [--after ID] [--wait S] [--follow] |
New-mail events |
Every route except /health needs Authorization: Bearer <token>. The default inbox id is default.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Status (no auth) |
| GET | /inboxes |
Inbox, plus the agent's folder scope and read-only flag |
| GET | /inboxes/{id}/messages |
Envelopes. Query: limit (max 50), folder, unread, q |
| GET | /inboxes/{id}/messages/{mid} |
One message. Query: folder, mark_read |
| PATCH | /inboxes/{id}/messages/{mid} |
{"add_labels": [...], "remove_labels": [...]} |
| GET | /inboxes/{id}/threads |
Thread list. Query: limit, folder |
| POST | /inboxes/{id}/messages/send |
Send |
| POST | /inboxes/{id}/messages/{mid}/reply |
Reply, threaded |
| GET | /inboxes/{id}/messages/{mid}/attachments |
List attachments |
| GET | /inboxes/{id}/messages/{mid}/attachments/{n} |
Download one attachment (scanned) |
| POST | /inboxes/{id}/messages/{mid}/attachments/fetch |
Fetch into quarantine and scan |
| GET | /inboxes/{id}/events |
New-mail events (long poll) |
| GET | /quarantine/status |
Quarantine layout and scanner mode |
List responses return envelope metadata only. A message body is always a second call. Message ids are only meaningful within one folder, so pass folder whenever it is not INBOX.
Send
curl -s -X POST http://127.0.0.1:18765/inboxes/default/messages/send \
-H "Authorization: Bearer $(cat $PROTON_AGENT_TOKEN_FILE)" \
-H "Content-Type: application/json" \
-d '{"to": "owner@example.com", "subject": "Hello", "text": "Hi there."}'Optional fields: cc, from (must be an allowed sender), and attachments.
Reply. The reply goes to Reply-To first, then the sender. It is sent from the address the original was written to (when that address is an allowed sender), with In-Reply-To and References set so the thread stays linked.
curl -s -X POST http://127.0.0.1:18765/inboxes/default/messages/42/reply \
-H "Authorization: Bearer $(cat $PROTON_AGENT_TOKEN_FILE)" \
-H "Content-Type: application/json" \
-d '{"text": "Thanks, received."}'Three independent settings narrow what an agent can do. They are service configuration, so the agent cannot widen them.
Folder scope. PROTON_AGENT_FOLDERS=Jobs,Receipts limits every route to those folders. For example, an agent that tracks job applications can read the Jobs folder and nothing else in a personal mailbox.
- Unset means every folder.
- The first entry is the default folder, so a call with no
foldernever falls back toINBOX. - A folder outside the set returns
403 folder_forbidden, not 404, so the boundary shows in logs. - Matching is exact. IMAP folder names are case-sensitive; only
INBOXis case-folded. - It is enforced on list, read, threads, attachments, reply, label changes and the new-mail watcher.
GET /inboxesreportsfolders,default_folderandread_only, so an agent can discover its own scope.- A malformed value stops the service at startup.
Read-only mode. PROTON_AGENT_READONLY=1 makes send, reply, label changes and mark_read return 403 read_only. Reading and attachment fetch still work. Without this switch, an agent that cannot send mail can still alter it through label changes.
Sender policy. PROTON_AGENT_SEND_AS="Name <a@example.com>;Name <b@example.com>" lists the addresses the service may send from. The first is the default. An explicit from outside the list returns 400 and nothing is sent.
A read-only, single-folder deployment:
PROTON_AGENT_FOLDERS=Jobs PROTON_AGENT_READONLY=1 proton-agent-mail serveWhen a send fails repeatedly, Bridge answers 554 Too many failed send attempts and then refuses every sender for several minutes. Each retry during that window extends it. The service is built so a failure cannot cascade:
- One send is one SMTP attempt. There is no retry loop.
- A lockout opens a shared circuit breaker. Further sends return
503withRetry-Afterand never reach Bridge. - A recipient Bridge refuses is remembered for 24 hours and answered with
422instead of being tried again. - Headers are RFC 2047 encoded, so a display name or subject with non-ASCII characters is not rejected by Bridge.
Failed sends leave drafts behind in Bridge. A pile of identical drafts means something is retrying.
If you run several processes that send, point them at the same PROTON_AGENT_BREAKER file so they share one breaker.
Attachments are untrusted input. The service treats them that way.
GET .../attachmentslists[{index, filename, content_type, size, inline}]. Filenames are sanitised; the sender's raw name never reaches the filesystem.GET .../attachments/{n}returns the bytes of one attachment, and only if the quarantine scan reportedclean. The response is alwaysapplication/octet-streamwithnosniffand anattachmentdisposition, never the type the sender declared.X-Attachment-ScanandX-Attachment-Sha256report what was checked.- A refused file is never served:
422infected or blocked by policy,413over the size limit,502scanner failure,404unknown index. POST .../attachments/fetchstages files in the quarantine tree (incoming→cleanorblocked) for callers that want paths rather than bytes.- Outbound attachments, by path or base64, are scanned the same way before SMTP.
A clean scan does not make a document safe to obey. Text inside a PDF can still carry instructions aimed at the model reading it. See SECURITY.md.
Bridge has no push mechanism, so a watcher polls the default folder and turns new message ids into message.received events. It is off unless PROTON_AGENT_WATCH_SECS is set.
- Long poll:
GET /inboxes/{id}/events?after=<event_id>&wait=<seconds> - Webhook: set
PROTON_AGENT_WEBHOOK_URL(http or https). The body is signed with HMAC-SHA256 usingPROTON_AGENT_WEBHOOK_SECRET, in theX-Proton-Agent-Signatureheader. - Local hook:
PROTON_AGENT_ON_NEWruns a command with no shell and no mail data passed to it, so a hostile subject cannot inject into it.
Events carry envelope metadata only (id, sender, subject, date, flags), never bodies. Subjects that look like one-time codes are masked.
All settings are environment variables.
Service
| Variable | Default | Meaning |
|---|---|---|
PROTON_AGENT_TOKEN |
Bearer token (or use the file) | |
PROTON_AGENT_TOKEN_FILE |
Token file; must be mode 0600 or tighter |
|
PROTON_AGENT_BIND |
127.0.0.1 |
Bind address |
PROTON_AGENT_ALLOW_LAN |
1 to allow a non-loopback bind. Avoid. |
|
PROTON_AGENT_PORT |
18765 |
Port |
PROTON_AGENT_INBOX |
default |
Inbox id in URLs |
PROTON_AGENT_STATE |
~/.local/state/proton-agent-mail |
State: events, refused recipients, breaker |
PROTON_AGENT_BREAKER |
Path of the shared send-breaker file |
Bridge connection
| Variable | Default | Meaning |
|---|---|---|
PROTON_AGENT_BRIDGE_FILE |
~/.config/proton-agent-mail/bridge.json |
JSON {user, password}, mode 0600 |
PROTON_AGENT_BRIDGE_USER |
Bridge username | |
PROTON_AGENT_BRIDGE_PASS_FILE |
File holding the Bridge password (with ..._USER) |
|
PROTON_AGENT_BRIDGE_PASS |
Bridge password directly. Prefer a file. | |
PROTON_BRIDGE_HOST |
127.0.0.1 |
Must be loopback |
PROTON_BRIDGE_IMAP_PORT |
1143 |
|
PROTON_BRIDGE_SMTP_PORT |
1025 |
Access control
| Variable | Default | Meaning |
|---|---|---|
PROTON_AGENT_FOLDERS |
all | Folders the agent may touch; first is the default |
PROTON_AGENT_READONLY |
off | 1 blocks send, reply, label changes, mark_read |
PROTON_AGENT_FROM |
agent@localhost |
From: when no sender list is set |
PROTON_AGENT_SEND_AS |
Allowed senders, Name <a@x>;Name <b@x>; first is default |
Attachments
| Variable | Default | Meaning |
|---|---|---|
PROTON_AGENT_QUARANTINE |
~/.local/state/proton-agent-mail/quarantine |
Quarantine root |
PROTON_AGENT_SCAN |
auto |
auto, docker, clamdscan or clamscan |
PROTON_AGENT_CLAM_IMAGE |
clamav/clamav:stable |
Image for the Docker scanner |
PROTON_AGENT_ATTACH_MAX_BYTES |
25 MiB | Size limit |
PROTON_AGENT_ALLOW_UNKNOWN |
1 releases unknown extensions after a clean scan |
Events
| Variable | Default | Meaning |
|---|---|---|
PROTON_AGENT_WATCH_SECS |
0 (off) |
Polling interval for new mail |
PROTON_AGENT_WEBHOOK_URL |
Webhook target | |
PROTON_AGENT_WEBHOOK_SECRET |
HMAC-SHA256 signing secret | |
PROTON_AGENT_ON_NEW |
Local command run on new mail |
The design assumes the agent can be prompt-injected. In short: the agent holds only a bearer token compared in constant time; the Bridge login is a 0600 file; the service binds to loopback and refuses a non-loopback Bridge host; list calls never include bodies; logs redact secrets; folder names and message ids are allow-listed; and attachments are quarantined and scanned.
The full threat model, every guarantee, and an operator checklist are in SECURITY.md. To report a vulnerability, open a private security advisory on GitHub and do not include credentials in public issues.
skills/proton-agent-mail/SKILL.md teaches an agent to set this up with a human and to use the API. It follows the agentskills.io layout and walks the human through connecting Bridge without ever asking for a password in chat. A longer guide is in references/connect-and-guide.md.
python3 -m unittest discover -s tests
bash scripts/security_audit.sh # fails if the tree looks like it contains secrets or operator pathsThe tests include real HTTP requests against the handler with a recording mail client, which prove that a denied request never reaches the mail layer.
Issues and pull requests are welcome. Please keep operator paths, tokens and mailbox contents out of anything you submit, and read SECURITY.md before filing something that might contain credentials. Run the tests and the audit script before opening a pull request.
| Pablo Navarro (@PabloTheThinker) | Author and maintainer |
| Tom Price (@tfp1) | Audit and design work behind folder scoping and read-only mode, attachment support, and the header-encoding and input-validation fixes in PR #1 |
MIT. Copyright (c) 2026 Pablo Navarro and contributors.