diff --git a/AGENTS.md b/AGENTS.md index d630c5a..28e8811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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 ` + (global) with `MAILCLI_ACCOUNT` as the default. No `--account` → the bare + `MAILCLI_*` vars (the `default` account). An account is read strictly from its + own `*_` 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. diff --git a/README.md b/README.md index 12bd54d..94ba2e0 100644 --- a/README.md +++ b/README.md @@ -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 ` **>** `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] +mail-cli [--json] [--account N] +``` + +### 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 @@ -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' ``` diff --git a/memory.md b/memory.md index be16f5e..539475c 100644 --- a/memory.md +++ b/memory.md @@ -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 ` 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}` 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, lookup: + fn(&str)->Option}`; var(base) appends `_`; 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` 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. diff --git a/plans/.gitignore b/plans/.gitignore new file mode 100644 index 0000000..9cc5a16 --- /dev/null +++ b/plans/.gitignore @@ -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 diff --git a/skill/SKILL.md b/skill/SKILL.md index 6424d98..cf2f253 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -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 ` **>** `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 @@ -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 diff --git a/skill/evals/evals.json b/skill/evals/evals.json index 2890007..bd3a63e 100644 --- a/skill/evals/evals.json +++ b/skill/evals/evals.json @@ -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": [] } ] } diff --git a/skill/references/reference.md b/skill/references/reference.md index ce8e359..9815898 100644 --- a/skill/references/reference.md +++ b/skill/references/reference.md @@ -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) @@ -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 ` > `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: @@ -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`. @@ -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. diff --git a/src/config.rs b/src/config.rs index 2eb6f28..0957463 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,64 +48,125 @@ pub struct SmtpConfig { pub auth: AuthMethod, } -/// Read an optional env var, treating empty/whitespace as absent. -fn opt(name: &str) -> Option { - match env::var(name) { - Ok(v) if !v.trim().is_empty() => Some(v), - _ => None, - } +/// The active account selector: an optional env-var suffix. +/// +/// `None` reads the bare `MAILCLI_*` names (the default account, exactly as the +/// tool worked before multi-account existed). `Some("1")` reads `MAILCLI_*_1`, +/// and so on. The suffix is numeric by convention but may be any non-empty +/// token, so `MAILCLI_*_work` works too. Reading is strict: an account is read +/// only from its own suffixed names, with no fallback to the bare vars. +/// +/// This is the ONLY place that touches the process environment, so all +/// account/name resolution stays contained at the boundary. +#[derive(Debug, Clone)] +pub struct Env { + /// `None` => bare names; `Some(s)` => `_`. + suffix: Option, + /// Injectable lookup, so `from_env` is testable without touching the + /// process-global environment. Defaults to `std::env::var`. + lookup: fn(&str) -> Option, } -/// Read a required env var, failing loud with the variable name. -fn required(name: &str) -> Result { - opt(name).with_context(|| format!("required environment variable {name} is not set")) +fn env_lookup(name: &str) -> Option { + env::var(name).ok() } -/// Parse a port, defaulting when unset, failing loud when malformed. -fn port(name: &str, default: u16) -> Result { - match opt(name) { - None => Ok(default), - Some(v) => v - .parse::() - .with_context(|| format!("environment variable {name} is not a valid port: {v:?}")), +impl Env { + /// Resolve the active account from the selector (already reduced from + /// `--account` > `MAILCLI_ACCOUNT` > none by the caller). An empty or + /// whitespace-only token is rejected loud rather than silently collapsing + /// to the default account. + pub fn new(account: Option) -> Result { + let suffix = match account { + None => None, + Some(tok) if tok.trim().is_empty() => { + bail!( + "account selector is empty; drop --account/MAILCLI_ACCOUNT for the default account, or give a non-empty token like 1" + ) + } + Some(tok) => Some(tok), + }; + Ok(Self { + suffix, + lookup: env_lookup, + }) } -} -fn tls_mode() -> Result { - match opt("MAILCLI_TLS").as_deref() { - None | Some("implicit") => Ok(TlsMode::Implicit), - Some("starttls") => Ok(TlsMode::StartTls), - Some(other) => bail!("MAILCLI_TLS must be 'implicit' or 'starttls', got {other:?}"), + /// The concrete env-var name for a base: `MAILCLI_IMAP_HOST` for the default + /// account, `MAILCLI_IMAP_HOST_1` when the suffix is `1`. + fn var(&self, base: &str) -> String { + match &self.suffix { + Some(s) => format!("{base}_{s}"), + None => base.to_string(), + } } -} -fn auth_method() -> Result { - match opt("MAILCLI_AUTH").as_deref() { - None | Some("login") => Ok(AuthMethod::Login), - Some("xoauth2") => Ok(AuthMethod::Xoauth2), - Some(other) => bail!("MAILCLI_AUTH must be 'login' or 'xoauth2', got {other:?}"), + /// Read an optional var, treating empty/whitespace as absent. + fn opt(&self, base: &str) -> Option { + match (self.lookup)(&self.var(base)) { + Some(v) if !v.trim().is_empty() => Some(v), + _ => None, + } + } + + /// Read a required var, failing loud with the *resolved* variable name. + fn required(&self, base: &str) -> Result { + let name = self.var(base); + self.opt(base) + .with_context(|| format!("required environment variable {name} is not set")) + } + + /// Parse a port, defaulting when unset, failing loud when malformed. + fn port(&self, base: &str, default: u16) -> Result { + match self.opt(base) { + None => Ok(default), + Some(v) => { + let name = self.var(base); + v.parse::().with_context(|| { + format!("environment variable {name} is not a valid port: {v:?}") + }) + } + } + } + + fn tls_mode(&self) -> Result { + let name = self.var("MAILCLI_TLS"); + match self.opt("MAILCLI_TLS").as_deref() { + None | Some("implicit") => Ok(TlsMode::Implicit), + Some("starttls") => Ok(TlsMode::StartTls), + Some(other) => bail!("{name} must be 'implicit' or 'starttls', got {other:?}"), + } + } + + fn auth_method(&self) -> Result { + let name = self.var("MAILCLI_AUTH"); + match self.opt("MAILCLI_AUTH").as_deref() { + None | Some("login") => Ok(AuthMethod::Login), + Some("xoauth2") => Ok(AuthMethod::Xoauth2), + Some(other) => bail!("{name} must be 'login' or 'xoauth2', got {other:?}"), + } } -} -/// Login username: `MAILCLI_USER` if set, else the account address. -fn login_user() -> Result { - match opt("MAILCLI_USER") { - Some(u) => Ok(u), - None => required("MAILCLI_EMAIL"), + /// Login username: `MAILCLI_USER[_N]` if set, else the account address. + fn login_user(&self) -> Result { + match self.opt("MAILCLI_USER") { + Some(u) => Ok(u), + None => self.required("MAILCLI_EMAIL"), + } } } impl ImapConfig { /// Build IMAP settings from the environment, validating only what read /// commands require. - pub fn from_env() -> Result { + pub fn from_env(env: &Env) -> Result { Ok(Self { - host: required("MAILCLI_IMAP_HOST")?, - port: port("MAILCLI_IMAP_PORT", 993)?, - user: login_user()?, - password: required("MAILCLI_PASSWORD")?, - tls: tls_mode()?, - auth: auth_method()?, + host: env.required("MAILCLI_IMAP_HOST")?, + port: env.port("MAILCLI_IMAP_PORT", 993)?, + user: env.login_user()?, + password: env.required("MAILCLI_PASSWORD")?, + tls: env.tls_mode()?, + auth: env.auth_method()?, }) } } @@ -113,15 +174,234 @@ impl ImapConfig { impl SmtpConfig { /// Build SMTP settings from the environment, validating only what `send` /// requires. - pub fn from_env() -> Result { + pub fn from_env(env: &Env) -> Result { Ok(Self { - host: required("MAILCLI_SMTP_HOST")?, - port: port("MAILCLI_SMTP_PORT", 465)?, - user: login_user()?, - password: required("MAILCLI_PASSWORD")?, - email: required("MAILCLI_EMAIL")?, - tls: tls_mode()?, - auth: auth_method()?, + host: env.required("MAILCLI_SMTP_HOST")?, + port: env.port("MAILCLI_SMTP_PORT", 465)?, + user: env.login_user()?, + password: env.required("MAILCLI_PASSWORD")?, + email: env.required("MAILCLI_EMAIL")?, + tls: env.tls_mode()?, + auth: env.auth_method()?, }) } } + +/// A configured account discovered by scanning the environment. Secret-free: +/// carries only enough to tell the human which accounts exist and whether they +/// look usable. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct AccountInfo { + /// The selector: `"default"` for the bare vars, else the suffix (`"1"`). + pub name: String, + /// The account address, if `MAILCLI_EMAIL[_N]` is set. + pub email: Option, + /// The IMAP host, if `MAILCLI_IMAP_HOST[_N]` is set. + pub imap_host: Option, + /// The SMTP host, if `MAILCLI_SMTP_HOST[_N]` is set. + pub smtp_host: Option, + /// Whether `MAILCLI_PASSWORD[_N]` is present (never the value itself). + pub has_password: bool, +} + +/// Bases that define an account. A key belongs to an account iff it is one of +/// these, optionally followed by `_`. +const ACCOUNT_BASES: &[&str] = &[ + "MAILCLI_IMAP_HOST", + "MAILCLI_IMAP_PORT", + "MAILCLI_SMTP_HOST", + "MAILCLI_SMTP_PORT", + "MAILCLI_EMAIL", + "MAILCLI_USER", + "MAILCLI_PASSWORD", + "MAILCLI_TLS", + "MAILCLI_AUTH", +]; + +/// Discover configured accounts from the process environment. Never reads or +/// returns secret values — only presence. `default` (bare vars) is included iff +/// any bare account var is set. Suffixes sort numerically-then-lexically. +pub fn scan_accounts() -> Vec { + scan_accounts_from(env::vars().collect::>()) +} + +/// Pure core of [`scan_accounts`], testable with an injected var list. +fn scan_accounts_from(vars: Vec<(String, String)>) -> Vec { + use std::collections::BTreeSet; + + let nonempty = |v: &str| !v.trim().is_empty(); + + // Collect the set of suffixes in use (None = default/bare). + let mut suffixes: BTreeSet> = BTreeSet::new(); + for (k, v) in &vars { + if !nonempty(v) { + continue; + } + for base in ACCOUNT_BASES { + if k == base { + suffixes.insert(None); + } else if let Some(sfx) = k.strip_prefix(&format!("{base}_")) + && !sfx.is_empty() + { + suffixes.insert(Some(sfx.to_string())); + } + } + } + + let get = |suffix: &Option, base: &str| -> Option { + let name = match suffix { + Some(s) => format!("{base}_{s}"), + None => base.to_string(), + }; + vars.iter() + .find(|(k, v)| k == &name && nonempty(v)) + .map(|(_, v)| v.clone()) + }; + + let mut infos: Vec = suffixes + .iter() + .map(|suffix| AccountInfo { + name: suffix.clone().unwrap_or_else(|| "default".to_string()), + email: get(suffix, "MAILCLI_EMAIL"), + imap_host: get(suffix, "MAILCLI_IMAP_HOST"), + smtp_host: get(suffix, "MAILCLI_SMTP_HOST"), + has_password: get(suffix, "MAILCLI_PASSWORD").is_some(), + }) + .collect(); + + // "default" first, then numeric suffixes ascending, then the rest lexically. + infos.sort_by_key(|a| account_sort_key(&a.name)); + infos +} + +/// Sort key: default first, then numeric ascending, then lexical. +fn account_sort_key(name: &str) -> (u8, u64, String) { + if name == "default" { + (0, 0, String::new()) + } else if let Ok(n) = name.parse::() { + (1, n, String::new()) + } else { + (2, 0, name.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build an Env with a given suffix over the real environment. Tests here + /// only exercise name resolution (`var`) and construction (`new`), which do + /// not read env, so the default lookup is fine. Reader tests inject an + /// explicit `fn` lookup (e.g. `|_| None`); captured-data cases go through the + /// pure `scan_accounts_from`. + fn env_with(suffix: Option<&str>) -> Env { + Env { + suffix: suffix.map(str::to_string), + lookup: env_lookup, + } + } + + #[test] + fn var_default_account_is_bare() { + let env = env_with(None); + assert_eq!(env.var("MAILCLI_IMAP_HOST"), "MAILCLI_IMAP_HOST"); + } + + #[test] + fn var_numeric_suffix_appended() { + let env = env_with(Some("1")); + assert_eq!(env.var("MAILCLI_IMAP_HOST"), "MAILCLI_IMAP_HOST_1"); + } + + #[test] + fn var_named_suffix_appended() { + let env = env_with(Some("work")); + assert_eq!(env.var("MAILCLI_PASSWORD"), "MAILCLI_PASSWORD_work"); + } + + #[test] + fn new_none_is_default_account() { + let env = Env::new(None).unwrap(); + assert_eq!(env.var("MAILCLI_EMAIL"), "MAILCLI_EMAIL"); + } + + #[test] + fn new_rejects_empty_and_whitespace() { + assert!(Env::new(Some(String::new())).is_err()); + assert!(Env::new(Some(" ".to_string())).is_err()); + } + + #[test] + fn new_accepts_token() { + assert_eq!(Env::new(Some("2".to_string())).unwrap().var("X"), "X_2"); + } + + /// A required var missing from account 2 names the *resolved* var. + #[test] + fn required_error_names_suffixed_var() { + let env = Env { + suffix: Some("2".to_string()), + lookup: |_| None, + }; + let err = env.required("MAILCLI_PASSWORD").unwrap_err(); + assert!( + err.to_string().contains("MAILCLI_PASSWORD_2"), + "error should name the suffixed var, got: {err}" + ); + } + + fn owned(vars: &[(&str, &str)]) -> Vec<(String, String)> { + vars.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn scan_finds_default_and_numeric_accounts() { + let infos = scan_accounts_from(owned(&[ + ("MAILCLI_IMAP_HOST", "imap.work.com"), + ("MAILCLI_EMAIL", "me@work.com"), + ("MAILCLI_PASSWORD", "secret"), + ("MAILCLI_IMAP_HOST_1", "imap.gmail.com"), + ("MAILCLI_EMAIL_1", "me@gmail.com"), + ("MAILCLI_PASSWORD_1", "hunter2"), + ("UNRELATED", "ignore me"), + ])); + assert_eq!(infos.len(), 2); + assert_eq!(infos[0].name, "default"); + assert_eq!(infos[0].email.as_deref(), Some("me@work.com")); + assert!(infos[0].has_password); + assert_eq!(infos[1].name, "1"); + assert_eq!(infos[1].imap_host.as_deref(), Some("imap.gmail.com")); + } + + #[test] + fn scan_reports_incomplete_without_leaking_password_value() { + let infos = scan_accounts_from(owned(&[ + ("MAILCLI_IMAP_HOST_2", "imap.example.com"), + ("MAILCLI_EMAIL_2", "me@example.com"), + // no password for account 2 + ])); + assert_eq!(infos.len(), 1); + assert_eq!(infos[0].name, "2"); + assert!(!infos[0].has_password); + } + + #[test] + fn scan_sorts_default_then_numeric_then_named() { + let infos = scan_accounts_from(owned(&[ + ("MAILCLI_EMAIL_10", "ten@x.com"), + ("MAILCLI_EMAIL_2", "two@x.com"), + ("MAILCLI_EMAIL", "def@x.com"), + ("MAILCLI_EMAIL_work", "work@x.com"), + ])); + let names: Vec<&str> = infos.iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, vec!["default", "2", "10", "work"]); + } + + #[test] + fn scan_ignores_empty_values() { + let infos = scan_accounts_from(owned(&[("MAILCLI_EMAIL_3", " ")])); + assert!(infos.is_empty()); + } +} diff --git a/src/main.rs b/src/main.rs index 3de46a5..c4951fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,12 +31,26 @@ struct Cli { #[arg(long, global = true)] json: bool, + /// Select a configured account: read `MAILCLI_*_` instead of the + /// bare `MAILCLI_*` names. Numeric by convention (1, 2, …), but any + /// non-empty token works. Overrides `MAILCLI_ACCOUNT`. Omit for the default + /// (bare-var) account. + #[arg(long, global = true)] + account: Option, + #[command(subcommand)] command: Command, } #[derive(Subcommand)] enum Command { + /// List configured accounts discovered from the environment. + /// + /// Scans `MAILCLI_*` (bare = the `default` account) and `MAILCLI_*_` + /// suffixed sets, reporting which look complete. Never prints passwords — + /// only whether one is set. Reads env but opens no connection. + Accounts, + /// List mailboxes (folders) for the account. Folders, @@ -231,13 +245,23 @@ fn main() { fn run() -> Result<()> { let cli = Cli::parse(); + + // Resolve the active account once: --account flag > MAILCLI_ACCOUNT env > + // none (the default, bare-var account). Build the env boundary from it. + let account = cli + .account + .clone() + .or_else(|| std::env::var("MAILCLI_ACCOUNT").ok()); + let env = config::Env::new(account)?; + match cli.command { - Command::Folders => cmd_folders(cli.json), + Command::Accounts => cmd_accounts(cli.json), + Command::Folders => cmd_folders(&env, cli.json), Command::List { folder, limit, search, - } => cmd_list(cli.json, &folder, limit, search.as_deref()), + } => cmd_list(&env, cli.json, &folder, limit, search.as_deref()), Command::Search { folder, limit, @@ -282,22 +306,24 @@ fn run() -> Result<()> { }; criteria.to_imap_query()? }; - cmd_list(cli.json, &folder, limit, Some(&query)) + cmd_list(&env, cli.json, &folder, limit, Some(&query)) } Command::Read { folder, selector, html, raw, - } => cmd_read(cli.json, &folder, selector, html, raw), - Command::Attachments { folder, selector } => cmd_attachments(cli.json, &folder, selector), + } => cmd_read(&env, cli.json, &folder, selector, html, raw), + Command::Attachments { folder, selector } => { + cmd_attachments(&env, cli.json, &folder, selector) + } Command::Download { folder, selector, attachment, all, out, - } => cmd_download(&folder, selector, attachment, all, &out), + } => cmd_download(&env, &folder, selector, attachment, all, &out), Command::Send { to, cc, @@ -307,7 +333,7 @@ fn run() -> Result<()> { body_file, html, attach, - } => cmd_send(to, cc, bcc, subject, body, body_file, html, attach), + } => cmd_send(&env, to, cc, bcc, subject, body, body_file, html, attach), Command::InstallSkill { dir, force } => cmd_install_skill(dir, force), } } @@ -339,8 +365,20 @@ fn resolve( // ---------- commands ---------- -fn cmd_folders(json: bool) -> Result<()> { - let cfg = ImapConfig::from_env()?; +/// List configured accounts discovered from the environment. Reads env but +/// opens no connection, and never prints passwords. +fn cmd_accounts(json: bool) -> Result<()> { + let accounts = config::scan_accounts(); + if json { + output::json(&accounts) + } else { + output::accounts_human(&accounts); + Ok(()) + } +} + +fn cmd_folders(env: &config::Env, json: bool) -> Result<()> { + let cfg = ImapConfig::from_env(env)?; let mut session = imap::connect(&cfg)?; let folders = imap::list_folders(&mut session)?; let _ = session.logout(); @@ -352,8 +390,14 @@ fn cmd_folders(json: bool) -> Result<()> { } } -fn cmd_list(json: bool, folder: &str, limit: usize, search: Option<&str>) -> Result<()> { - let cfg = ImapConfig::from_env()?; +fn cmd_list( + env: &config::Env, + json: bool, + folder: &str, + limit: usize, + search: Option<&str>, +) -> Result<()> { + let cfg = ImapConfig::from_env(env)?; let mut session = imap::connect(&cfg)?; let uids = imap::newest_uids(&mut session, folder, limit, search)?; let summaries = imap::fetch_summaries(&mut session, folder, &uids)?; @@ -366,8 +410,15 @@ fn cmd_list(json: bool, folder: &str, limit: usize, search: Option<&str>) -> Res } } -fn cmd_read(json: bool, folder: &str, selector: Selector, html: bool, raw: bool) -> Result<()> { - let cfg = ImapConfig::from_env()?; +fn cmd_read( + env: &config::Env, + json: bool, + folder: &str, + selector: Selector, + html: bool, + raw: bool, +) -> Result<()> { + let cfg = ImapConfig::from_env(env)?; let mut session = imap::connect(&cfg)?; let (uid, index) = resolve(&mut session, folder, selector)?; let bytes = imap::fetch_raw(&mut session, folder, uid)?; @@ -390,8 +441,8 @@ fn cmd_read(json: bool, folder: &str, selector: Selector, html: bool, raw: bool) } } -fn cmd_attachments(json: bool, folder: &str, selector: Selector) -> Result<()> { - let cfg = ImapConfig::from_env()?; +fn cmd_attachments(env: &config::Env, json: bool, folder: &str, selector: Selector) -> Result<()> { + let cfg = ImapConfig::from_env(env)?; let mut session = imap::connect(&cfg)?; let (uid, index) = resolve(&mut session, folder, selector)?; let bytes = imap::fetch_raw(&mut session, folder, uid)?; @@ -407,6 +458,7 @@ fn cmd_attachments(json: bool, folder: &str, selector: Selector) -> Result<()> { } fn cmd_download( + env: &config::Env, folder: &str, selector: Selector, attachment: Option, @@ -416,7 +468,7 @@ fn cmd_download( if attachment.is_none() && !all { bail!("specify --attachment K or --all"); } - let cfg = ImapConfig::from_env()?; + let cfg = ImapConfig::from_env(env)?; let mut session = imap::connect(&cfg)?; let (uid, index) = resolve(&mut session, folder, selector)?; let bytes = imap::fetch_raw(&mut session, folder, uid)?; @@ -458,6 +510,7 @@ fn cmd_download( #[allow(clippy::too_many_arguments)] fn cmd_send( + env: &config::Env, to: Vec, cc: Vec, bcc: Vec, @@ -471,7 +524,7 @@ fn cmd_send( bail!("at least one --to recipient is required"); } let body = resolve_body(body, body_file)?; - let cfg = SmtpConfig::from_env()?; + let cfg = SmtpConfig::from_env(env)?; let outgoing = smtp::Outgoing { to, cc, diff --git a/src/output.rs b/src/output.rs index 68d6d48..700fa70 100644 --- a/src/output.rs +++ b/src/output.rs @@ -3,6 +3,7 @@ use anyhow::Result; use serde::Serialize; +use crate::config::AccountInfo; use crate::imap::Folder; use crate::parse::{Attachment, Rendered, Summary}; @@ -104,6 +105,34 @@ pub fn attachments_human(uid: u32, index: Option, atts: &[Attachment]) { } } +/// Render the configured-accounts listing (`accounts`). Never prints secrets. +pub fn accounts_human(accounts: &[AccountInfo]) { + if accounts.is_empty() { + println!("(no accounts configured — set MAILCLI_* env vars)"); + return; + } + println!( + "{:<10} {:<12} {:<28} {:<28} SMTP HOST", + "ACCOUNT", "STATUS", "EMAIL", "IMAP HOST" + ); + for a in accounts { + // "complete" = enough to read (imap host + email + password present). + let status = if a.imap_host.is_some() && a.email.is_some() && a.has_password { + "ok" + } else { + "incomplete" + }; + println!( + "{:<10} {:<12} {:<28} {:<28} {}", + clip(&a.name, 10), + status, + clip(a.email.as_deref().unwrap_or("-"), 28), + clip(a.imap_host.as_deref().unwrap_or("-"), 28), + clip(a.smtp_host.as_deref().unwrap_or("-"), 28), + ); + } +} + /// Human-readable byte size. fn human_size(bytes: usize) -> String { const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];