Scripting
Data goes to stdout, everything else to stderr, and exit codes say what went wrong. So proton composes with the rest of your shell instead of fighting it.
See Output and exit codes for the response shapes in full.
Capture a new ID
Section titled “Capture a new ID”Commands that create something print the new ID to stdout, so a plain assignment works:
LABEL=$(proton mail settings labels create --name Work --color purple)VAULT=$(proton pass vaults create --name Automation)MSG=$(proton mail messages send --to me@proton.me --subject Deploy --body "Done.")Read JSON with jq
Section titled “Read JSON with jq”# every unread subjectproton mail messages list --unread --output json | jq -r '.messages[].subject'
# senders of everything older than a week, deduplicatedproton mail messages list --before 2026-04-08 --folder all --limit 0 --output json | jq -r '.messages[].from_address' | sort -u
# total size of a Drive folderproton drive items list /Backup --output json | jq '[.items[].size] | add'
# every vault nameproton pass vaults list --output json | jq -r '.vaults[].name'
# today's agenda, one line per eventday=$(date +%F)proton calendar events list --after "$day" --before "$day" --output json | jq -r '.events[] | if .all_day then "all day \(.title)" else "\(.start[11:16]) \(.title)" end'Every list is an object keyed by its plural name, always with a count:
proton mail messages list --output json | jq '.count'proton drive items list /Backup --output json | jq -r '.items[].name'Keys are snake_case, IDs are always complete, and enumerated values are names rather than numbers: "type": "file", not "type": 2.
Use exit codes as control flow
Section titled “Use exit codes as control flow”if proton pass items get "deploy-key" >/dev/null 2>&1; then echo "secret exists"fi
proton contacts get janecase $? in 0) echo "found" ;; 3) echo "no such contact" ;; 4) echo "ambiguous, be more specific" ;;esacA mutation also reports itself structurally, which is easier to check than parsing a sentence:
proton mail messages trash --older-than 1y --output json | jq '.count'Stream instead of writing temporary files
Section titled “Stream instead of writing temporary files”A single - means stdin for an input and stdout for an output:
# back up a database straight into Drivepg_dump mydb | gzip | proton drive items upload - /Backups/db.sql.gz
# restore it without landing on diskproton drive items download /Backups/db.sql.gz --dest - | gunzip | psql mydb
# mail a report generated on the flygenerate-report | proton mail messages send --to team@example.com --subject "Nightly report" --body -
# encrypt something with your own tooling on the way outproton drive items download /report.pdf --dest - | gpg --encrypt --recipient me > report.pdf.gpgArchive mail to disk
Section titled “Archive mail to disk”export writes ordinary RFC 822 .eml files.
# a year of archive, one .eml per message, named "<date> <subject>.eml"proton mail messages export --folder archive --older-than 1y --all --dest-dir ./mail-backup
# a whole folder as a single mbox, ready for Thunderbird or muttproton mail messages export --folder inbox --all --format mbox --dest inbox.mbox
# one message straight into another toolproton mail messages export "Invoice #2291" --dest - | formail -X ""
# metadata and bodies only, skipping attachment downloads - much fasterproton mail messages export --folder all --all --no-attachments --dest-dir ./indexExported files are not encrypted, so put them somewhere you would be comfortable putting the mail itself.
To go the other way, read a file back into a draft or a send:
proton mail drafts create --eml ./message.emlproton mail messages send --eml ./message.eml --to someone-else@proton.meAnswer mail from a script
Section titled “Answer mail from a script”# acknowledge everything unread from a sender, then archive itproton mail messages list --from alerts@example.com --unread --folder all --output json | jq -r '.messages[].id' | while read -r id; do proton mail messages reply "$id" --body "Received, thanks." --no-signature proton mail messages move "$id" --into archive doneRecipes
Section titled “Recipes”Desktop notifications
Section titled “Desktop notifications”mail messages watch and calendar reminders watch stay attached and print a line the moment something happens, so whatever shows notifications on your machine can read from them.
# Linuxproton mail messages watch --output json | jq --unbuffered -r '[.from_name, .subject] | @tsv' | while IFS=$'\t' read -r from subject; do notify-send "$from" "$subject"; done
# macOSproton mail messages watch --output json | jq --unbuffered -r '[.from_name, .subject] | @tsv' | while IFS=$'\t' read -r from subject; do osascript -e "display notification \"$subject\" with title \"$from\"" done
# Windows (PowerShell, with the BurntToast module)proton mail messages watch --output json | jq --unbuffered -c . | ForEach-Object { New-BurntToastNotification -Text $_.from_name, $_.subject}Calendar reminders carry a ready-made sentence in says, so theirs is shorter:
proton calendar reminders watch --output json | jq --unbuffered -r .says | while read -r line; do notify-send "Reminder" "$line"; doneA watcher reports what happens while it is watching. It never replays what arrived before it started.
Unread count in a status bar
Section titled “Unread count in a status bar”proton mail conversations count --folder inbox --output json | jq '.folders_or_labels[0].unread'Waybar, polybar, i3blocks and tmux can each run that on an interval. A minute apart is plenty; see Rate limits before polling faster.
Run a watch under systemd
Section titled “Run a watch under systemd”[Unit]Description=Proton Mail arrivals
[Service]Environment=PROTON_NO_INPUT=1ExecStart=/bin/sh -c 'proton mail messages watch --quiet --output json \ | jq --unbuffered -r "[.from_name, .subject] | @tsv" \ | while IFS=$(printf "\t") read -r f s; do notify-send "$f" "$s"; done'Restart=always
[Install]WantedBy=default.target[Unit]Description=Proton Calendar reminders
[Service]Environment=PROTON_NO_INPUT=1ExecStart=/bin/sh -c 'proton calendar reminders watch --quiet --output json \ | jq --unbuffered -r .says \ | while read -r line; do notify-send Reminder "$line"; done'Restart=always
[Install]WantedBy=default.targetsystemctl --user enable --now proton-mail-watch proton-reminders-watch starts both. A watch stops cleanly on SIGTERM, so systemctl --user stop is not logged as a failure.
The same unit shape keeps a local index current, so a search answers without catching up first. While a watch runs, a search reads the copy as of its last poll, at most 30 seconds old:
[Unit]Description=Proton local index
[Service]Environment=PROTON_NO_INPUT=1ExecStart=/usr/local/bin/proton index watch --quietRestart=always
[Install]WantedBy=default.targetWith a timer instead of a daemon, run proton index update on a schedule. A search then catches the index up itself, so it answers from the account as of the command.
Which folders count as an arrival is a setting of its own. mail settings folders list shows it per folder under NOTIFY, and folders create and folders update take --notify. Without --folder, the watch covers the inbox plus every folder marked that way.
Nightly backup with cron
Section titled “Nightly backup with cron”0 3 * * * /usr/local/bin/proton-backup >/dev/null#!/usr/bin/env bashset -euo pipefail
# Signing in again as the same account does nothing, so running this every time# costs nothing and recovers on its own from a session that expired.proton account login --user me@proton.me --password-file ~/.proton-pwproton drive items upload --recursive /var/backups /BackupsKeep the inbox tidy
Section titled “Keep the inbox tidy”#!/usr/bin/env bashset -euo pipefail
# archive read newsletters older than a weekproton mail messages move --into archive --from newsletter@example.com --older-than 7d
# bin anything left in spam after a monthproton mail messages delete --folder spam --older-than 30d --yesRun it once with --dry-run appended to each command before trusting it.
The --yes is not optional. A cron job has no terminal, so anything that removes permanently, or removes what a filter picked out, refuses rather than waiting for an answer nobody can give. See Dry runs and confirmations.
Fence off what a job may do
Section titled “Fence off what a job may do”When the thing running proton is a scheduler or an agent rather than you, say what it may not do and let a mistake fail loudly:
export PROTON_CONFIRM='deletions=deny, drive:all=deny'A permanent deletion then exits 6 and touches nothing, and --yes does not get past it. So the --yes your job needs for the trashing it is meant to do cannot quietly authorise more.
To keep a policy from following you around, point the job at its own file:
PROTON_CONFIG=/etc/proton/backup.yaml proton drive items upload ~/Documents /BackupsThe full syntax is in Writing a policy. When the thing running proton is an agent, AI agents covers what it can be taught and what to fence off.
Systemd timer
Section titled “Systemd timer”[Service]Type=oneshotEnvironment=PROTON_NO_INPUT=1LoadCredential=proton:%h/.proton-pwExecStart=/usr/bin/proton account login --user me@proton.me --password-file %d/protonExecStart=/usr/bin/proton drive items upload --recursive %h/Documents /Backups[Timer]OnCalendar=dailyPersistent=true
[Install]WantedBy=timers.targetOut of office
Section titled “Out of office”proton mail settings autoreply set --repeat fixed --start "$(date -d 'next monday 09:00' +%Y-%m-%dT%H:%M)" --end "$(date -d 'next friday 18:00' +%Y-%m-%dT%H:%M)" --body "Away this week. For anything urgent, contact team@example.com."
# and when you are backproton mail settings autoreply disableAn alias per signup
Section titled “An alias per signup”alias() { proton pass aliases create --prefix "$1" --mailbox me@proton.me}alias newsletter-xyzBefore you automate
Section titled “Before you automate”- Credentials. Hand the password to
account loginwith--password-file, from a path only your user can read; systemd’sLoadCredential=, Kubernetes secrets and Docker secrets all give you one. A two-password account adds--second-password-file, a Pass extra password adds--extra-password-file. See Sign in without a terminal. - Two-factor. A code or a security key is only asked for at a fresh login. Sign in once at a terminal so the session file exists, then let the job reuse it.
- Commands that ask for the password again. A few do, even with a session; they take
--password-fileand--password-file -of their own. The list. - Secrets you store.
pass items createandpass items updatetake them from--secret-file NAME=FILEor--secret-file NAME=-, never from a flag value. See Secrets. - CAPTCHAs. A job that is asked for one exits
2with the page and a token; solve the page, then run the command again with--verified TOKEN. See Solving a CAPTCHA in a script. - Quiet output.
--quietsilences the✓lines and progress bars, which is what cron wants. - Failures. Exit
5means come back later,2means fix the credential, and retrying never helps with6or8. See Exit codes. - Rate limits. A loop that acts on many things should sleep between iterations. See Everything started failing at once.
- Search lag. A change reaches
lista few seconds late. Act on the ID the command printed rather than searching for it again.