Skip to content

Repository files navigation

mail-cli

A single-binary, stateless command-line email client in Rust. Read, list, fetch, and search mail over IMAP; send mail (with attachments) over SMTP.

Configuration comes entirely from environment variables; everything else is a CLI flag. No config file, no cache, no stored state of any kind. Human-friendly by default, script-friendly with --json.

See AGENTS.md for the design rationale and contributor guide.

Quick install (for agents)

Copy-paste this one-liner into your coding agent (or your own shell) to install the binary and the bundled skill:

Install mail-cli by running: curl -fsSL https://raw.githubusercontent.com/patch-notes/mail-cli/master/install.sh | sh

Install

One line — downloads the right prebuilt binary for your OS/arch from the latest release and installs the bundled agent skill:

curl -fsSL https://raw.githubusercontent.com/patch-notes/mail-cli/master/install.sh | sh

The script auto-detects your platform (linux-amd64, macos-amd64, macos-arm64), installs to /usr/local/bin (falling back to ~/.local/bin), and runs mail-cli install-skill. Override the target with BINDIR=... or skip the skill with INSTALL_SKILL=0:

curl -fsSL .../install.sh | BINDIR="$HOME/.local/bin" INSTALL_SKILL=0 sh

On Windows, download mail-cli-*-windows-amd64.exe from the release page.

Build from source (optional)

cargo install --path .        # installs `mail-cli` into ~/.cargo/bin
# or
cargo build --release         # binary at target/release/mail-cli

Configuration (environment only)

Variable Required Default Meaning
MAILCLI_IMAP_HOST for read cmds IMAP server host.
MAILCLI_IMAP_PORT no 993 IMAP port.
MAILCLI_SMTP_HOST for send SMTP server host.
MAILCLI_SMTP_PORT no 465 SMTP port.
MAILCLI_EMAIL yes Account address (From + default login user).
MAILCLI_USER no =EMAIL Login username if different from the address.
MAILCLI_PASSWORD yes Password / app-password / (with MAILCLI_AUTH=xoauth2) an OAuth access token.
MAILCLI_TLS no implicit implicit (TLS on connect) or starttls.
MAILCLI_AUTH no login login (PLAIN/LOGIN) or xoauth2 (token passthrough).
MAILCLI_SENT_FOLDER no auto → Sent Folder to save sent mail into. If unset, send auto-discovers the server's \Sent special-use mailbox, else falls back to Sent.
MAILCLI_SAVE_SENT no 1 Whether send files a copy in the Sent folder (0/false/no/off to disable). Requires IMAP config.
MAILCLI_ACCOUNT no Default account selector when --account is omitted (see Multiple accounts).

Only the variables a given command needs are required. Switch accounts by switching environment (shell, direnv, a wrapper script), or configure several at once with a numeric suffix and pick one per invocation — see Multiple accounts.

Multiple accounts

Configure several accounts at once by giving each a numeric suffix on its MAILCLI_* vars (_1, _2, …), then pick one per run with --account:

# default account = the bare vars (works exactly as before)
export MAILCLI_IMAP_HOST=imap.work.com
export MAILCLI_EMAIL=me@work.com
export MAILCLI_PASSWORD=…

# account 1
export MAILCLI_IMAP_HOST_1=imap.gmail.com
export MAILCLI_EMAIL_1=me@gmail.com
export MAILCLI_PASSWORD_1=…

mail-cli list                 # default account
mail-cli --account 1 list     # account 1
mail-cli --account 1 send --to a@b.com --subject Hi --body yo

Rules:

  • No --account → the default account = the bare, un-suffixed vars. Existing setups are unaffected.
  • Selector precedence: --account <TOKEN> > MAILCLI_ACCOUNT env > none.
  • The token is numeric by convention but may be any non-empty string, so --account work reads MAILCLI_*_work. An empty token is rejected loudly.
  • Strict, no fallback. Account N is read only from *_N names — there is no inheritance from the bare vars. A missing value fails naming the exact var (e.g. required environment variable MAILCLI_PASSWORD_2 is not set). Because the optional vars already default (993/465/implicit/login, USER = EMAIL), an account only needs MAILCLI_IMAP_HOST_N, MAILCLI_EMAIL_N, MAILCLI_PASSWORD_N (plus MAILCLI_SMTP_HOST_N for send).

Use mail-cli accounts to see what is configured.

Usage

mail-cli [--json] [--account N] <SUBCOMMAND>

accounts

List configured accounts discovered from the environment. Reports which look complete; never prints passwords (only whether one is set). Reads env but opens no connection.

mail-cli accounts
# ACCOUNT     STATUS        EMAIL              IMAP HOST          SMTP HOST
# default     ok            me@work.com        imap.work.com      smtp.work.com
# 1           ok            me@gmail.com       imap.gmail.com     -
# 2           incomplete    me@example.com     imap.example.com   -

folders

List mailboxes for the account.

mail-cli folders

list

List messages newest-first.

mail-cli list --folder INBOX --limit 20
mail-cli list --search "FROM alice UNSEEN"

Columns: # UID FROM SUBJECT DATE 📎. # is a cosmetic row number for this listing only. UID is the stable machine id — use it across commands.

search

Search a folder with typed flags. They compile to a single server-side IMAP SEARCH and are AND-combined; the output is a normal listing (same columns and --json as list).

mail-cli search --from alice --unseen
mail-cli search --subject "invoice" --since 2026-01-01 --before 2026-07-01
mail-cli search --to bob --larger 1000000        # >1 MB to bob
mail-cli search --raw "OR FROM alice FROM bob"   # raw IMAP escape hatch

Flags: --from --to --cc --subject --body --text, dates --since/--before/--on (YYYY-MM-DD), state --seen/--unseen --flagged/--unflagged --answered, size --larger/--smaller BYTES, plus --folder and --limit.

Notes:

  • Substring, server-side match--from alice matches any From header containing alice, not an exact address.
  • --raw conflicts with all structured flags; conflicting state pairs (--seen/--unseen, --flagged/--unflagged) are rejected.
  • With no criteria it errors loudly (it will not silently SEARCH ALL — use list for that).
  • Results reshuffle # more than list does; use --json and uid: in scripts.

read

Render one message. SELECTOR is either a live index N or a stable uid:NNN.

mail-cli read INBOX 3          # 3rd newest right now
mail-cli read INBOX uid:4821   # stable — what scripts should use
mail-cli read INBOX 3 --html   # raw HTML part
mail-cli read INBOX 3 --raw    # untouched RFC822 source

The output header echoes the resolved UID, e.g. message uid:4821 (#3).

attachments

mail-cli attachments INBOX uid:4821

download

mail-cli download INBOX uid:4821 --attachment 1 --out ./downloads
mail-cli download INBOX uid:4821 --all

send

mail-cli send --to a@x.com,b@y.com --cc c@z.com \
  --subject "Hello" --body "Hi there" \
  --attach ./report.pdf

# body from a file, or from stdin with '-'
mail-cli send --to a@x.com --subject S --body-file note.txt
echo "hi" | mail-cli send --to a@x.com --subject S --body-file -

# HTML body
mail-cli send --to a@x.com --subject S --html --body '<p>Hi</p>'

# Sent copy: by default `send` files a copy in the Sent folder over IMAP.
mail-cli send --to a@x.com --subject S --body hi --sent-folder "Sent Items"
mail-cli send --to a@x.com --subject S --body hi --no-save-sent

Saving to Sent. SMTP delivery does not put a copy in your Sent mailbox — that lives on the IMAP side. After a successful send, mail-cli files an identical copy into your Sent folder (flagged read) via IMAP APPEND. The folder is resolved as --sent-folder > MAILCLI_SENT_FOLDER > the server's \Sent special-use mailbox (auto-discovered) > the literal Sent.

This is best-effort: the message is already delivered, so a failed copy only prints a warning: and still exits 0 (a non-zero exit would invite a retry that double-sends). Disable it with --no-save-sent or MAILCLI_SAVE_SENT=0. If IMAP isn't configured, saving is skipped with a one-line note:.

install-skill

Install the agent usage skill that ships inside the binary — no repo clone needed. It writes SKILL.md plus its references/ and evals/ to disk. Purely local: no MAILCLI_* env vars are read.

mail-cli install-skill                       # -> ~/.agents/skills/mail-cli
mail-cli install-skill --dir ./my-skills/mc  # custom location
mail-cli install-skill --force               # overwrite an existing install

Without --force it refuses to clobber existing files, naming the offender.

JSON output

Add --json to any read command (accounts, folders, list, read, attachments) for machine-readable output:

mail-cli --json list --limit 5 | jq '.[].uid'

Message references (stateless)

Two ways to name a message, both without any cache:

  • uid:NNN — canonical, stable id. Scripts should use this.
  • N — a live index, re-derived each call (read INBOX 3 = select INBOX, list newest-first, take the 3rd). Valid only for the mailbox's current state. Commands echo the resolved uid: so a shift between glance and command is visible rather than silent.

Authentication

  • MAILCLI_AUTH=login (default): LOGIN/PLAIN with a password or app-specific password.
  • MAILCLI_AUTH=xoauth2: MAILCLI_PASSWORD is treated as an OAuth access token (SASL XOAUTH2). Obtain/refresh the token externally — no token store here.

Testing

Unit tests are pure and offline:

cargo test

The search query builder and parsing/selector logic are fully covered here.

Integration tests exercise search, the save-to-Sent APPEND/discovery paths, and a full send→INBOX+Sent e2e loop against a real IMAP/SMTP server (GreenMail). They are gated on MAILCLI_IT=1 so a plain cargo test never needs a server:

podman run -d --name greenmail-mailcli -p 3025:3025 -p 3143:3143 \
  -e GREENMAIL_OPTS='-Dgreenmail.setup.test.smtp -Dgreenmail.setup.test.imap -Dgreenmail.hostname=0.0.0.0 -Dgreenmail.auth.disabled' \
  docker.io/greenmail/standalone:2.1.0

MAILCLI_IT=1 cargo test --test search_integration --test sent_integration --test send_e2e -- --test-threads=1

These also run automatically on every PR via the integration (GreenMail) CI job. send_e2e builds a message with mail-cli's real builder, delivers it, and files the copy with mail-cli's real IMAP APPEND — only the encrypted transport hop is substituted (mail-cli's SMTP TLS trusts bundled roots only, so it can't validate GreenMail's self-signed cert; the payload sent is byte-identical). (docker works too if you prefer it to podman.)

License

MIT — see LICENSE.

About

A single-binary, stateless command-line email client in Rust. Read, list, fetch, and search mail over IMAP; send mail (with attachments) over SMTP.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages