Unofficial, agent-native CLI for Proton Mail Bridge — search, read, send, and organize mail through the local IMAP/SMTP gateway. Multi-account, JSON output for AI agents, robust from WSL against a Windows-hosted bridge. Not operated by Proton.
uv tool install git+https://github.com/evrenverse/proton-mail-bridge-cli
pmb account add # wizard: host/ports + email + bridge password, tests the login
pmb --json message search --text "invoice" --since 2026-01-01 --with-body
pmb --json message search --attachment-text 26593328 --since 2026-01-01 # inside the PDFsProton Mail Bridge must be running. The bridge password (≠ your Proton password) is shown
in the Bridge under info. Short alias: pmb = proton-mail-bridge.
You can hand this block to an agent as-is ("here's the repo, install it"):
# 1. Install (requires uv: https://docs.astral.sh/uv/)
uv tool install git+https://github.com/evrenverse/proton-mail-bridge-cli
# 2. Check connectivity — Proton Mail Bridge must be running
pmb --json bridge doctor
# 3. Set up an account (non-interactive). The bridge password (≠ Proton password)
# is shown in the Bridge under `info` — ask the human for it, never guess.
pmb account add-raw --email you@proton.me --password '<bridge-password>'
pmb --json account test
# 4. Install the agent skill into the project (docs + workflows for the agent)
pmb skill install --agent claude # or --agent codexOperating rules (details in the skill):
- Always
--json, bulk-first (one task = 1–3 calls). - Multi-account: without
--account,message search/listfan out over all accounts. - Token-efficient:
message search --ids-only(pipelines),--count-only(count questions). - Discovery:
pmb --help,pmb describe <path...>(e.g.describe account identity add),pmb fields message. - Write operations:
--dry-runfirst (every writing command has it), never pass--yeson your own. - Read
search.truncated:--limitbounds the matches, and a result that had to stop early says so instead of looking complete.
An agent logs into a shop, the shop mails a one-time code, a form wants it back:
pmb --json message otp --from voelkner.de --since 10m
{"code": "867674", "from": "noreply@voelkner.de", "age_seconds": 411,
"folders": ["INBOX", "Spam"], "ambiguous": false, "also_found": [], …}--since is a duration here (10m, 2h, 1d), not the ISO date the other commands take,
and it defaults to ten minutes — a code from yesterday is not a code. Without --folder it
reads INBOX and Spam, and falls back to All Mail when neither holds a code — a mail filter
can move a verification mail out of both, and All Mail is the one scope that cannot miss a
folder. Plain output is the bare code on stdout, context on stderr, so
pmb message otp --from shop.de | xclip is the whole story.
The code is in code and nowhere else. No error message quotes one, and also_found
names the messages the winner displaced by uid/from/date and their shape ("6 digits")
without their codes — a failed run can be logged or pasted safely. ambiguous: true means
the window held more than one candidate and the newest won; read it before using the code,
the way search.truncated is read, and narrow with --from.
What it refuses matters more than what it finds. Mail is full of numbers that look like
codes — postal codes and register numbers in the imprint, order numbers, an expiry
timestamp, the sender's IP, a discount code — so a candidate only counts when a genuine code
word stands next to it, a grouped code (582 800) must come out in equal groups, and digits
beat letters. A message offering several candidates is never used: nothing in it says which
one the form wants. Pure-letter codes are refused rather than guessed; fall back to
message search --with-body for those.
pmb --json message senders --min-count 20 # who sends the volume (headers only)
pmb --json message search --list-unsubscribe --limit 0 # everything with an unsubscribe link
pmb --json --account you@proton.me message bulk-delete --all-folders \
--from newsletter@example.com --dry-run # preview, one entry per folderbulk-move --dest F and bulk-delete take the same selection options as search, run folder
by folder and skip All Mail (read-only duplicate view). --log FILE writes one JSON line per
deleted message — the only record that survives --expunge.
List-Unsubscribe is a bulk-sender signal, not proof of advertising: project boards and
portals set it on notifications that matter. Selection criterion, never an auto-delete rule.
Env vars (PROTON_BRIDGE_HOST/IMAP_PORT/SMTP_PORT/USER/PASS/ACCOUNT) or a config file
(~/.config/proton-mail-bridge/config.toml, Windows %APPDATA%). Template: config.example.toml.
macOS: the Bridge often runs SMTP in SSL mode (IMAP stays STARTTLS). pmb account add
detects this automatically; after the fact: pmb bridge config --smtp-security ssl
(diagnosis: pmb bridge doctor).
A Proton account can own several addresses. With the Bridge in combined-addresses mode a single login covers all of them:
pmb --json account identity discover # preview: senders found in Sent
pmb --json account identity discover --save # write them into the config
pmb account identity set-default kontakt@proton.me # default sender for this account
pmb --json compose send --identity kontakt@proton.me --to a@x.de --subject S --body B --dry-rundiscover fills in email/name only — it never invents labels. Labels come from
account identity add --label (or editing the config file); use the address with
--identity/set-default otherwise. reply and forward answer from the address the
original mail was sent to.
The Bridge is a plain SMTP relay: it forwards the message it is handed and never adds the signature configured in the Proton apps — that one is inserted by Proton's own composers only. So it has to live in a local file per identity:
pmb --json account identity signature import --identity kontakt # preview
pmb --json account identity signature import --identity kontakt --save # write + linkimport reads back through the Sent folder (--scan, 25 messages by default) until it finds
one that a Proton composer sent — mail sent through this CLI carries no signature and is
exactly what sits at the top of Sent. It then writes kontakt.sig (plus kontakt.sig.html)
next to config.toml and points the identity at them. An embedded logo is written along with
them as kontakt.sig.0.png, and the cid: in the HTML is repointed at that file — the cid
is the file name, so no third config entry is needed. Only Proton's own
protonmail_signature_block counts; when an address has no signature configured, Proton
marks the block empty and the command says so instead of guessing. Writing the files by hand
works just as well:
identities = [
{ email = "kontakt@proton.me", label = "kontakt",
signature_file = "kontakt.sig", signature_html_file = "kontakt.sig.html" },
]Relative paths resolve against the config directory, and images sit next to the HTML file.
Every compose command appends the signature of the sending identity; --no-signature sends
without it. An HTML signature adds an HTML part to a plain-text mail as well — a logo only
travels as markup — and every image it references is attached inline, so it renders instead
of leaving a hole. A cid: without a file next to the HTML is a hard error rather than a
silently broken image.
127.0.0.1 is tried first (works with WSL in mirrored networking mode, native macOS,
Windows, and Linux without any special path). If that fails inside WSL, the CLI automatically
probes the Windows host IP (gateway/nameserver). Diagnosis: pmb bridge doctor.
Write operations are risk-tiered (🟢/🟡/🔴) and every one of them takes --dry-run:
pmb --json --account you@proton.me message delete --uid 12,13,14 --dry-runThe dry run resolves the whole operation and prints it — for UID operations including one entry
per message (uid, date, sender, subject, size, flags), the resulting risk tier, and the UIDs the
folder does not hold — then stops. It changes nothing, not even \Seen (headers are fetched
with BODY.PEEK), and it needs no confirmation, because there is nothing to confirm. That makes
it the way to inspect a 🔴 operation that would otherwise demand a terminal.
Read-only commands (search, read, list, mailbox list, …) have no --dry-run; neither
does account identity discover, which without --save already is the preview.
TLS against the self-signed bridge certificate is unverified by default (loopback/trusted host);
pin it via tls_cert_path in the config file. See SECURITY.md.
Unofficial community client, not operated by Proton. Apache-2.0.