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.
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
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 | shThe 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 shOn Windows, download mail-cli-*-windows-amd64.exe from the release page.
cargo install --path . # installs `mail-cli` into ~/.cargo/bin
# or
cargo build --release # binary at target/release/mail-cli| 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.
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 yoRules:
- No
--account→ the default account = the bare, un-suffixed vars. Existing setups are unaffected. - Selector precedence:
--account <TOKEN>>MAILCLI_ACCOUNTenv > none. - The token is numeric by convention but may be any non-empty string, so
--account workreadsMAILCLI_*_work. An empty token is rejected loudly. - Strict, no fallback. Account N is read only from
*_Nnames — 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 needsMAILCLI_IMAP_HOST_N,MAILCLI_EMAIL_N,MAILCLI_PASSWORD_N(plusMAILCLI_SMTP_HOST_Nforsend).
Use mail-cli accounts to see what is configured.
mail-cli [--json] [--account N] <SUBCOMMAND>
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 -List mailboxes for the account.
mail-cli foldersList 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 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 hatchFlags: --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 alicematches any From header containingalice, not an exact address. --rawconflicts 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— uselistfor that). - Results reshuffle
#more thanlistdoes; use--jsonanduid:in scripts.
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 sourceThe output header echoes the resolved UID, e.g. message uid:4821 (#3).
mail-cli attachments INBOX uid:4821mail-cli download INBOX uid:4821 --attachment 1 --out ./downloads
mail-cli download INBOX uid:4821 --allmail-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-sentSaving 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 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 installWithout --force it refuses to clobber existing files, naming the offender.
Add --json to any read command (accounts, folders, list, read,
attachments) for machine-readable output:
mail-cli --json list --limit 5 | jq '.[].uid'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 resolveduid:so a shift between glance and command is visible rather than silent.
MAILCLI_AUTH=login(default):LOGIN/PLAINwith a password or app-specific password.MAILCLI_AUTH=xoauth2:MAILCLI_PASSWORDis treated as an OAuth access token (SASL XOAUTH2). Obtain/refresh the token externally — no token store here.
Unit tests are pure and offline:
cargo testThe 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=1These 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.)
MIT — see LICENSE.