Skip to content

akeemjenkins/protoncli

protoncli

An agent-first CLI for Proton Mail. Schema manifest for tool use, NDJSON on stdout, typed error envelopes, and a stable exit-code enum — designed to be driven by a language model, not a human.

CI Release Go Reference License Go

Why this exists

Wiring a normal CLI into an agent loop is painful. Stdout mixes prose and data, errors are English sentences, exit codes are 0-or-1, and the only tool specification is --help. protoncli inverts that. Every design decision assumes the primary caller is a model:

  • Schema manifest. protoncli schema emits a JSON description of every subcommand — flags, args, stdout format, exit codes. An agent loads it once and can drive the tool without prompt-engineered command syntax.
  • NDJSON streaming with a terminator. Every streaming command emits one JSON object per line and ends with {"type":"summary", ...}. Callers know when a stream is done without heuristics.
  • Typed error envelopes. Every failure emits {"error": {"kind", "code", "reason", "message", "hint"}}. Agents branch on .error.kind, not on parsed English.
  • Exit codes as an enum. Exit codes map 1:1 to error kinds, so retry and escalation are deterministic: retry on 5 imap, prompt the user on 2 auth, surface to the caller on 4 config.
  • Sanitized stderr. Human-readable progress on stderr, stripped of ANSI escapes, bidi controls, and zero-width characters — safe to feed back into a model's context.
  • Checkpointed state. SQLite state DB with backfill and state clear so runs are resumable across crashes and restarts.
  • Secrets out-of-band. Credentials go to the OS keyring (macOS Keychain, Windows Credential Manager, libsecret on Linux) with an AES-256-GCM file fallback, so nothing sensitive flows through an agent's context. The first workload riding this contract is a local-only Proton Mail inbox classifier via Proton Mail Bridge and Ollama. The classifier is useful on its own, but the contract is the point.

The classifier

protoncli classify connects to a running Proton Mail Bridge over IMAP, streams each message through a local Ollama model, and applies one or more labels from an 11-category taxonomy. Every step runs on localhost: no API keys, no cloud inference, no telemetry.

The default model is Gemma 4, chosen because it has native function-calling support, a 128K context window on the small variants, and fits comfortably on a laptop.

Quickstart

  1. Install and sign into Proton Mail Bridge. Note the IMAP username and bridge password it assigns.

  2. Install Ollama and pull a model:

    ollama pull gemma4
    
  3. Install protoncli:

    # Release tarball
    curl -L https://github.com/akeemjenkins/protoncli/releases/latest/download/protoncli_0.1.0_darwin_arm64.tar.gz | tar xz
    
    # go install
    go install github.com/akeemjenkins/protoncli/cmd/protoncli@latest
    

    Or build from source:

    git clone https://github.com/akeemjenkins/protoncli.git
    cd protoncli
    make build
    
  4. Export Bridge and Ollama settings. A .env at the repo root works:

    export PM_IMAP_USERNAME="alice@proton.me"
    export PM_IMAP_PASSWORD="bridge-generated-password"
    export PM_IMAP_HOST="127.0.0.1"
    export PM_IMAP_PORT="1143"
    export PM_OLLAMA_MODEL="gemma4"
    
  5. Dry-run against your inbox:

    protoncli classify --dry-run --limit 20 INBOX
    

    Review the NDJSON. When the suggestions look right, rerun with --apply to write labels back to Proton.

The agent contract

Schema manifest

protoncli schema returns a manifest of every subcommand. Load it once, drive the CLI from it.

protoncli schema classify
{
  "name": "classify",
  "summary": "Classify messages with Ollama and optionally apply labels",
  "args": [{"name": "mailbox", "required": false, "default": "INBOX"}],
  "flags": [
    {"name": "dry-run", "type": "bool", "description": "Preview suggestions without writing labels"},
    {"name": "apply", "type": "bool", "description": "Apply suggested labels to IMAP"},
    {"name": "limit", "type": "int", "default": 100},
    {"name": "workers", "type": "int", "default": 4},
    {"name": "no-state", "type": "bool"},
    {"name": "reprocess", "type": "bool"}
  ],
  "stdout": "ndjson",
  "exit_codes": [0, 2, 3, 4, 5, 6, 7, 9]
}

Output contract

  • stdout: structured JSON. Single-object commands pretty-print; streaming commands emit NDJSON.
  • stderr: human-readable progress, sanitized of ANSI escapes, bidi controls, and zero-width characters.
  • NDJSON terminator: every streaming command ends with one {"type":"summary", ...} line. Read until you see it.
  • Error envelope: failures emit a typed envelope with kind, code, reason, message, and hint.
{
  "error": {
    "kind": "config",
    "code": 400,
    "reason": "configError",
    "message": "PM_IMAP_USERNAME is required",
    "hint": "export PM_IMAP_USERNAME=<bridge-username>"
  }
}

Exit codes

Code Kind Description
0 success Command completed without error
1 api Upstream API error (generic)
2 auth Authentication or credential failure
3 validation Invalid flags, arguments, or input
4 config Missing or malformed configuration
5 imap IMAP protocol or connection error
6 classify Classification error (Ollama, prompt, parsing)
7 state State DB error (SQLite)
8 discovery Mailbox discovery failure
9 internal Unexpected internal error

Commands

The intended flow is: discover mailboxes, classify them, apply labels. Everything else (backfill, cleanup-labels, state) exists to repair or inspect state along the way.

scan-folders

Enumerate IMAP mailboxes and return the canonical All Mail and Labels roots.

protoncli scan-folders
{
  "mailboxes": [
    {"name": "INBOX", "delimiter": "/", "messages": 1204, "unseen": 18, "attributes": []},
    {"name": "Labels/Orders", "delimiter": "/", "attributes": ["\\HasNoChildren"]}
  ],
  "all_mail": "All Mail",
  "labels_root": "Labels"
}

classify

Stream messages through Ollama and emit one NDJSON object per message, followed by a summary terminator. Add --apply to write labels back to IMAP in the same pass.

protoncli classify --dry-run --limit 20 INBOX
protoncli classify --apply --workers 4 "Folders/Accounts"
protoncli classify --reprocess --no-state INBOX

Flags: --dry-run, --apply, --limit N, --no-state, --reprocess, --workers N.

{"mailbox":"INBOX","uid":1842,"uid_validity":1,"subject":"Your order has shipped","from":"ship-confirm@amazon.com","date":"2026-04-09T14:22:10Z","suggested_labels":["Orders"],"confidence":0.94,"rationale":"Shipping notification with tracking number","is_mailing_list":false}
{"type":"summary","mailbox":"INBOX","classified":20,"errors":0,"skipped":0}

apply-labels

Apply pending labels recorded in the state DB. Use when classify ran without --apply.

protoncli apply-labels --limit 100 "Folders/Accounts"
protoncli apply-labels --dry-run "Folders/Accounts"

cleanup-labels

Consolidate legacy or user-created labels into the canonical 11-label taxonomy. Useful after migrating from Proton's built-in filters.

protoncli cleanup-labels --dry-run
protoncli cleanup-labels

fetch-and-parse

Fetch and parse messages without classifying. Useful for piping into other tools.

protoncli fetch-and-parse INBOX | jq 'select(.from | contains("github"))'

backfill

Replay a prior classify NDJSON log into the state DB. Recovers state after a crash or migration.

protoncli backfill classify.log

state

Inspect or reset the SQLite state DB.

protoncli state stats
protoncli state stats "Folders/Accounts"
protoncli state clear "Folders/Accounts"

schema

Emit a machine-readable manifest of every subcommand. See The agent contract.

protoncli schema
protoncli schema classify

Configuration

All configuration is read from environment variables. Defaults target a standard Proton Mail Bridge + Ollama setup on the same host.

Credentials

Credentials are stored in the OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service via libsecret) so they never live in shell history or a .env file. When the keyring is unreachable, an encrypted-file fallback (~/.protoncli/credentials.enc, AES-256-GCM with Argon2id-derived keys) is available.

# Prompt interactively and store via the OS keyring (default).
protoncli auth login
 
# Pipe the password in from a secret manager:
pass show proton/bridge | protoncli auth login --username alice@proton.me --password-stdin
 
# Check where credentials live and which backends are available.
protoncli auth status
 
# Remove stored credentials from every backend.
protoncli auth logout

If PM_IMAP_USERNAME and/or PM_IMAP_PASSWORD are set in the environment they always win — useful for one-off overrides in CI or shells. To use the encrypted-file backend, export PM_KEYSTORE_PASSPHRASE (required to read or write the file) and optionally PM_KEYSTORE_PATH to relocate it.

IMAP (Proton Mail Bridge)

Variable Default Description
PM_IMAP_HOST 127.0.0.1 Bridge host
PM_IMAP_PORT 1143 Bridge IMAP port
PM_IMAP_USERNAME (required) Bridge IMAP username
PM_IMAP_PASSWORD (required) Bridge-generated password
PM_IMAP_SECURITY starttls One of starttls, tls, insecure
PM_IMAP_TLS_SKIP_VERIFY auto Skip TLS verification (auto-enabled for loopback)
PM_IMAP_APPLY_TIMEOUT 180 Per-command timeout (seconds) for --apply

Ollama

Variable Default Description
PM_OLLAMA_BASE_URL http://localhost:11434 Ollama API base URL
PM_OLLAMA_MODEL gemma4 Model name passed to Ollama

Classify tuning

Variable Default Description
PM_CLASSIFY_LIMIT 100 Max messages per classify run
PM_CLASSIFY_BATCH_SIZE 25 IMAP fetch batch size
PM_CLASSIFY_WORKERS 4 Parallel Ollama workers

State

Variable Default Description
PM_STATE_DB ~/.protoncli/state.db SQLite state DB path

Label taxonomy

The classifier is constrained to 11 canonical labels. 612 aliases in internal/labels/data/labels.toml normalize legacy or model-generated names back to this set.

Label Covers
Orders Purchase confirmations, shipping, returns
Finance Banks, cards, taxes, invoices
Newsletters Editorial digests, blog mailings
Promotions Marketing, discounts, sales
Jobs Recruiters, job boards, offers
Social Social network notifications, friend activity
Services SaaS account activity, product updates
Health Providers, pharmacy, insurance
Travel Flights, hotels, itineraries
Security 2FA, password resets, security alerts
Signups Account creation, email verification

See internal/labels/data/labels.toml for the full alias map.

Development

Every common task is wrapped in the repo Makefile.

Target Description
make build Build the ./bin/protoncli binary
make test Run go test ./...
make test-race Run tests with the race detector
make cover Coverage profile plus go tool cover -func summary
make cover-html HTML coverage report at coverage.html
make vet Run go vet ./...
make vuln Run govulncheck (installs into ./bin if missing)
make check vet + test-race + vuln
make clean Remove the built binary and coverage outputs
make tidy Run go mod tidy

Run make check before opening a PR.

Contributing

Bug reports and pull requests are welcome — see CONTRIBUTING.md for the workflow and code-review expectations.

Security

Please report vulnerabilities privately via the process in SECURITY.md. Do not open public issues for security reports.

License

Apache License 2.0 — see LICENSE.

Acknowledgements

About

Local-only Proton Mail inbox classifier — connects to Proton Mail Bridge over IMAP and uses a local Ollama model to classify, label, and organize your mail. No data leaves your machine.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors