Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ before non-trivial work — it is the locked source of truth.
```
src/
main.rs // clap surface, Selector enum, live selector resolution, dispatch
config.rs // the ONLY boundary that reads env; per-command validation
config.rs // the ONLY boundary that reads env; per-command validation;
// Env account-suffix resolver + accounts scan
imap.rs // the ONLY place that talks IMAP (connect/auth/list/fetch)
parse.rs // mail-parser wrappers; Summary/Rendered/Attachment structs
smtp.rs // lettre message build (+attachments) and send
Expand All @@ -20,6 +21,10 @@ src/
skill/ // the bundled agent skill (SKILL.md + references/ + evals/),
// embedded into the binary via include_str! and materialized by
// `mail-cli install-skill`. Keep in sync with the shipped skill.

plans/ // local, git-ignored working design docs (e.g. ACCOUNTS-PLAN.md).
// The directory stays in the repo via its tracked .gitignore; its
// contents are NOT committed. Write plans here, not at root.
```

Structs live with the module that owns them (no `model.rs`). Keep IMAP calls
Expand Down Expand Up @@ -60,3 +65,10 @@ inside `imap.rs` so a future async swap stays contained.
- Secrets come only from `MAILCLI_PASSWORD` (env). Never persist, prompt, or log
credentials. Guard the stateless line — persistence must be explicit and
opt-in, never an implementation detail.
- **Accounts are env-suffixed, selection is a CLI flag.** Config *values* stay
env-only; the runtime *choice* of which account to use is `--account <TOKEN>`
(global) with `MAILCLI_ACCOUNT` as the default. No `--account` → the bare
`MAILCLI_*` vars (the `default` account). An account is read strictly from its
own `*_<TOKEN>` names — no fallback to the bare vars. All suffix resolution
lives in `config.rs::Env`; `main.rs` only parses the flag and passes `&Env`
in. `mail-cli accounts` scans the env and must never print password values.
61 changes: 56 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,66 @@ cargo build --release # binary at target/release/mail-cli
| `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_ACCOUNT` | no | — | Default account selector when `--account` is omitted (see [Multiple accounts](#multiple-accounts)). |

Only the variables a given command needs are required. Switch accounts by
switching environment (shell, `direnv`, a wrapper script) — there are no
profiles.
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).

## 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`:

```sh
# 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] <SUBCOMMAND>
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.
```sh
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
Expand Down Expand Up @@ -156,8 +207,8 @@ Without `--force` it refuses to clobber existing files, naming the offender.

## JSON output

Add `--json` to any read command (`folders`, `list`, `read`, `attachments`) for
machine-readable output:
Add `--json` to any read command (`accounts`, `folders`, `list`, `read`,
`attachments`) for machine-readable output:
```sh
mail-cli --json list --limit 5 | jq '.[].uid'
```
Expand Down
60 changes: 60 additions & 0 deletions memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,66 @@
Agent one-liner is now just the curl|sh command. Left AGENTS.md untouched (it
documents src/ internals, not top-level scripts).

- 2026-07-09 (rev 12): PLANNED multi-account support (env-var suffixes _1/_2/…).
Wrote ACCOUNTS-PLAN.md (plan only, not implemented). Design: an account = the
set of MAILCLI_* vars sharing a numeric suffix. Selector resolution =
`--account <TOK>` global flag > MAILCLI_ACCOUNT env > none(=bare vars=today's
DEFAULT account, zero breakage). Token is numeric by convention but any
non-empty string works (one code path; `--account work`→*_work free superset);
empty token rejected loud. FALLBACK = STRICT/no inheritance (v1): acct N reads
ONLY *_N; missing→error naming the real var e.g. MAILCLI_PASSWORD_2. OK because
optional vars already default (993/465/implicit/login, USER=EMAIL) so an acct
only MUST set IMAP_HOST/EMAIL/PASSWORD (+SMTP_HOST for send). Layered
inheritance deferred behind opt-in MAILCLI_ACCOUNT_INHERIT (v2, non-goal).
IMPL confined to config.rs+main.rs: introduce `Env{suffix:Option<String>}` with
var(base)/opt/required/port/tls_mode/auth_method/login_user methods; from_env
takes &Env. main.rs adds global `--account` flag, resolves once, threads &Env
into cmd_* (install-skill untouched—reads no env). config.rs STAYS the ONLY env
boundary (confirmed via grep: skill.rs only reads HOME/USERPROFILE). Tests:
pure Env::var/new cases; optional injected lookup fn for from_env DI (avoids
env-race mutex). Optional `accounts` subcommand (milestone B) lists configured
suffixes WITHOUT printing PASSWORD. Milestones A(core)/B(discovery)/C(docs+
SKILL sync+eval — skill/ must stay byte-identical, per rev 8). TENSION noted:
rev 2 dropped --account/profiles; this env-only suffix form stays inside the
env-only+stateless constraints (no file/cache), so it supersedes that line ONLY
for this form — user must confirm.

- 2026-07-09 (rev 13): IMPLEMENTED multi-account (ACCOUNTS-PLAN.md, all 3
milestones A/B/C). User confirmed: ship `accounts` cmd; no --account = default
(bare vars). config.rs: replaced free opt/required/port/tls_mode/auth_method/
login_user fns with methods on new pub `Env{suffix:Option<String>, lookup:
fn(&str)->Option<String>}`; var(base) appends `_<suffix>`; Env::new(None)=
default, Some(empty/ws)=loud error, Some(tok)=suffix. ImapConfig/SmtpConfig::
from_env now take &Env. Added pub `scan_accounts()` + pure `scan_accounts_from(
Vec<(String,String)>)` + `AccountInfo{name,email,imap_host,smtp_host,
has_password}` (serde::Serialize, NO password value ever) + account_sort_key
(default<numeric<lexical). main.rs: global `--account:Option<String>` flag
(next to --json); run() resolves account = flag > MAILCLI_ACCOUNT env > None,
builds Env once, threads &env into cmd_folders/list/read/attachments/download/
send; install-skill untouched. New `Accounts` subcommand → cmd_accounts(json)
→ output::accounts_human (STATUS ok/incomplete, never prints pw) or --json.
output.rs imports crate::config::AccountInfo. 11 new config tests (var
resolution, empty-token reject, suffixed-error naming via injected `|_|None`
lookup, scanner: default+numeric, incomplete-no-leak, sort order, empty-value
skip). 45 tests total pass (16 lib+26 bin+3 integ), clippy clean (used
let-chains to collapse if, sort_by_key, inlined literal), fmt clean. Smoke
verified real binary: accounts table+json (no pw leak), empty --account
rejected, MAILCLI_PASSWORD_2 / MAILCLI_IMAP_HOST_3 named on miss, MAILCLI_
ACCOUNT env selects, --help shows global --account. Docs: README (config table
+MAILCLI_ACCOUNT row, new "Multiple accounts" section, accounts subcommand,
usage synopsis `[--account N]`), AGENTS.md (config.rs desc + Security rule
"accounts env-suffixed, selection is a CLI flag"). SKILL synced: SKILL.md
(+MAILCLI_ACCOUNT, Multiple accounts section, accounts cmd), references/
reference.md (AccountInfo JSON schema, Accounts section, error cases, fixed
stale "no --account/profiles" invariant), evals.json (+eval 6 --account
select, +eval 7 accounts discovery/no-leak). VERIFIED `install-skill --force`
output is byte-identical to skill/ via diff -rq. Design decisions: STRICT
no-fallback (acct N only reads *_N); numeric convention but any non-empty token
= one code path; default account = bare vars = zero breakage. TENSION w/ rev-2
"dropped --account/profiles" resolved: this env-only suffix form stays inside
env-only+stateless, so it supersedes that line for THIS form only (user
confirmed).

## Semantic (facts learned)
- Rust email crate landscape (mid-2026):
- `imap` (jonhoo) = sync, simple, but stuck on 3.0.0-alpha, seeking maintainers.
Expand Down
4 changes: 4 additions & 0 deletions plans/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Plans are local working docs — ignore everything here except this
# .gitignore, so the directory stays in the repo but its contents don't.
*
!.gitignore
38 changes: 36 additions & 2 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,37 @@ given command needs are required; a missing one fails loud, naming the variable.
| `MAILCLI_PASSWORD` | yes | — | Password / app-password / (with `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_ACCOUNT` | no | — | Default account selector when `--account` is omitted |

Switch accounts by switching environment (shell export, `direnv`, a wrapper
script). There are no profiles. **Never persist or log the password.**
script), **or** configure several at once with a numeric suffix and pick one per
run — see [Multiple accounts](#multiple-accounts). **Never persist or log the
password.**

For provider-specific setup (Gmail app passwords, Outlook, XOAUTH2 tokens),
read `references/reference.md`.

## Multiple accounts

Several accounts can be configured at once by giving each a **suffix** on its
`MAILCLI_*` vars (`_1`, `_2`, … — numeric by convention, any non-empty token
works), then selecting one per invocation with the global `--account` flag:

```sh
mail-cli list # default account = the bare MAILCLI_* vars
mail-cli --account 1 list # reads MAILCLI_*_1
mail-cli --account 1 send --to a@b.com --subject Hi --body yo
```

Rules an agent must respect:
- **No `--account` → the default account** = the bare, un-suffixed vars.
- Precedence: `--account <TOKEN>` **>** `MAILCLI_ACCOUNT` env **>** none.
- **Strict, no fallback:** account N is read *only* from `*_N` names. A missing
value fails naming the exact var (`… MAILCLI_PASSWORD_2 is not set`) — set that
suffixed var; do not fall back to the bare one.
- Run `mail-cli accounts` to see which accounts are configured (it never prints
passwords). Use it to pick the right `--account` before acting.

## `--json`: a global flag — canonical placement is before the subcommand

```sh
Expand All @@ -54,12 +78,22 @@ mail-cli list --limit 5 --json # also works (it's a global flag)
`--json` is declared global, so clap accepts it on either side of the
subcommand. Prefer putting it **before** the subcommand — that's the form shown
throughout the docs and it reads unambiguously. It applies to the read commands
(`folders`, `list`, `search`, `read`, `attachments`) and prints
(`accounts`, `folders`, `list`, `search`, `read`, `attachments`) and prints
machine-readable output. Always use it when you're going to parse the result
(e.g. piping to `jq`) rather than scraping the human table.

## Commands

### accounts — list configured accounts
```sh
mail-cli accounts # human table: ACCOUNT STATUS EMAIL IMAP/SMTP host
mail-cli --json accounts # {name, email, imap_host, smtp_host, has_password}
```
Discovers accounts from the environment (`default` = bare vars, then suffixed
sets). Reports `ok`/`incomplete` and **never prints passwords** — only
`has_password`. Reads env but opens no connection. Use it to decide which
`--account` to pass.

### folders — list mailboxes
```sh
mail-cli folders
Expand Down
12 changes: 12 additions & 0 deletions skill/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@
"prompt": "Write me a little shell script that prints how many unread emails I currently have in my inbox using mail-cli.",
"expected_output": "Produces `mail-cli --json search --unseen | jq 'length'` (or list-based equivalent). --json before subcommand; parses JSON rather than scraping the human table.",
"files": []
},
{
"id": 6,
"prompt": "I have two mailboxes set up in mail-cli: my work account under the plain MAILCLI_* vars, and my personal Gmail under MAILCLI_*_1. List the newest 10 messages from my personal Gmail, not my work one.",
"expected_output": "Runs `mail-cli --account 1 list --limit 10` (the --account global flag selects the *_1 suffixed vars). Understands that omitting --account would hit the default/work account, and that account 1 is read strictly from MAILCLI_*_1. Does not try to swap env vars or invent a config file/profile.",
"files": []
},
{
"id": 7,
"prompt": "How do I see which email accounts I've configured for mail-cli, and check that account 2 is fully set up? I don't want to accidentally print my passwords.",
"expected_output": "Runs `mail-cli accounts` (optionally `--json`) to list configured accounts and their ok/incomplete status. Notes it never prints passwords (only has_password / a presence flag), reports whether account 2 shows as complete, and opens no connection. Does not cat env or echo MAILCLI_PASSWORD*.",
"files": []
}
]
}
55 changes: 51 additions & 4 deletions skill/references/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ lives in `SKILL.md`.

## Table of contents
- [JSON output schemas](#json-output-schemas)
- [Accounts (multi-account)](#accounts-multi-account)
- [Selectors: `N` vs `uid:NNN`](#selectors-n-vs-uidnnn)
- [Authentication](#authentication)
- [TLS modes](#tls-modes)
Expand Down Expand Up @@ -68,6 +69,49 @@ writes untouched RFC822 **bytes to stdout** and ignores `--json`.
- `index` is the 1-based `K` used by `download --attachment K`.
- Raw bytes are never included in JSON (only written to disk by `download`).

### `accounts` — array of AccountInfo
```json
[
{
"name": "default",
"email": "me@work.com",
"imap_host": "imap.work.com",
"smtp_host": "smtp.work.com",
"has_password": true
}
]
```
- `name` is `"default"` for the bare vars, else the suffix (`"1"`, `"work"`).
- `email`/`imap_host`/`smtp_host` are `null` when that var is unset.
- `has_password` is a **boolean presence flag** — the password value is never
emitted. Accounts sort `default` first, then numeric ascending, then lexical.

## Accounts (multi-account)

Configure several accounts by suffixing their `MAILCLI_*` vars (`_1`, `_2`, …)
and select one per run with the global `--account` flag.

- **Selector precedence:** `--account <TOKEN>` > `MAILCLI_ACCOUNT` env > none.
- **No selector → the `default` account** = the bare, un-suffixed vars. This is
exactly the pre-multi-account behavior; existing setups are unaffected.
- The token is **numeric by convention** but any non-empty string works
(`--account work` → `MAILCLI_*_work`). An empty/whitespace token is rejected:
`account selector is empty; …`.
- **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 resolved var,
e.g. `required environment variable MAILCLI_PASSWORD_2 is not set`. Because the
optional vars still 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`).
- `mail-cli accounts` lists what's configured without opening a connection and
without printing any password. Use it to choose the right `--account`.

```sh
# discover, then act on a specific account
mail-cli --json accounts | jq -r '.[] | select(.name=="1") | .email'
mail-cli --account 1 list
```

## Selectors: `N` vs `uid:NNN`

Both name a message in a folder, without any cache:
Expand Down Expand Up @@ -144,8 +188,10 @@ export MAILCLI_SMTP_PORT=587
The binary fails loud and exits non-zero, printing `error: …`:

- **Missing env var** → names the exact variable, e.g.
`required environment variable MAILCLI_PASSWORD is not set`. Fix the env; do
not look for a config file.
`required environment variable MAILCLI_PASSWORD is not set` (with `--account 2`
it names `MAILCLI_PASSWORD_2`). Fix the env; do not look for a config file.
- **Empty `--account` / `MAILCLI_ACCOUNT`** → `account selector is empty; …`
(drop it for the default account, or pass a non-empty token).
- **Bad `MAILCLI_TLS` / `MAILCLI_AUTH`** → states the allowed values.
- **Empty `search` criteria** → refuses rather than matching everything.
- **`download` without `--attachment`/`--all`** → `specify --attachment K or --all`.
Expand Down Expand Up @@ -177,8 +223,9 @@ mail-cli --json read INBOX "uid:$uid" | jq -r '.body' \

These are deliberate; work with them rather than around them:

- **Stateless.** No cache, no last-list memory, no `--account`/profiles. Account
switching = environment switching.
- **Stateless.** No cache, no last-list memory, no config file or on-disk
profiles. Multiple accounts are just env vars with a numeric suffix, chosen at
runtime via `--account`; there is still nothing stored between runs.
- **Env is the only config boundary.** If something feels like it should be a
flag but isn't, check whether it's a `MAILCLI_*` var before assuming it's
missing.
Expand Down
Loading
Loading