Skip to content

Repository files navigation

proton-mail-bridge-cli

CI

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.

Quickstart

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 PDFs

Proton Mail Bridge must be running. The bridge password (≠ your Proton password) is shown in the Bridge under info. Short alias: pmb = proton-mail-bridge.

For AI agents

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 codex

Operating rules (details in the skill):

  1. Always --json, bulk-first (one task = 1–3 calls).
  2. Multi-account: without --account, message search/list fan out over all accounts.
  3. Token-efficient: message search --ids-only (pipelines), --count-only (count questions).
  4. Discovery: pmb --help, pmb describe <path...> (e.g. describe account identity add), pmb fields message.
  5. Write operations: --dry-run first (every writing command has it), never pass --yes on your own.
  6. Read search.truncated: --limit bounds the matches, and a result that had to stop early says so instead of looking complete.

Login codes

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.

Cleaning up a mailbox

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 folder

bulk-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.

Configuration

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).

Multiple sender addresses

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-run

discover 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.

Signatures

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 + link

import 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.

WSL → Windows bridge

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.

Security

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-run

The 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.

Status & license

Unofficial community client, not operated by Proton. Apache-2.0.

About

Unofficial, agent-native CLI for Proton Mail Bridge (IMAP/SMTP) — multi-account, JSON output for AI agents

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages