45 Commits

Author SHA1 Message Date
steve 03bcdf6fc0 chore(release): default installer to v0.6.0
release / release (push) Successful in 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:16:41 +01:00
steve 87555fdc4d style(cli): align account show columns with consistent label width
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:43:14 +01:00
steve 5476c04443 fix(cli): recognize account ls alias for agent role; align account show output; document edit password invariant
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:42:19 +01:00
steve dbefb68611 feat(cli): positional audit grammar (account positional, ls alias) 2026-06-27 12:33:11 +01:00
steve 1e00f68a3d docs: update admin command reference to positional grammar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:28:23 +01:00
steve 1a03ce1c69 docs(cli): help reflects positional admin grammar + aliases
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:19:42 +01:00
steve ca49a42d40 feat(tui): drop whitelist toggles from account form (managed via whitelist group)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:16:20 +01:00
steve c826042625 feat(cli): top-level ls alias for list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:12:03 +01:00
steve 44a9211a6f feat(cli): doctor accepts positional account alongside --account 2026-06-27 12:08:21 +01:00
steve 9a8765d4e4 feat(cli): positional account grammar, account show, TTY remove confirm; drop whitelist flags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 12:04:04 +01:00
steve 1bf5bf3c47 feat(cli): positional whitelist grammar with required direction, enable/disable, validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 11:56:54 +01:00
steve f407fc126d feat(cli): positional config grammar with registry + config list 2026-06-27 11:49:56 +01:00
steve 56ecdf246c feat(store): add SetWhitelistEnabled 2026-06-27 11:47:01 +01:00
steve a5bfaa4fe3 feat(cli): add config settings registry 2026-06-27 11:45:10 +01:00
steve 85642d5b12 feat(policy): add ValidWhitelistAddress
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:42:44 +01:00
steve 68c926f83b feat(cli): add normalizeVerb alias helper 2026-06-27 11:40:42 +01:00
steve 2c7b8d3610 docs: implementation plan for human CLI grammar redesign
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:39:03 +01:00
steve 7a4d2881ba docs: spec for human-facing CLI grammar redesign
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:21:23 +01:00
steve 3c5e0a26f3 chore(release): default installer to v0.5.2
release / release (push) Successful in 43s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:05:31 +01:00
steve 456d25d4f3 fix(cli): clearer whitelist usage errors
`whitelist <in|out> <add|remove|list>` has two positional slots; omitting
either let a --flag slide into the slot and produced a misleading
"--account is required". Validate the direction and the subcommand up
front, before flag parsing, so the real mistake is reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:04:40 +01:00
steve 3bea73f857 fix(store): expand a leading ~ in EMCLI_DB
A literal "~/..." in EMCLI_DB has no shell to expand it, so SQLite opened
it relative to the cwd and silently created a stray "~" directory tree.
Expand a leading "~" or "~/" to the user's home dir; "~user", mid-path
tildes, and absolute/relative paths are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:04:40 +01:00
steve c651b00d08 chore(release): default installer to v0.5.1
release / release (push) Failing after 3m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 22:11:48 +01:00
steve 8ed10dd503 docs: agent can discover accounts via account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:39:12 +01:00
steve 2140d9e173 feat(cli): agent-readable account list (reduced JSON view)
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a
JSON envelope of name/from/can_send, while the admin keeps the full text
table. account add/edit/remove stay admin-only.

Also emit the agent path's missing-key/open failure as a JSON Failure
envelope (per spec), and update the stale run_test case that asserted the
old admin-only behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:37:37 +01:00
steve 64ff32ab29 docs(plan): agent-readable account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:34:33 +01:00
steve 7039371f70 docs(spec): agent-readable account list (reduced JSON view)
Let an agent holding only EMCLI_KEY discover accounts via `account list`,
exposing name/from/can_send (not host/username); admin keeps the full
text table. account add/edit/remove stay admin-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:29:07 +01:00
steve e1b4ec38e5 docs(manual): document --from on account edit and the send-as address
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:49:46 +01:00
steve bd06b4b900 chore(release): default installer to v0.5.0
release / release (push) Successful in 40s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:38:01 +01:00
steve 8e5c06a4cb style: fix test name typo, table-test reporting, validator wording
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:32:33 +01:00
steve 32f5a8d933 fix(cli): clarify edit --from help; test edit --from validation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:29:37 +01:00
steve b6e68ddeae feat(cli): configurable send-as From address (flags, TUI, validation)
- tui.ValidFromAddress: exported validator; blank passes, malformed rejects
- Fields.FromAddress: new field, round-trips through ToAccount/FieldsFromAccount
- Fields.Validate: calls ValidFromAddress before returning nil
- TUI form: from_address fieldDef between username and password
- send.go: From set via acc.SendFrom() instead of acc.Username
- admin.go account add: --from flag with pre-parse validation
- admin.go account edit: --from flag; validate before Visit, apply in Visit
- USER-MANUAL.md: --from flag added to account add flags table

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:25:14 +01:00
steve 6a99e5bb6e feat(mail): derive bare envelope sender from display-name From
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:20:54 +01:00
steve c5e42ffbae fix(store): surface invalid schema_version; split migration test assertion
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:19:35 +01:00
steve cdffb15004 feat(store): add account from_address field + v2 migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:16:15 +01:00
steve a4c49d4aca docs: implementation plan for send-as From address
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:12:28 +01:00
steve 852bb1dc5b docs: design for send-as From address field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:06:38 +01:00
steve 76ada04442 refactor(cli): wire commandRole into dispatch; doc + comment cleanup
Resolve final-review findings: commandRole is now the single source of
truth (Run resolves role once and threads it to handlers, replacing
hardcoded openStore roles). Tighten crypto/SKILL/SPEC/USER-MANUAL wording
and document init's agent-key-on-first-init-only semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 07:18:27 +01:00
steve add9515b5c docs: document two-key privilege model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:11:18 +01:00
steve 456e15a2f8 test(cli): check setup errors + report all admin refusals
Address review: fail fast on store.Open/key-loader errors in test setup;
use t.Errorf+continue so every admin command is checked, not just the first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:06:47 +01:00
steve 5c7dd252db test(cli): prove agent key cannot run admin commands
Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with
only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:03:17 +01:00
steve 9d946b1b03 feat(cli): two-key role routing + init bootstrap
openStore(role) selects the DEK wrap slot; admin commands require
EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both
slots from both keys. Test helpers seed the wrap slots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:59:16 +01:00
steve cb0425f18d feat(store): envelope DEK with admin/agent wrap slots
Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs;
Unlock loads it from the role's slot (admin slot has no agent fallback).
s.key becomes the DEK, so account/mail crypto is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:52:21 +01:00
steve c52f30898b feat(crypto): named-var key loaders (admin/agent) + NewDEK
Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY
and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double
as DEK wrap/unwrap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:47:05 +01:00
steve 77ba5a146f docs(plan): two-key privilege separation implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:43:16 +01:00
steve 2bc2c1b50e docs(spec): two-key privilege separation design
Enforce the agent/admin trust boundary with two env keys (EMCLI_ADMIN_KEY,
EMCLI_KEY) via envelope encryption: one DEK wrapped per role. Admin commands
unwrap the admin slot only (no agent fallback), so a forced agent holding
EMCLI_KEY cannot authorize config changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:34:26 +01:00
52 changed files with 6385 additions and 347 deletions
+6 -3
View File
@@ -9,11 +9,14 @@ it isn't permitted to see or send mail to people it isn't permitted to contact.
## Getting started ## Getting started
```bash ```bash
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # one-time: generate & save a key export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
emcli init # create the DB, add your first account export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
emcli doctor # confirm it connects and authenticates emcli init # writes both wrap slots
emcli doctor # confirm connect/auth (agent key is enough)
``` ```
`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it.
## Documentation ## Documentation
See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail
+155 -65
View File
@@ -13,7 +13,8 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## Contents ## Contents
1. [Key concepts](#1-key-concepts) 1. [Key concepts](#1-key-concepts)
2. [Setup: the encryption key and database](#2-setup-the-encryption-key-and-database) 2. [Setup: encryption keys and database](#2-setup-encryption-keys-and-database)
- [Privilege model](#2a-privilege-model)
3. [Quick start](#3-quick-start) 3. [Quick start](#3-quick-start)
4. [Adding accounts](#4-adding-accounts) 4. [Adding accounts](#4-adding-accounts)
- [Gmail (app password)](#gmail-app-password) - [Gmail (app password)](#gmail-app-password)
@@ -32,10 +33,14 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## 1. Key concepts ## 1. Key concepts
**Two kinds of commands.** **Two kinds of commands.**
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`, `doctor`) are for *you*, - **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
the human. They print human-readable text or open an interactive form. `EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`) are for the *agent*. They print one interactive form. (`account list` is the one exception — it is also an agent command; see below.)
line of JSON and nothing else, so a program can consume them reliably. - **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
as a superset, so either key suffices for agent commands.)
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never **Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
sees its password. sees its password.
@@ -61,50 +66,95 @@ acking is a deliberate, separate step.
--- ---
## 2. Setup: the encryption key and database ## 2. Setup: encryption keys and database
`emcli` reads two environment variables: `emcli` reads three environment variables:
| Variable | Purpose | Default | | Variable | Purpose | Default |
|---|---|---| |---|---|---|
| `EMCLI_KEY` | **Required.** Base64-encoded 32-byte key (AES-256) used to encrypt passwords at rest. | none — commands fail without it | | `EMCLI_ADMIN_KEY` | **Required for admin.** Base64-encoded 32-byte key (AES-256). Authorises ALL commands. | none — admin commands fail without it |
| `EMCLI_KEY` | **Required for agents.** Base64-encoded 32-byte key (AES-256). Authorises agent commands only. | none — agent commands fail without it |
| `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) | | `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) |
**Generate a key once** and keep it safe (store it the way the program/orchestrator that launches **Generate both keys once** and keep them safe:
`emcli` expects — e.g. a secrets manager or your shell profile):
```bash ```bash
head -c 32 /dev/urandom | base64 export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
``` ```
Set it in your environment before running any command: > **Important:** the keys protect your account passwords via envelope encryption (see "Privilege
> model" below). If you lose `EMCLI_ADMIN_KEY`, account secrets can't be decrypted and you'll have
```bash > to re-add accounts. `emcli` never falls back to plaintext — a missing or wrong key makes every
export EMCLI_KEY='paste-the-base64-key-here' > command fail safely.
```
> **Important:** the key encrypts your account passwords. If you lose it, the stored passwords
> can't be decrypted and you'll have to re-add accounts. If you change it, the same applies.
> `emcli` never falls back to plaintext — a missing or wrong key makes every command fail safely.
Account passwords are stored **encrypted**; they never appear in command output, error messages, Account passwords are stored **encrypted**; they never appear in command output, error messages,
or the audit log. or the audit log.
--- ---
## 2a. Privilege model
`emcli` enforces a two-role privilege split so a process holding only the agent key cannot
reconfigure accounts, whitelists, or audit settings.
### The two keys
| Key | Holder | Authorises |
|---|---|---|
| `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) |
| `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `ack`, `send`, `doctor`) |
`EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands
too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY
(admin privilege)` if it attempts an admin command.
### Envelope encryption (DEK)
`emcli init` generates a random data-encryption key (DEK) that seals all account secrets. The DEK
is stored in the `settings` table in two sealed copies:
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY`.
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY`.
The DEK is never written in cleartext. Admin commands unwrap the DEK from the admin slot only; they
have no fallback to the agent slot. This means a process holding only `EMCLI_KEY` cannot unlock the
DEK for an admin command, even if it somehow knows the agent key.
### Command → role table
| Command | Role required |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
`can_send` — no host or login username.
| `init` | Both keys required (writes both wrap slots) |
### Agent launcher guidance
Configure your agent's orchestrator with **only `EMCLI_KEY`**. Never give the orchestrator
`EMCLI_ADMIN_KEY`. If the agent tries to run an admin command — even by mistake — `emcli` will
refuse it at the key level, not just by convention.
---
## 3. Quick start ## 3. Quick start
```bash ```bash
# 1. Set your key (see section 2) # 1. Generate and export both keys (see section 2)
export EMCLI_KEY='…' export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # keep this yourself
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # give only this to the agent
# 2. Create the database and add your first account (interactive form) # 2. Create the database and add your first account (interactive form)
emcli init emcli init
# 3. Check it connects and authenticates # 3. Check it connects and authenticates (agent key is enough for doctor)
emcli doctor emcli doctor
# 4. The agent can now read # 4. The agent can now read (needs only EMCLI_KEY)
emcli list --account gmail --folder INBOX --limit 10 emcli list --account gmail --folder INBOX --limit 10
``` ```
@@ -126,17 +176,18 @@ emcli account add # opens the form
Or with flags (good for scripting): Or with flags (good for scripting):
```bash ```bash
emcli account add --name work --mode RW \ emcli account add work --mode RW \
--imap-host imap.example.com --imap-port 993 --imap-security tls \ --imap-host imap.example.com --imap-port 993 --imap-security tls \
--smtp-host smtp.example.com --smtp-port 465 --smtp-security tls \ --smtp-host smtp.example.com --smtp-port 465 --smtp-security tls \
--username you@example.com --password 'your-password' --username you@example.com --password 'your-password'
``` ```
The account name is the first positional argument. Omit it to open the interactive form.
**`account add` flags:** **`account add` flags:**
| Flag | Default | Notes | | Flag | Default | Notes |
|---|---|---| |---|---|---|
| `--name` | — | Account name the agent will use (required) |
| `--mode` | `RO` | `RO` (read-only) or `RW` (read + send) | | `--mode` | `RO` | `RO` (read-only) or `RW` (read + send) |
| `--imap-host` | — | IMAP server (required) | | `--imap-host` | — | IMAP server (required) |
| `--imap-port` | `993` | | | `--imap-port` | `993` | |
@@ -146,11 +197,12 @@ emcli account add --name work --mode RW \
| `--smtp-security` | `tls` | `tls` or `starttls` | | `--smtp-security` | `tls` | `tls` or `starttls` |
| `--username` | — | Login username, usually your full email (required) | | `--username` | — | Login username, usually your full email (required) |
| `--password` | — | Login password or app password | | `--password` | — | Login password or app password |
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
| `--subject-regex` | — | Inbound subject filter (optional) | | `--subject-regex` | — | Inbound subject filter (optional) |
| `--whitelist-in` | off | Enable inbound whitelist |
| `--whitelist-out` | off | Enable outbound whitelist |
| `--process-backlog` | off | Treat existing mail as "new" (see below) | | `--process-backlog` | off | Treat existing mail as "new" (see below) |
To enable whitelisting after adding an account, use `whitelist enable` (section 6).
**`--process-backlog`.** When `emcli` first sees a folder: **`--process-backlog`.** When `emcli` first sees a folder:
- **off (default):** existing mail is treated as already handled — `list --new` starts empty and - **off (default):** existing mail is treated as already handled — `list --new` starts empty and
only mail that arrives *after* this point counts as new. only mail that arrives *after* this point counts as new.
@@ -168,7 +220,7 @@ Gmail needs an **app password**, not your normal Google password.
4. Add the account (paste the app password; the spaces Google shows are optional): 4. Add the account (paste the app password; the spaces Google shows are optional):
```bash ```bash
emcli account add --name gmail --mode RW \ emcli account add gmail --mode RW \
--imap-host imap.gmail.com --imap-port 993 --imap-security tls \ --imap-host imap.gmail.com --imap-port 993 --imap-security tls \
--smtp-host smtp.gmail.com --smtp-port 465 --smtp-security tls \ --smtp-host smtp.gmail.com --smtp-port 465 --smtp-security tls \
--username you@gmail.com --password 'xxxxxxxxxxxxxxxx' --username you@gmail.com --password 'xxxxxxxxxxxxxxxx'
@@ -182,7 +234,7 @@ An app password keeps working until you revoke it, change your main Google passw
Most IMAP/SMTP providers work the same way. A typical cPanel-style host: Most IMAP/SMTP providers work the same way. A typical cPanel-style host:
```bash ```bash
emcli account add --name work --mode RW \ emcli account add work --mode RW \
--imap-host mail.yourdomain.com --imap-port 993 --imap-security tls \ --imap-host mail.yourdomain.com --imap-port 993 --imap-security tls \
--smtp-host mail.yourdomain.com --smtp-port 465 --smtp-security tls \ --smtp-host mail.yourdomain.com --smtp-port 465 --smtp-security tls \
--username you@yourdomain.com --password 'your-password' --username you@yourdomain.com --password 'your-password'
@@ -201,28 +253,42 @@ confirm.
emcli account list emcli account list
``` ```
**Show an account's current settings:**
```bash
emcli account show gmail
```
Prints the account name, mode, IMAP/SMTP host, username, send-from address, subject filter, and
whitelist state. The password is never shown.
**Edit an account** — interactive (opens the form pre-filled): **Edit an account** — interactive (opens the form pre-filled):
```bash ```bash
emcli account edit --name gmail emcli account edit gmail
``` ```
**Edit with flags** — only the flags you pass are changed; everything else is preserved. Leaving **Edit with flags** — only the flags you pass are changed; everything else is preserved. Leaving
`--password` out keeps the existing password. `--password` out keeps the existing password.
```bash ```bash
emcli account edit --name work --mode RW --smtp-host smtp.example.com --smtp-port 587 --smtp-security starttls emcli account edit work --mode RW --smtp-host smtp.example.com --smtp-port 587 --smtp-security starttls
emcli account edit --name gmail --password 'new-app-password' # rotate the app password emcli account edit gmail --password 'new-app-password' # rotate the app password
``` ```
> Note: the flag form of `account edit` covers connection/auth fields and `--subject-regex`. To ```bash
> toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit --name X` emcli account edit work --from 'Work Team <you@yourdomain.com>' # set the send-as address
> with no other flags), or the `whitelist` commands in section 6. emcli account edit work --from '' # clear it (revert to username)
```
**Remove an account** (requires `--yes`): > Note: the flag form of `account edit` covers connection/auth fields, `--from`, and
> `--subject-regex`. Passing `--from ''` clears the send-as address so mail falls back to the login
> username. To enable or disable whitelists use `whitelist enable`/`whitelist disable` (section 6).
**Remove an account** (requires `--yes` when stdin is not a terminal):
```bash ```bash
emcli account remove --name work --yes emcli account remove work --yes
``` ```
--- ---
@@ -235,24 +301,30 @@ Set with `--mode RO|RW` on `account add`/`edit`. `RO` accounts reject every `sen
### Inbound whitelist ### Inbound whitelist
Enable it on the account (`--whitelist-in`, or the interactive form), then manage entries: Manage entries, then enable filtering when ready:
```bash ```bash
emcli whitelist in add --account gmail --address boss@example.com emcli whitelist add gmail boss@example.com --in
emcli whitelist in add --account gmail --address @partner.com emcli whitelist add gmail @partner.com --in
emcli whitelist in list --account gmail emcli whitelist list gmail --in
emcli whitelist in remove --account gmail --address boss@example.com emcli whitelist remove gmail boss@example.com --in
emcli whitelist enable gmail --in # activate filtering
emcli whitelist disable gmail --in # suspend filtering without removing entries
``` ```
`whitelist list` shows the current entries and whether filtering is ENABLED or DISABLED.
Enabling an empty whitelist warns you that all inbound mail will be blocked until you add addresses.
When enabled, the agent only sees mail from listed senders. Everything else is invisible. When enabled, the agent only sees mail from listed senders. Everything else is invisible.
### Outbound whitelist ### Outbound whitelist
Enable it (`--whitelist-out`), then manage entries the same way with `whitelist out`: Same pattern with `--out`:
```bash ```bash
emcli whitelist out add --account gmail --address @example.com emcli whitelist add gmail @example.com --out
emcli whitelist out list --account gmail emcli whitelist list gmail --out
emcli whitelist enable gmail --out
``` ```
When enabled, **every** recipient of a `send` (to + cc + bcc) must match an entry or the whole When enabled, **every** recipient of a `send` (to + cc + bcc) must match an entry or the whole
@@ -269,10 +341,10 @@ send is blocked.
A regular expression on the account. If set, the agent only sees mail whose subject matches: A regular expression on the account. If set, the agent only sees mail whose subject matches:
```bash ```bash
emcli account edit --name gmail --subject-regex '^\[ticket\]' emcli account edit gmail --subject-regex '^\[ticket\]'
``` ```
Clear it by setting it empty in the interactive form. Clear it by passing an empty string (`--subject-regex ''`) or using the interactive form.
--- ---
@@ -375,6 +447,11 @@ emcli send --account gmail --to a@x.com --subject "Re: Hi" --body "thanks" \
client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed
to see. to see.
The message's `From:` is the account's send-as address (`--from`, set on `account add`/`edit`); if
none is configured it falls back to the login username. A display-name address like
`Steve Cliff <me@example.com>` shows the name in the recipient's client while the bare address is
used for the SMTP envelope.
--- ---
## 8. The JSON envelope ## 8. The JSON envelope
@@ -438,7 +515,8 @@ sends and reads nothing.
```bash ```bash
emcli doctor # check every account emcli doctor # check every account
emcli doctor --account gmail # check just one emcli doctor gmail # check just one (positional)
emcli doctor --account gmail # same — flag form also works
``` ```
Example output: Example output:
@@ -469,7 +547,7 @@ Every agent action (`list`, `get`, `search`, `ack`, `send`) — allowed or block
```bash ```bash
emcli audit list # most recent 50 emcli audit list # most recent 50
emcli audit list --account gmail # filter to one account emcli audit list gmail # filter to one account
emcli audit list --limit 200 emcli audit list --limit 200
``` ```
@@ -479,6 +557,7 @@ the reason. Secrets never appear here.
### Settings ### Settings
```bash ```bash
emcli config list # list all settings and their current values
emcli config set audit_retention_days 90 emcli config set audit_retention_days 90
emcli config get audit_retention_days emcli config get audit_retention_days
``` ```
@@ -486,15 +565,24 @@ emcli config get audit_retention_days
- `audit_retention_days` — how long to keep audit rows. On each run, entries older than this are - `audit_retention_days` — how long to keep audit rows. On each run, entries older than this are
purged. Must be a whole number ≥ 0. `0` or unset means no automatic purging. purged. Must be a whole number ≥ 0. `0` or unset means no automatic purging.
`config list` prints every known setting, its current value, and a short description. Unknown keys
are rejected by `config get` and `config set`.
--- ---
## 11. Troubleshooting ## 11. Troubleshooting
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid **"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
base64-encoded 32-byte key (section 2). Every command that touches the database needs it. base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `ack`, `send`,
`doctor`) need this key.
**A command fails to decrypt / "wrong EMCLI_KEY?".** The key doesn't match the one used when the **"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
account was added. Restore the original key, or re-add the account with the current key. Admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) need this key;
`EMCLI_KEY` alone is not enough for them. (`account list` is the exception — an agent can run it.)
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
the DEK if one already exists) with both correct keys, then re-add any accounts if needed.
**`doctor` shows `IMAP FAIL` / `SMTP FAIL`.** **`doctor` shows `IMAP FAIL` / `SMTP FAIL`.**
- *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure - *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure
@@ -503,11 +591,11 @@ account was added. Restore the original key, or re-add the account with the curr
`587`/`starttls` is a common alternative to `465`/`tls`. `587`/`starttls` is a common alternative to `465`/`tls`.
**The agent can't see a message you know exists.** It's probably filtered: check the account's **The agent can't see a message you know exists.** It's probably filtered: check the account's
inbound whitelist (`emcli whitelist in list --account NAME`) and subject filter. Filtered mail is inbound whitelist (`emcli whitelist list NAME --in`) and subject filter. Filtered mail is
invisible by design. invisible by design.
**`send` is blocked.** **`send` is blocked.**
- `ro_mode` — the account is read-only. Change it: `emcli account edit --name NAME --mode RW` - `ro_mode` — the account is read-only. Change it: `emcli account edit NAME --mode RW`
(and set SMTP details). (and set SMTP details).
- `whitelist_out` — a recipient isn't on the outbound whitelist. Add it, or review the rule. - `whitelist_out` — a recipient isn't on the outbound whitelist. Add it, or review the rule.
@@ -528,19 +616,21 @@ running non-interactively.
emcli # or: emcli help / emcli --help — list all commands emcli # or: emcli help / emcli --help — list all commands
emcli <command> --help # usage and flags for one command emcli <command> --help # usage and flags for one command
# Admin # Admin (requires EMCLI_ADMIN_KEY)
emcli init # create DB + add first account (form) emcli init # create DB + add first account (form)
emcli account add [flags | none for form] # add an account emcli account add [<name>] [flags] # add an account; no args → interactive form
emcli account list # list accounts (no secrets) emcli account list # full table (admin) / name+from+can_send JSON (agent)
emcli account edit --name N [flags | none for form] # change an account emcli account show N # display one account's settings
emcli account remove --name N --yes # delete an account emcli account edit N [flags | none for form] # change an account
emcli whitelist in|out add|remove|list --account N [--address A] emcli account remove N [--yes] # delete an account (--yes or TTY confirm)
emcli whitelist <add|remove|list|enable|disable> N [addr…] --in|--out
emcli config list # list all settings and descriptions
emcli config set|get <key> [value] # e.g. audit_retention_days emcli config set|get <key> [value] # e.g. audit_retention_days
emcli audit list [--account N] [--limit K] emcli audit list [account] [--limit K]
emcli doctor [--account N] # connectivity/auth check
emcli version emcli version
# Agent (one line of JSON each) # Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
emcli doctor [N | --account N] # connectivity/auth check
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U] emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
emcli get --account N [--folder F] --uid U emcli get --account N [--folder F] --uid U
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K] emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
@@ -548,4 +638,4 @@ emcli ack --account N [--folder F] --uid-list U1,U2,U3
emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]] emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]]
``` ```
Environment: `EMCLI_KEY` (required, base64 32-byte key), `EMCLI_DB` (optional DB path). Environment: `EMCLI_ADMIN_KEY` (required for admin commands, base64 32-byte key), `EMCLI_KEY` (required for agent commands, base64 32-byte key), `EMCLI_DB` (optional DB path).
@@ -0,0 +1,855 @@
# Two-Key Privilege Separation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enforce the agent/admin trust boundary with two environment keys — `EMCLI_ADMIN_KEY` (all commands) and `EMCLI_KEY` (agent commands only) — so a forced agent holding only `EMCLI_KEY` cannot run admin commands.
**Architecture:** Envelope encryption. `init` generates a random data-encryption key (DEK) that seals all account secrets exactly as today. The DEK is stored in `settings`, sealed twice: under the admin KEK (`dek_wrap_admin`) and under the agent KEK (`dek_wrap_agent`). Admin commands unwrap the DEK from the admin slot **only** (no agent fallback); agent commands use the agent slot (falling back to the admin slot when only the admin key is present). The store's existing `s.key` field simply becomes the DEK, so `account.go` / mail crypto is untouched.
**Tech Stack:** Go, `modernc.org/sqlite`, AES-256-GCM (existing `crypto.Seal`/`crypto.Open`), standard `flag` CLI.
## Global Constraints
- Module path: `git.dcglab.co.uk/steve/emcli`. Packages under `internal/`.
- Keys are base64-encoded **exactly 32 bytes** (AES-256). Reject anything else.
- Single static CGO-free binary; `go vet ./...` must stay clean; tests pass under `-race`.
- Secrets (keys, passwords, DEK) never appear on stdout, in the JSON envelope, or the audit log.
- Agent commands emit exactly one JSON object on stdout; admin commands print human-readable text (never JSON).
- DEK never written to disk in cleartext; wrap slots stored as base64 text in the `settings` table (`value TEXT NOT NULL`).
- No migration / no schema-version gate — `init` writes wrap slots into a fresh DB (decided in spec).
**Command → role mapping (single source of truth, implemented in Task 3):**
| Command | Role |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent |
| `account`, `whitelist`, `config`, `audit` | admin |
| `init` | bootstrap (needs **both** keys) |
| `help` / no args | none (no DB access) |
---
## Task 1: crypto — named-var key loaders + DEK generation
**Files:**
- Modify: `internal/crypto/crypto.go`
- Test: `internal/crypto/crypto_test.go`
**Interfaces:**
- Consumes: existing `Seal(key, plaintext []byte) ([]byte, error)`, `Open(key, blob []byte) ([]byte, error)` (unchanged — they double as DEK wrap/unwrap).
- Produces:
- `AgentKeyFromEnv() ([]byte, error)` — reads `EMCLI_KEY`.
- `AdminKeyFromEnv() ([]byte, error)` — reads `EMCLI_ADMIN_KEY`.
- `NewDEK() ([]byte, error)` — fresh random 32-byte key.
- Removes: `KeyFromEnv`, `ErrNoKey`, `ErrBadKey` (only `crypto_test.go` and `internal/cli/run.go:30` reference them; the latter is rewritten in Task 3).
- [ ] **Step 1: Replace the env/error section of `crypto.go`**
Replace the current `var ( ErrNoKey … ); func KeyFromEnv()` block (lines ~1430) with:
```go
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
// named environment variable. Errors name the variable so callers get a
// role-appropriate message.
func keyFromEnv(varName string) ([]byte, error) {
raw := os.Getenv(varName)
if raw == "" {
return nil, fmt.Errorf("%s is not set", varName)
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
}
return key, nil
}
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
// NewDEK returns a fresh random 32-byte data-encryption key.
func NewDEK() ([]byte, error) {
dek := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
return dek, nil
}
```
Update the import block to add `"fmt"` and drop `"errors"` if now unused (it is — no other `errors.` use remains after removing the sentinels; `Open` uses `errors.New` so KEEP `"errors"`). Net: add `"fmt"`, keep everything else.
- [ ] **Step 2: Rewrite `TestKeyFromEnv` in `crypto_test.go`**
Replace `TestKeyFromEnv` (lines ~5369) with:
```go
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
good := base64.StdEncoding.EncodeToString(testKey())
t.Setenv("EMCLI_KEY", good)
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", good)
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", "")
if _, err := AdminKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
}
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := AgentKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("short key: want length error, got %v", err)
}
}
func TestNewDEKIsRandom32(t *testing.T) {
a, err := NewDEK()
if err != nil || len(a) != 32 {
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
}
b, _ := NewDEK()
if bytes.Equal(a, b) {
t.Fatal("two DEKs must differ")
}
}
```
Add `"strings"` to the test imports (`bytes` and `encoding/base64` are already imported).
- [ ] **Step 3: Run crypto tests, expect FAIL to PASS transition**
Run: `go test ./internal/crypto/...`
Expected: PASS. (If it fails to compile because `"errors"` became unused, that means `Open` no longer references it — it does, so this should not happen; if `"fmt"` is reported unused, you forgot to add a loader. Fix and re-run.)
- [ ] **Step 4: Commit**
```bash
git add internal/crypto/crypto.go internal/crypto/crypto_test.go
git commit -m "feat(crypto): named-var key loaders (admin/agent) + NewDEK
Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY
and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double
as DEK wrap/unwrap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
> Note: the repo will not fully build until Task 3 (cli still references the removed `crypto.KeyFromEnv`). The crypto package and its tests are self-contained and pass.
---
## Task 2: store — unlock/init split + DEK wrap slots
**Files:**
- Modify: `internal/store/store.go`
- Create: `internal/store/keys.go`
- Test: `internal/store/keys_test.go`
**Interfaces:**
- Consumes: `crypto.NewDEK`, `crypto.Seal`, `crypto.Open` (Task 1); existing `(*Store).GetSetting`, `(*Store).SetSetting`.
- Produces:
- `type Role int` with `RoleAgent Role = iota` and `RoleAdmin`.
- `store.Open(path string) (*Store, error)`**signature change**: drops the `key` param; store opens locked.
- `(*Store).InitKeys(adminKey, agentKey []byte) error`.
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error`.
- `var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")`.
- Note for later tasks: after `Open`, `s.key` is nil; a command MUST call `Unlock` (or `InitKeys`) before any account read/write, or `crypto.Open(s.key, …)` in `account.go` will fail.
- [ ] **Step 1: Change `store.Open` to open locked (no key param)**
In `internal/store/store.go`, change the signature and the struct construction:
```go
// Open opens (creating if needed) the DB at path and applies the schema.
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
// secret read/write.
func Open(path string) (*Store, error) {
```
and replace `s := &Store{db: db, key: key}` with:
```go
s := &Store{db: db}
```
Leave the rest of `Open` (dir creation, pragma, schema, schema_version setting) unchanged. The `key []byte` field on `Store` stays as-is (now populated by `Unlock`/`InitKeys`).
- [ ] **Step 2: Write the failing test for InitKeys + Unlock**
Create `internal/store/keys_test.go`:
```go
package store
import (
"bytes"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
func k(b byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = b
}
return key
}
func tempStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
admin, agent := k(0xAA), k(0xBB)
st := tempStore(t)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("InitKeys: %v", err)
}
// Seal a password under the DEK that InitKeys set.
if _, err := st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
// Re-open and unlock via the AGENT slot.
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
t.Fatalf("Unlock(agent): %v", err)
}
got, err := st2.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
}
st2.Close()
// Unlock via the ADMIN slot recovers the same DEK.
st3, _ := Open(path)
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
t.Fatalf("Unlock(admin): %v", err)
}
got3, err := st3.GetAccount("a")
if err != nil || got3.Password != "pw" {
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
}
st3.Close()
}
func TestUnlockWrongKeyFails(t *testing.T) {
st := tempStore(t)
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatal(err)
}
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
t.Fatal("Unlock with wrong admin key must fail")
}
st2.Close()
}
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
// RoleAdmin must use the admin slot; passing the agent key as the admin
// key must fail — there is no fallback to the agent slot.
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
t.Fatal("agent key must not unlock the admin slot")
}
}
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
})
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("re-InitKeys: %v", err)
}
got, err := st.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
}
_ = bytes.Equal // keep import if unused elsewhere
}
```
- [ ] **Step 3: Run the test to verify it fails (compile error)**
Run: `go test ./internal/store/ -run TestInitKeys -v`
Expected: FAIL — compile errors (`InitKeys`, `Unlock`, `RoleAgent`, `RoleAdmin`, `dbPath` undefined).
- [ ] **Step 4: Implement `internal/store/keys.go`**
```go
package store
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
// Role selects which DEK wrap slot a command may unlock.
type Role int
const (
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
RoleAdmin // all commands; uses dek_wrap_admin ONLY
)
const (
settingDEKWrapAdmin = "dek_wrap_admin"
settingDEKWrapAgent = "dek_wrap_agent"
)
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
// dbPath returns the file path SQLite opened (used by tests to re-open).
func (s *Store) dbPath() string {
var p string
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
return p
}
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
// both wrap slots, and unlocks the store. If the slots already exist it does
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
return s.Unlock(RoleAdmin, adminKey, nil)
}
dek, err := crypto.NewDEK()
if err != nil {
return err
}
wrapAdmin, err := crypto.Seal(adminKey, dek)
if err != nil {
return err
}
wrapAgent, err := crypto.Seal(agentKey, dek)
if err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
return err
}
s.key = dek
return nil
}
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
// falls back to the admin slot only when no agent key is supplied.
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
switch role {
case RoleAdmin:
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
case RoleAgent:
if len(agentKey) > 0 {
return s.unlockSlot(settingDEKWrapAgent, agentKey)
}
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
default:
return fmt.Errorf("unknown role %d", role)
}
}
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
if len(kek) == 0 {
return ErrLocked
}
enc, err := s.GetSetting(settingKey)
if errors.Is(err, sql.ErrNoRows) {
return ErrLocked
}
if err != nil {
return err
}
blob, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
}
dek, err := crypto.Open(kek, blob)
if err != nil {
return errors.New("wrong key for this DB")
}
s.key = dek
return nil
}
```
- [ ] **Step 5: Run the store tests, expect PASS**
Run: `go test ./internal/store/... -v`
Expected: PASS, including the existing store tests. (Existing `store_test.go` may call `Open(path, key)` with two args — if so, that is fixed in Step 6.)
- [ ] **Step 6: Fix any existing `store` callers of the old `Open(path, key)` signature**
Run: `git grep -n "store.Open(\|Open(path," internal/store`
For each in-package call to `Open` with two args (e.g. in `store_test.go`), change `Open(path, someKey)` to `Open(path)` followed by `st.InitKeys(k(0xAA), k(0xBB))` (or `st.Unlock(...)` if the test re-opens an initialized DB). Re-run `go test ./internal/store/...` until green.
- [ ] **Step 7: Commit**
```bash
git add internal/store/store.go internal/store/keys.go internal/store/keys_test.go internal/store/store_test.go
git commit -m "feat(store): envelope DEK with admin/agent wrap slots
Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs;
Unlock loads it from the role's slot (admin slot has no agent fallback).
s.key becomes the DEK, so account/mail crypto is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: cli — role routing, openStore(role), init bootstrap
**Files:**
- Modify: `internal/cli/run.go` (openStore, command routing, agent call sites)
- Modify: `internal/cli/admin.go` (4 `openStore()` call sites)
- Modify: `internal/cli/interactive.go` (`runInit` bootstrap)
- Modify: `internal/cli/admin_test.go` (`adminEnv` helper)
- Modify: `internal/cli/run_test.go` (b64 helpers, gating test)
**Interfaces:**
- Consumes: `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv` (Task 1); `store.Open`, `store.Role`, `store.RoleAgent`, `store.RoleAdmin`, `(*Store).Unlock`, `(*Store).InitKeys` (Task 2).
- Produces: `commandRole(cmd string) store.Role`; `openStore(role store.Role) (*store.Store, error)`.
- [ ] **Step 1: Rewrite `openStore` and add `commandRole` in `run.go`**
Replace the current `openStore` (lines ~2839) with:
```go
// commandRole maps a command to the privilege it requires. Admin commands
// mutate configuration or expose oversight data; everything else is agent.
func commandRole(cmd string) store.Role {
switch cmd {
case "account", "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
func openStore(role store.Role) (*store.Store, error) {
adminKey, adminErr := crypto.AdminKeyFromEnv()
agentKey, agentErr := crypto.AgentKeyFromEnv()
switch role {
case store.RoleAdmin:
if adminErr != nil {
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
}
case store.RoleAgent:
if agentErr != nil && adminErr != nil {
return nil, agentErr // "EMCLI_KEY is not set"
}
}
path, err := store.DefaultDBPath()
if err != nil {
return nil, err
}
st, err := store.Open(path)
if err != nil {
return nil, err
}
if err := st.Unlock(role, adminKey, agentKey); err != nil {
st.Close()
return nil, err
}
return st, nil
}
```
Remove the now-unused `crypto` import only if it becomes unused — it does NOT (openStore still uses it). Ensure `run.go` imports include `"fmt"` (already present) and `crypto`/`store` (already present).
- [ ] **Step 2: Update the three agent call sites in `run.go`**
- `runDoctor` (line ~82): `st, err := openStore(store.RoleAgent)`
- `runAgent` (line ~162): `st, err := openStore(store.RoleAgent)`
- `runSend` (line ~252): `st, err := openStore(store.RoleAgent)`
- [ ] **Step 3: Update the four admin call sites in `admin.go`**
In `runAccount` (~24), `runConfig` (~207), `runAudit` (~265), `runWhitelist` (~304): change each `st, err := openStore()` to `st, err := openStore(store.RoleAdmin)`. Confirm `admin.go` imports `git.dcglab.co.uk/steve/emcli/internal/store` (it returns `store` types already; if not imported, add it).
- [ ] **Step 4: Rewrite `runInit` bootstrap in `interactive.go`**
Replace the body of `runInit` (lines ~7597) with:
```go
func runInit(args []string, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init")
return 0
}
adminKey, err := crypto.AdminKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
agentKey, err := crypto.AgentKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
path, err := store.DefaultDBPath()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
st, err := store.Open(path)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if err := st.InitKeys(adminKey, agentKey); err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90")
}
accs, _ := st.ListAccounts()
if len(accs) > 0 {
fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs))
} else {
fmt.Fprintln(out, "Initializing emcli — add your first account.")
}
return addInteractive(st, tui.Fields{}, out, errOut)
}
```
Add imports to `interactive.go` if missing: `git.dcglab.co.uk/steve/emcli/internal/crypto` and `git.dcglab.co.uk/steve/emcli/internal/store` (it already uses `store` for `addInteractive`; `tui` and `fmt` are already imported).
- [ ] **Step 5: Update test helpers in `admin_test.go`**
Replace `adminEnv` (lines ~1420) with a version that sets both keys AND seeds the wrap slots:
```go
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
func adminEnv(t *testing.T) string {
t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, err := store.Open(db)
if err != nil {
t.Fatalf("Open: %v", err)
}
adminKey, _ := crypto.AdminKeyFromEnv()
agentKey, _ := crypto.AgentKeyFromEnv()
if err := st.InitKeys(adminKey, agentKey); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
return db
}
```
Add `"git.dcglab.co.uk/steve/emcli/internal/crypto"` to `admin_test.go` imports (`store` and `filepath` are already imported).
- [ ] **Step 6: Add `b64AgentKey` and fix the gating test in `run_test.go`**
Add next to `b64Key` (line ~53):
```go
func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
```
In `TestRunVersionIsJSONForAgentButTextHere` (lines ~2133), make the admin-key absence explicit so the test is deterministic regardless of the developer's shell:
```go
t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
code := Run([]string{"account", "list"}, &out, &errOut)
```
The assertion `strings.Contains(out+err, "EMCLI_KEY")` still holds — the admin error text contains `EMCLI_ADMIN_KEY`.
- [ ] **Step 7: Build and run the full cli suite**
Run: `go build ./... && go test ./internal/cli/... -race`
Expected: PASS. (This is the first point the whole repo builds again.) If `crypto` shows as an unused import anywhere you touched, remove it; if a `store` import is missing in `admin.go`/`interactive.go`, add it.
- [ ] **Step 8: Add routing tests in a new `internal/cli/role_test.go`**
```go
package cli
import (
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func TestCommandRole(t *testing.T) {
admin := []string{"account", "whitelist", "config", "audit"}
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
for _, c := range admin {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%s should be admin", c)
}
}
for _, c := range agent {
if commandRole(c) != store.RoleAgent {
t.Errorf("%s should be agent", c)
}
}
}
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
// A human holding only the admin key can still run agent commands
// (admin is a superset → agent-role unlock falls back to the admin slot).
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, _ := store.Open(db)
ak, _ := crypto.AdminKeyFromEnv()
gk, _ := crypto.AgentKeyFromEnv()
st.InitKeys(ak, gk)
st.Close()
// Only the admin key now; agent command must still open the store.
t.Setenv("EMCLI_KEY", "")
s2, err := openStore(store.RoleAgent)
if err != nil {
t.Fatalf("agent role with only admin key should open: %v", err)
}
s2.Close()
}
```
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentCommandWorksWithOnlyAdminKey' -v`
Expected: PASS.
- [ ] **Step 9: Commit**
```bash
git add internal/cli/run.go internal/cli/admin.go internal/cli/interactive.go \
internal/cli/admin_test.go internal/cli/run_test.go internal/cli/role_test.go
git commit -m "feat(cli): two-key role routing + init bootstrap
openStore(role) selects the DEK wrap slot; admin commands require
EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both
slots from both keys. Test helpers seed the wrap slots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: headline security-invariant test
**Files:**
- Create: `internal/cli/security_invariant_test.go`
**Interfaces:**
- Consumes: `store.Open`, `(*Store).InitKeys`, `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv`, `run`, `b64Key`, `b64AgentKey` (Tasks 23).
- [ ] **Step 1: Write the invariant test**
```go
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func dbBytes(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read db: %v", err)
}
return b
}
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
// command, and the DB must be unchanged after it tries.
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, _ := store.Open(db)
ak, _ := crypto.AdminKeyFromEnv()
gk, _ := crypto.AgentKeyFromEnv()
if err := st.InitKeys(ak, gk); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
// Simulate the agent's environment: admin key absent.
t.Setenv("EMCLI_ADMIN_KEY", "")
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "list"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
for _, args := range adminAttempts {
code, out, errOut := run(t, args...)
if code == 0 {
t.Fatalf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
}
}
if !bytes.Equal(before, dbBytes(t, db)) {
t.Fatal("DB changed despite all admin commands being refused")
}
}
```
- [ ] **Step 2: Run it**
Run: `go test ./internal/cli/ -run TestAgentKeyCannotRunAdminCommands -v -race`
Expected: PASS. (If any admin command exits 0, the role gate is broken — fix Task 3 before continuing.)
- [ ] **Step 3: Full suite + vet**
Run: `go vet ./... && go test ./... -race`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/cli/security_invariant_test.go
git commit -m "test(cli): prove agent key cannot run admin commands
Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with
only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: documentation
**Files:**
- Modify: `README.md`
- Modify: `USER-MANUAL.md`
- Modify: `specifications/SPEC.md`
- Modify: `skills/emcli/SKILL.md` and `skills/emcli/AGENTIC-MANUAL.md` (whichever document key setup)
**Interfaces:** none (docs only). No code; no test cycle — verification is `grep` + a manual read.
- [ ] **Step 1: README "Getting started" — two keys**
Replace the single-key export block with:
```bash
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
emcli init # writes both wrap slots
emcli doctor # confirm connect/auth (agent key is enough)
```
Add one sentence: *"`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it."*
- [ ] **Step 2: USER-MANUAL — key model + role table**
Add a "Privilege model" section documenting: the two env vars; the DEK/envelope design in one paragraph (DEK sealed under both keys; admin slot has no agent fallback); the command→role table (copy from Global Constraints above); and the agent-launcher guidance (set only `EMCLI_KEY`). Update any existing single-`EMCLI_KEY` setup and `init` instructions to the two-key flow.
- [ ] **Step 3: SPEC §4/§5 — enforced trust boundary**
In §4 "Trust boundary": change the wording from *the agent invokes only the agent commands* (convention) to the enforced model — agent commands accept `EMCLI_KEY`; admin commands require `EMCLI_ADMIN_KEY` and unlock the admin DEK slot only. In §5 "Configuration & secrets": document `EMCLI_ADMIN_KEY`, `EMCLI_KEY`, the DEK, and the two `settings` wrap rows (`dek_wrap_admin`, `dek_wrap_agent`).
- [ ] **Step 4: skill docs — agent gets only EMCLI_KEY**
In `skills/emcli/SKILL.md` / `AGENTIC-MANUAL.md`, state that the agent is provided only `EMCLI_KEY` and therefore can run `list`/`get`/`search`/`ack`/`send`/`doctor`; admin commands are unavailable to it by design. Remove any text implying the agent can configure accounts/whitelists.
- [ ] **Step 5: Verify and commit**
Run: `git grep -n "EMCLI_KEY" README.md USER-MANUAL.md specifications/SPEC.md skills/`
Confirm every setup/init reference reflects the two-key model and no doc tells the agent to run admin commands. Then:
```bash
git add README.md USER-MANUAL.md specifications/SPEC.md skills/
git commit -m "docs: document two-key privilege model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review (completed by plan author)
**Spec coverage:**
- Two env vars / role model → Tasks 1, 3. ✓
- Envelope DEK + two wrap slots → Task 2. ✓
- `s.key` becomes DEK, account crypto untouched → Task 2 (no change to `account.go`). ✓
- Command classification (doctor=agent, audit=admin) → Task 3 `commandRole`. ✓
- Admin slot has no agent fallback (enforcement linchpin) → Task 2 `Unlock`/`unlockSlot`, proved in Task 2 `TestAdminSlotNotOpenableByAgentKey` and Task 4. ✓
- Agent→admin superset fallback → Task 2 `Unlock`, Task 3 `TestAgentCommandWorksWithOnlyAdminKey`. ✓
- `init` requires both keys; idempotent (no DEK regen) → Task 3 `runInit`, Task 2 `TestInitKeysIdempotentKeepsDEK`. ✓
- No migration / no version gate → no such code added. ✓
- Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3 `openStore`, Task 2 `unlockSlot`. ✓
- Existing-test fallout (helpers) → Task 3 Steps 56. ✓
- Headline invariant test → Task 4. ✓
- Docs → Task 5. ✓
**Placeholder scan:** No TBD/TODO; all code blocks complete; the only `bytes.Equal` no-op is annotated. ✓
**Type consistency:** `store.Role`/`RoleAgent`/`RoleAdmin`, `Open(path)`, `InitKeys(adminKey, agentKey)`, `Unlock(role, adminKey, agentKey)`, `commandRole`, `openStore(role)`, `AgentKeyFromEnv`/`AdminKeyFromEnv`/`NewDEK`, `b64Key`/`b64AgentKey` are used identically across tasks. ✓
@@ -0,0 +1,497 @@
# Agent-readable `account list` Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an agent holding only `EMCLI_KEY` run `emcli account list` and get a reduced JSON view (name, from, can_send), while admin keeps the full text table and `account add/edit/remove` stay admin-only.
**Architecture:** Make `commandRole` subcommand-aware so `account list` routes to the agent role; branch the `list` renderer on whether the admin key is present (admin → existing text table; agent → standard `Success` JSON envelope). No schema change; `ListAccounts` already avoids decrypting secrets.
**Tech Stack:** Go, standard library (`flag`, `encoding/json`), existing `internal/cli` envelope helpers and `internal/crypto` key loaders.
## Global Constraints
- Agent output is the existing JSON envelope shape: `{"error":bool,"error_detail":{...},"data":{...}}` via `Success(...)` / `Failure(...)` in `internal/cli/envelope.go`.
- Admin `account list` output stays byte-for-byte the current human-readable table (`NAME MODE IMAP USER`).
- The agent (reduced) view exposes only `name`, `from`, `can_send` — never the IMAP host/port or login username.
- `from = Account.SendFrom()` (explicit `FromAddress`, else `Username`). `can_send = (Mode == "RW")`.
- `account add/edit/remove` remain admin-only (hard-require `EMCLI_ADMIN_KEY`, no fallback).
- Privilege detection: a caller is "admin" iff `crypto.AdminKeyFromEnv()` returns no error.
- Spec: `docs/superpowers/specs/2026-06-23-agent-account-list-design.md`.
---
### Task 1: Route `account list` to the agent role and render by privilege
**Files:**
- Modify: `internal/cli/run.go` (`commandRole`, its call site in `Run`)
- Modify: `internal/cli/admin.go` (the `list` branch of `runAccount`; add `crypto` import)
- Modify: `internal/cli/role_test.go` (`TestCommandRole`)
- Modify: `internal/cli/security_invariant_test.go` (refused-commands set)
- Create/Test: `internal/cli/account_list_test.go`
**Interfaces:**
- Consumes: `store.Account.SendFrom() string`, `store.Account.Mode string`, `store.Store.ListAccounts() ([]store.Account, error)`, `crypto.AdminKeyFromEnv() ([]byte, error)`, `Success(map[string]any) Envelope`, `Failure(code, msg string) Envelope`, `Envelope.Write(io.Writer) error`, test helpers `adminEnv(t)`, `run(t, args...)`.
- Produces: `commandRole(args []string) store.Role` (signature changes from `commandRole(cmd string)`). Agent `account list` emits `{"data":{"accounts":[{"name":string,"from":string,"can_send":bool}]}}`.
- [ ] **Step 1: Rewrite `TestCommandRole` for the new signature and subcommand routing**
Replace the body of `TestCommandRole` in `internal/cli/role_test.go` with:
```go
func TestCommandRole(t *testing.T) {
adminCmds := [][]string{
{"whitelist"}, {"config"}, {"audit"},
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
{"account", "list"},
}
for _, c := range adminCmds {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%v should be admin", c)
}
}
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%v should be agent", c)
}
}
}
```
Note: `init` is intentionally absent from this table. `commandRole({"init"})` falls through to the agent arm, but `Run` dispatches `init` via its own bootstrap path (which requires both keys), so its `commandRole` result is never used — asserting a role for it here would be both wrong and meaningless.
- [ ] **Step 2: Run the routing test to verify it fails to compile**
Run: `go test ./internal/cli/ -run TestCommandRole`
Expected: FAIL — compile error, `commandRole` takes `string`, called with `[]string`.
- [ ] **Step 3: Make `commandRole` subcommand-aware, update its call site, and fix the security invariant**
In `internal/cli/run.go`, replace:
```go
func commandRole(cmd string) store.Role {
switch cmd {
case "account", "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
```
with:
```go
func commandRole(args []string) store.Role {
switch args[0] {
case "account":
// account list is a read-only discovery view available to agents;
// add/edit/remove mutate config and require admin.
if len(args) >= 2 && args[1] == "list" {
return store.RoleAgent
}
return store.RoleAdmin
case "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
```
In `Run`, change the call site from `role := commandRole(cmd)` to `role := commandRole(args)`.
In `internal/cli/security_invariant_test.go`, in `TestAgentKeyCannotRunAdminCommands`, replace the `adminAttempts` entry `{"account", "list"}` so the set covers a *mutating* account command instead (account list is now allowed for agents):
```go
adminAttempts := [][]string{
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
```
- [ ] **Step 4: Run the cli package tests to verify routing + invariant pass**
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentKeyCannotRunAdminCommands'`
Expected: PASS (both). The agent can no longer be proven to refuse `account list` — that is intended; the invariant now proves `account add` is refused and the DB is unchanged.
- [ ] **Step 5: Add the rendering tests (agent JSON view + admin text view)**
Create `internal/cli/account_list_test.go`:
```go
package cli
import (
"encoding/json"
"strings"
"testing"
)
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
// name/from/can_send, and never the IMAP host or login username.
func TestAccountListAgentJSONView(t *testing.T) {
adminEnv(t) // both keys + initialized temp DB
run(t, "account", "add", "--name", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
run(t, "account", "add", "--name", "alerts", "--mode", "RO",
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
// Drop the admin key → caller is an agent.
t.Setenv("EMCLI_ADMIN_KEY", "")
code, out, errOut := run(t, "account", "list")
if code != 0 {
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
}
var env struct {
Error bool `json:"error"`
Data struct {
Accounts []struct {
Name string `json:"name"`
From string `json:"from"`
CanSend bool `json:"can_send"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
}
if env.Error || len(env.Data.Accounts) != 2 {
t.Fatalf("want 2 accounts and no error, got %+v", env)
}
// The reduced view must not leak the IMAP host or the login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("agent view leaked host/username:\n%s", out)
}
got := map[string]struct {
from string
canSend bool
}{}
for _, a := range env.Data.Accounts {
got[a.Name] = struct {
from string
canSend bool
}{a.From, a.CanSend}
}
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
}
// alerts has no --from → SendFrom() falls back to the username.
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
}
}
// With the admin key present, `account list` stays the full human-readable table.
func TestAccountListAdminTextView(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
code, out, _ := run(t, "account", "list")
if code != 0 {
t.Fatalf("admin account list failed: code=%d", code)
}
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
if !strings.Contains(out, want) {
t.Fatalf("admin view missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"accounts"`) {
t.Fatalf("admin view should be text, not JSON:\n%s", out)
}
}
```
- [ ] **Step 6: Run the rendering tests to verify the agent view fails**
Run: `go test ./internal/cli/ -run 'TestAccountListAgentJSONView|TestAccountListAdminTextView'`
Expected: `TestAccountListAdminTextView` PASS (already text); `TestAccountListAgentJSONView` FAIL — output is still the text table, so `json.Unmarshal` errors.
- [ ] **Step 7: Split the `list` branch by privilege in `runAccount`**
In `internal/cli/admin.go`, add the crypto import. Change the import block:
```go
import (
"flag"
"fmt"
"io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
```
Replace the `case "list":` block (currently):
```go
case "list":
accs, err := st.ListAccounts()
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
```
with:
```go
case "list":
// Holding the admin key means the caller is the human admin (full
// detail). An agent holds only EMCLI_KEY and gets a reduced JSON view.
_, adminErr := crypto.AdminKeyFromEnv()
isAdmin := adminErr == nil
accs, err := st.ListAccounts()
if err != nil {
if isAdmin {
fmt.Fprintf(errOut, "list: %v\n", err)
} else {
_ = Failure(CodeDB, err.Error()).Write(out)
}
return 1
}
if !isAdmin {
items := make([]map[string]any, 0, len(accs))
for _, a := range accs {
items = append(items, map[string]any{
"name": a.Name,
"from": a.SendFrom(),
"can_send": a.Mode == "RW",
})
}
_ = Success(map[string]any{"accounts": items}).Write(out)
return 0
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
```
- [ ] **Step 8: Run the full cli package test suite**
Run: `go test ./internal/cli/`
Expected: PASS (all tests, including the two new rendering tests, the routing test, and the security invariant).
- [ ] **Step 9: Run the whole module to confirm nothing else regressed**
Run: `go build ./... && go test ./...`
Expected: build clean; all packages PASS.
- [ ] **Step 10: Commit**
```bash
git add internal/cli/run.go internal/cli/admin.go internal/cli/role_test.go \
internal/cli/security_invariant_test.go internal/cli/account_list_test.go
git commit -m "feat(cli): agent-readable account list (reduced JSON view)
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a
JSON envelope of name/from/can_send, while the admin keeps the full text
table. account add/edit/remove stay admin-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Update user and agent documentation
**Files:**
- Modify: `USER-MANUAL.md` (command-kinds note, role table, cheatsheet)
- Modify: `skills/emcli/SKILL.md` (allowed-commands note, command table, do/don't)
- Modify: `skills/emcli/AGENTIC-MANUAL.md` (§4 account discovery)
**Interfaces:**
- Consumes: behavior shipped in Task 1 (agent `account list``{"data":{"accounts":[{name,from,can_send}]}}`).
- Produces: docs only; no code interface.
- [ ] **Step 1: USER-MANUAL — note that `account list` is the one agent-readable admin view**
In `USER-MANUAL.md`, in the "Two kinds of commands" block, change the Admin bullet (line ~36) from:
```
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
and are for *you*, the human. They print human-readable text or open an interactive form.
```
to:
```
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
```
- [ ] **Step 2: USER-MANUAL — update the role table**
Replace the role table rows (lines ~127-128):
```
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
```
with:
```
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
`can_send` — no host or login username.
```
- [ ] **Step 3: USER-MANUAL — annotate the cheatsheet**
In the cheatsheet (line ~597), change:
```
emcli account list # list accounts (no secrets)
```
to:
```
emcli account list # full table (admin) / name+from+can_send JSON (agent)
```
- [ ] **Step 4: SKILL.md — carve `account list` out of the forbidden-commands rule**
In `skills/emcli/SKILL.md`, change the first bullet (lines ~20-24):
```
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
with a privilege error.
```
to:
```
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
`init`. `emcli` will refuse those with a privilege error.
```
- [ ] **Step 5: SKILL.md — add `account list` to the command table**
In the command table (after the `send` row, line ~122), add:
```
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
```
- [ ] **Step 6: SKILL.md — fix the "don't" bullet**
Change (lines ~147-148):
```
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
```
to:
```
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
```
Also change the ✅ bullet (line ~145) from `Ask the user for the account name; keep bodies plain text.` to:
```
- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
```
- [ ] **Step 7: AGENTIC-MANUAL — document discovery via `account list`**
In `skills/emcli/AGENTIC-MANUAL.md`, replace the body of `## 4. Find the account(s)` (lines ~88-97):
```
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
configured accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Just take the account name from the user and start with the workflow in `SKILL.md`.
```
with:
```
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
with one entry per account:
```bash
emcli account list
# {"error":false,"error_detail":{},"data":{"accounts":[
# {"name":"gmail","from":"me@gmail.com","can_send":true},
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
```
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
(also an agent command) checks that accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Then start with the workflow in `SKILL.md`.
```
- [ ] **Step 8: Sanity-check the docs render and reference reality**
Run: `grep -n "account list" USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md`
Expected: each file shows the updated `account list` references; no remaining text claims the agent cannot run `account list`.
- [ ] **Step 9: Commit**
```bash
git add USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md
git commit -m "docs: agent can discover accounts via account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Notes for the implementer
- Run all Go commands from the repo root (`/home/steve/src/emcli`).
- The two intentional red states are Step 2 (compile error) and Step 6 (agent JSON test) in Task 1. Every other test run must be green.
- Do not change the admin text table or add columns to it — admin output must stay identical.
- `adminEnv(t)` and `run(t, ...)` live in `internal/cli/admin_test.go`; `b64Key`/`b64AgentKey` in `internal/cli/run_test.go`. No new helpers are needed.
@@ -0,0 +1,688 @@
# Send-as "From" Address Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let each account configure the email address used as the `From:` when sending, instead of always reusing the login username.
**Architecture:** Add a single freeform RFC 5322 `from_address` field to the account (bare address or `Display Name <addr>`). When blank, sending falls back to the login username — no migration of existing data. The header `From:` carries the full identity; the SMTP envelope sender is derived as the bare address. A version-gated `ALTER TABLE` migration adds the column to existing databases.
**Tech Stack:** Go, SQLite (`modernc.org/sqlite`), `github.com/emersion/go-message/mail` for MIME, `net/mail` (stdlib) for address validation, bubbletea TUI.
## Global Constraints
- Module path: `git.dcglab.co.uk/steve/emcli`.
- The `from_address` field is **not** a secret — store as plaintext (like `username`), never encrypted.
- A blank from-address is always valid and means "fall back to `Account.Username`".
- Follow existing patterns: `nullStr` for nullable text columns, `sql.NullString` in `scanAccount`, `fs.Visit` overlay for `account edit` flags.
- Tests are Go table/unit tests in the same package; reuse the existing `openTemp(t)` helper where keys are needed.
---
### Task 1: Store — field, migration, persistence
**Files:**
- Modify: `internal/store/account.go` (Account struct, AddAccount, GetAccount, ListAccounts, UpdateAccount, scanAccount)
- Modify: `internal/store/schema.go` (add column, bump schemaVersion)
- Modify: `internal/store/store.go` (run migration in Open)
- Modify: `internal/store/store_test.go` (update schema_version expectations to "2")
- Test: `internal/store/account_test.go` (SendFrom + round-trip), `internal/store/store_test.go` (migration)
**Interfaces:**
- Produces: `store.Account.FromAddress string` field; method `func (a Account) SendFrom() string`; schema at version 2 with `accounts.from_address TEXT` column.
- [ ] **Step 1: Write the failing test for SendFrom + round-trip**
Add to `internal/store/account_test.go`:
```go
func TestSendFromFallsBackToUsername(t *testing.T) {
a := Account{Username: "login@example.com"}
if got := a.SendFrom(); got != "login@example.com" {
t.Fatalf("blank from-address should fall back to username, got %q", got)
}
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if got := a.SendFrom(); got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("set from-address should win, got %q", got)
}
}
func TestAddGetAccountRoundTripsFromAddress(t *testing.T) {
s := openTemp(t)
a := sampleAccount()
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if _, err := s.AddAccount(a); err != nil {
t.Fatalf("AddAccount: %v", err)
}
got, err := s.GetAccount("work")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FromAddress not round-tripped: %q", got.FromAddress)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/store/ -run 'TestSendFrom|TestAddGetAccountRoundTripsFromAddress' -v`
Expected: FAIL — `a.SendFrom undefined` and `a.FromAddress undefined`.
- [ ] **Step 3: Add the field and SendFrom method**
In `internal/store/account.go`, add `FromAddress` to the struct (right after `Username`) and the method. The struct becomes:
```go
type Account struct {
ID int64
Name string
Mode string // RO | RW
IMAPHost string
IMAPPort int
IMAPSecurity string // tls | starttls
SMTPHost string // nullable for RO accounts
SMTPPort int
SMTPSecurity string // tls | starttls
AuthType string // password | oauth2
Username string
FromAddress string // send-as identity; blank ⇒ fall back to Username
Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool
WhitelistOutEnabled bool
SubjectRegex string
ProcessBacklog bool
}
// SendFrom returns the From identity for outgoing mail, falling back to the
// login username when no explicit from-address is configured.
func (a Account) SendFrom() string {
if a.FromAddress != "" {
return a.FromAddress
}
return a.Username
}
```
- [ ] **Step 4: Thread from_address through persistence**
In `internal/store/account.go`:
AddAccount — add `from_address` to the column list and a value placeholder. The INSERT becomes:
```go
res, err := s.db.Exec(`
INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username, nullStr(a.FromAddress),
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
```
GetAccount and ListAccounts — add `from_address` to both SELECT column lists, right after `username`:
```go
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
```
UpdateAccount — add `from_address=?` to the SET clause and its arg (after `username=?` / `a.Username`):
```go
set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
smtp_host=?, smtp_port=?, smtp_security=?,
auth_type=?, username=?, from_address=?,
whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
args := []any{
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username, nullStr(a.FromAddress),
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
}
```
scanAccount — add a `fromAddr sql.NullString` local, scan it after `&a.Username`, and assign. The var block gains `fromAddr sql.NullString`; the Scan call becomes:
```go
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&smtpHost, &smtpPort, &smtpSec,
&a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog)
```
and after the existing assignments add:
```go
a.FromAddress = fromAddr.String
```
- [ ] **Step 5: Add the column to the schema and bump the version**
In `internal/store/schema.go`, change `const schemaVersion = 1` to `const schemaVersion = 2`, and add the column to the `accounts` CREATE TABLE, right after the `username` line:
```sql
username TEXT NOT NULL,
from_address TEXT,
enc_password BLOB,
```
- [ ] **Step 6: Run the round-trip + SendFrom tests to verify they pass**
Run: `go test ./internal/store/ -run 'TestSendFrom|TestAddGetAccountRoundTripsFromAddress' -v`
Expected: PASS.
- [ ] **Step 7: Write the failing migration test**
The existing `TestOpenCreatesSchemaAndIsIdempotent` will now fail because it expects `schema_version == "1"`. Update both assertions in `internal/store/store_test.go` from `"1"` to `"2"`. Then add a new migration test in `internal/store/store_test.go`:
```go
func TestOpenMigratesV1AddsFromAddress(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
// Hand-build a v1 database: accounts table WITHOUT from_address, a settings
// table pinned at schema_version=1, and one pre-existing account row.
raw, err := sql.Open("sqlite", p)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
const v1Schema = `
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
mode TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_security TEXT NOT NULL,
smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
enc_password BLOB,
enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB,
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
subject_regex TEXT,
process_backlog INTEGER NOT NULL DEFAULT 0
);
INSERT INTO settings(key,value) VALUES ('schema_version','1');
INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username)
VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com');
`
if _, err := raw.Exec(v1Schema); err != nil {
t.Fatalf("seed v1 schema: %v", err)
}
raw.Close()
// Open via the store: the migration must add from_address and bump to v2.
s, err := Open(p)
if err != nil {
t.Fatalf("Open (migrate): %v", err)
}
defer s.Close()
if v, _ := s.GetSetting("schema_version"); v != "2" {
t.Fatalf("schema_version after migrate: %q, want 2", v)
}
// ListAccounts SELECTs from_address; it would error if the column were missing.
accs, err := s.ListAccounts()
if err != nil {
t.Fatalf("ListAccounts after migrate: %v", err)
}
if len(accs) != 1 || accs[0].FromAddress != "" {
t.Fatalf("legacy account wrong after migrate: %+v", accs)
}
if got := accs[0].SendFrom(); got != "login@example.com" {
t.Fatalf("legacy account should send from username, got %q", got)
}
}
```
Ensure `internal/store/store_test.go` imports `"database/sql"` (add it to the import block).
- [ ] **Step 8: Run the migration test to verify it fails**
Run: `go test ./internal/store/ -run 'TestOpenMigratesV1AddsFromAddress|TestOpenCreatesSchemaAndIsIdempotent' -v`
Expected: migration test FAILS with a "no such column: from_address" error from `ListAccounts` (the column is in the schema for new DBs but not added to the seeded v1 DB).
- [ ] **Step 9: Add the migration runner to Open**
In `internal/store/store.go`, replace the post-schema version block with a call to a new `migrate` method. Change the tail of `Open` from:
```go
s := &Store{db: db}
if _, err := s.GetSetting("schema_version"); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
db.Close()
return nil, err
}
}
return s, nil
```
to:
```go
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
```
and add the method:
```go
// migrate brings an existing database up to the current schemaVersion. A brand-
// new database (no schema_version yet) already has every column from schemaSQL,
// so it is simply stamped at the current version. Each older version runs its
// forward step. The version gate makes every step idempotent across reopens.
func (s *Store) migrate() error {
v, err := s.GetSetting("schema_version")
if err != nil {
// Fresh database: schemaSQL created all columns already.
return s.SetSetting("schema_version", strconv.Itoa(schemaVersion))
}
ver, _ := strconv.Atoi(v)
if ver < 2 {
if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil {
return fmt.Errorf("migrate to v2: %w", err)
}
if err := s.SetSetting("schema_version", "2"); err != nil {
return err
}
}
return nil
}
```
Confirm `internal/store/store.go` already imports `fmt` and `strconv` (it does); no import changes needed.
- [ ] **Step 10: Run the full store test suite to verify it passes**
Run: `go test ./internal/store/ -v`
Expected: PASS (migration, idempotency, round-trip, and existing tests all green).
- [ ] **Step 11: Commit**
```bash
git add internal/store/account.go internal/store/schema.go internal/store/store.go internal/store/account_test.go internal/store/store_test.go
git commit -m "feat(store): add account from_address field + v2 migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Mail — envelope sender vs header From
**Files:**
- Modify: `internal/mail/send.go` (add `envelopeFrom` helper, use it in `SendSMTP`)
- Test: `internal/mail/send_test.go` (envelopeFrom table test + BuildMIME display-name assertion)
**Interfaces:**
- Consumes: `OutgoingMessage.From` may now hold `Display Name <addr>`.
- Produces: `func envelopeFrom(from string) string` (package-private) — bare address for the SMTP envelope.
- [ ] **Step 1: Write the failing test for envelopeFrom and the display-name header**
Add to `internal/mail/send_test.go`:
```go
func TestEnvelopeFromStripsDisplayName(t *testing.T) {
cases := map[string]string{
"Steve Cliff <me@stevecliff.com>": "me@stevecliff.com",
"me@stevecliff.com": "me@stevecliff.com",
"<me@stevecliff.com>": "me@stevecliff.com",
"not a valid address": "not a valid address", // unparseable ⇒ passthrough
}
for in, want := range cases {
if got := envelopeFrom(in); got != want {
t.Fatalf("envelopeFrom(%q) = %q, want %q", in, got, want)
}
}
}
func TestBuildMIMEKeepsDisplayNameInHeader(t *testing.T) {
raw, err := BuildMIME(OutgoingMessage{
From: "Steve Cliff <me@stevecliff.com>",
To: []string{"you@example.com"},
Subject: "hi",
BodyText: "body",
Date: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("BuildMIME: %v", err)
}
if !strings.Contains(string(raw), "Steve Cliff") {
t.Fatalf("From header lost display name:\n%s", raw)
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `go test ./internal/mail/ -run 'TestEnvelopeFromStripsDisplayName|TestBuildMIMEKeepsDisplayNameInHeader' -v`
Expected: FAIL — `envelopeFrom` undefined. (The BuildMIME test may already pass, since `SetAddressList` renders display names; the envelopeFrom test is the gating failure.)
- [ ] **Step 3: Add the envelopeFrom helper and use it in SendSMTP**
In `internal/mail/send.go`, add the helper (near `addrList`):
```go
// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
// any display name. A display-name From (e.g. "Name <addr>") is a valid header
// but an invalid envelope sender, so it must be reduced to the bare address.
// Unparseable input is passed through unchanged (preserves prior behaviour for
// plain addresses).
func envelopeFrom(from string) string {
if a, err := gomail.ParseAddress(from); err == nil {
return a.Address
}
return from
}
```
In `SendSMTP`, change the send line from:
```go
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
```
to:
```go
if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `go test ./internal/mail/ -run 'TestEnvelopeFromStripsDisplayName|TestBuildMIMEKeepsDisplayNameInHeader' -v`
Expected: PASS.
- [ ] **Step 5: Run the full mail suite**
Run: `go test ./internal/mail/`
Expected: PASS (`imap_integration_test` may skip without a live server — that is fine).
- [ ] **Step 6: Commit**
```bash
git add internal/mail/send.go internal/mail/send_test.go
git commit -m "feat(mail): derive bare envelope sender from display-name From
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: CLI + TUI — inputs, validation, and send wiring
**Files:**
- Modify: `internal/cli/send.go:26` (use `acc.SendFrom()`)
- Modify: `internal/cli/admin.go` (`--from` flag on `account add` and `account edit`)
- Modify: `internal/tui/account.go` (Fields field, fieldDef, ToAccount, FieldsFromAccount, fieldValue, collect, validation helper, Validate)
- Test: `internal/tui/account_test.go` (validation + round-trip), `internal/cli/send_test.go` (send uses configured from)
**Interfaces:**
- Consumes: `store.Account.FromAddress`, `store.Account.SendFrom()` (Task 1).
- Produces: `func ValidFromAddress(s string) error` exported from `tui` package, used by both `Fields.Validate` and `internal/cli/admin.go`.
- [ ] **Step 1: Write the failing TUI validation + round-trip tests**
Add to `internal/tui/account_test.go`:
```go
func TestValidateRejectsBadFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "not an address"
if err := f.Validate(); err == nil {
t.Fatal("malformed from-address should fail validation")
}
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
if err := f.Validate(); err != nil {
t.Fatalf("display-name from-address should validate: %v", err)
}
f.FromAddress = "me@stevecliff.com"
if err := f.Validate(); err != nil {
t.Fatalf("bare from-address should validate: %v", err)
}
f.FromAddress = "" // blank ⇒ fall back, always valid
if err := f.Validate(); err != nil {
t.Fatalf("blank from-address should validate: %v", err)
}
}
func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
acc, _ := f.ToAccount()
if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
}
back := FieldsFromAccount(acc)
if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `go test ./internal/tui/ -run 'TestValidateRejectsBadFromAddress|TestFieldsFromToAccountCarriesFromAddress' -v`
Expected: FAIL — `f.FromAddress` undefined.
- [ ] **Step 3: Add the field, validation helper, and wiring in tui/account.go**
In `internal/tui/account.go`:
Add `"net/mail"` to the import block.
Add `FromAddress` to `Fields` (after the `Username, Password` line):
```go
type Fields struct {
Name, Mode string
IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool
SubjectRegex string
}
```
Add the exported validator:
```go
// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
// address (bare or "Display Name <addr>"). A blank value is valid: sending
// falls back to the login username.
func ValidFromAddress(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
if _, err := mail.ParseAddress(s); err != nil {
return errors.New("from address must be a valid email address")
}
return nil
}
```
In `Fields.Validate`, add before the final `return nil`:
```go
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
```
In `ToAccount`, set the field on the assembled account (add to the struct literal, after `Username/Password`):
```go
AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
```
In `FieldsFromAccount`, prefill it (after `Username: a.Username,`):
```go
Username: a.Username,
FromAddress: a.FromAddress,
```
Add a `fieldDef` to `fieldDefs`, immediately after the `username` entry (so it appears next to it in the form):
```go
{key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true},
```
- [ ] **Step 4: Wire from_address through fieldValue and collect**
In `internal/tui/account.go`, find `fieldValue` (≈ line 147) and add a `case "from_address": return f.FromAddress` alongside the other string cases. Find `collect` (≈ line 228) and add the inverse mapping so the typed value is written back to `f.FromAddress` (mirror exactly how `username` is handled in that function's switch).
- [ ] **Step 5: Run the tui tests to verify they pass**
Run: `go test ./internal/tui/ -v`
Expected: PASS (new validation/round-trip tests plus existing form tests).
- [ ] **Step 6: Write the failing CLI send test**
The harness in `internal/cli/send_test.go` records every sent message into `*sent`, so assert directly on `m.From`. Add:
```go
func TestSendUsesConfiguredFromAddress(t *testing.T) {
acc := rwAccount()
acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
d, sent, _ := sendDeps(t, acc, nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
if got := (*sent)[0].From; got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("From = %q, want configured from-address", got)
}
}
func TestSendFallsBackToUsernameAsFrom(t *testing.T) {
// rwAccount has no FromAddress, so From must be the login username.
d, sent, _ := sendDeps(t, rwAccount(), nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if got := (*sent)[0].From; got != "emcli@stevecliff.com" {
t.Fatalf("From = %q, want username fallback", got)
}
}
```
- [ ] **Step 7: Run the CLI send test to verify it fails**
Run: `go test ./internal/cli/ -run 'TestSendUsesConfiguredFromAddress' -v`
Expected: FAIL — `send.go` still sets `From: acc.Username`.
- [ ] **Step 8: Wire send.go and add the --from flags**
In `internal/cli/send.go`, change:
```go
msg := mail.OutgoingMessage{
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
```
to:
```go
msg := mail.OutgoingMessage{
From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
```
In `internal/cli/admin.go`, `account add`: register the flag and validate it.
Add alongside the other `add` flags:
```go
from := fs.String("from", "", "send-as address (blank = use username)")
```
After the required-fields check, before building `acc`:
```go
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
```
Add `FromAddress: *from,` to the `store.Account{...}` literal.
In `account edit`: register the flag:
```go
from := fs.String("from", "", "send-as address (blank keeps existing)")
```
Add a case to the `fs.Visit` switch:
```go
case "from":
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return // see note below
}
acc.FromAddress = *from
```
Because `fs.Visit`'s callback cannot return an exit code, instead validate `--from` before the `fs.Visit` block (the flag value is available regardless of Visit) and set the field inside Visit:
```go
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
// ... existing GetAccount + fs.Visit ...
case "from":
acc.FromAddress = *from
```
Use this pre-Visit validation form (not the in-callback `return`).
- [ ] **Step 9: Run the CLI suite to verify it passes**
Run: `go test ./internal/cli/ -v`
Expected: PASS.
- [ ] **Step 10: Build and vet the whole module**
Run: `go build ./... && go vet ./... && go test ./...`
Expected: clean build, no vet complaints, all tests PASS.
- [ ] **Step 11: Commit**
```bash
git add internal/cli/send.go internal/cli/admin.go internal/cli/send_test.go internal/tui/account.go internal/tui/account_test.go
git commit -m "feat(cli): configurable send-as From address (flags, TUI, validation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Notes for the implementer
- The `account list` output (`admin.go`, `case "list"`) shows NAME/MODE/IMAP/USER. Adding a FROM column is optional polish, not required — leave it unless asked.
- `USER-MANUAL.md` / `README.md` mention `account add` flags; if they enumerate flags explicitly, add `--from` there in the relevant commit. Grep first: `grep -rn 'account add\|--username' README.md USER-MANUAL.md docs/`.
- Existing send tests in `internal/cli/send_test.go` define the harness shape — read them before writing Task 3 Step 6 rather than inventing a new fake.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
# Two-key privilege separation — design
**Date:** 2026-06-22
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
Today `EMCLI_KEY` does double duty: it is both the AES-256 key that decrypts the
stored mail-account passwords *and* the only gate for every command. The agent
process is launched with `EMCLI_KEY` so it can read and send mail, but that same
key authorizes the admin commands (`account`, `whitelist`, `config`, `audit`,
`init`) too. SPEC §4's "trust boundary" — *the agent invokes only agent
commands* — is convention, not enforcement. A prompt-injected / "forced" agent
holding `EMCLI_KEY` can run `account add`, edit a whitelist, or flip `config`,
dismantling the very guardrails emcli exists to enforce.
## Goal
Split privilege into two environment keys so that the boundary is *enforced*,
not merely conventional:
- `EMCLI_ADMIN_KEY` — authorizes **all** commands.
- `EMCLI_KEY` — authorizes **agent** (non-admin) commands only.
The agent's launch environment is given **only** `EMCLI_KEY`. Because the admin
secret is simply absent from that environment, no instruction can make the agent
perform an admin action — the binary refuses, and there is no key present that
could authorize it.
## Constraints / decisions
These were settled during brainstorming:
1. **Two distinct env vars** (`EMCLI_ADMIN_KEY`, `EMCLI_KEY`) — role is named and
readable in configs/skills, not inferred from a single value.
2. **Both keys must be able to decrypt account passwords.** The agent decrypts to
talk to IMAP/SMTP; admin's `account add` / `doctor` need it too. So the keys
differ in *authorization*, not in crypto capability. This forces envelope
encryption (one data key, wrapped per role).
3. **Agent-key commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`.
**Admin-only:** `account`, `whitelist`, `config`, `audit`, `init`.
4. **No migration, no schema-version gate.** No third party uses this DB and the
data is being scrapped anyway. `init` writes the new wrap slots into a fresh
DB; existing DBs are simply re-created by re-running `init`.
## Approach: envelope encryption (one DEK, wrapped per role)
### Key model
- `EMCLI_ADMIN_KEY` and `EMCLI_KEY` are base64 32-byte **key-encryption keys
(KEKs)**. Neither directly encrypts account secrets.
- `emcli init` generates a random 32-byte **data-encryption key (DEK)**. All
account secrets (`enc_password`, `enc_oauth_client_id`,
`enc_oauth_client_secret`, `enc_oauth_refresh_token`) are sealed under the DEK,
exactly as they are sealed under the raw key today.
- The DEK is stored in the `settings` table, sealed twice:
- `dek_wrap_admin = Seal(adminKey, DEK)`
- `dek_wrap_agent = Seal(agentKey, DEK)`
- The DEK never touches disk in cleartext.
### Why this enforces the boundary
The store already isolates all secret crypto behind one `s.key` field
(`account.go` calls `crypto.Seal(s.key, …)` / `crypto.Open(s.key, …)`). In the
new model **`s.key` simply becomes the DEK** — `account.go`, `send.go`, etc. do
not change. The entire change lives in *how the DEK is obtained*:
- **Admin command** → require `EMCLI_ADMIN_KEY`; unwrap the DEK from
`dek_wrap_admin` **only**. If the var is unset or fails to unwrap → hard error,
**with no fallback to the agent slot.** This is the enforcement linchpin.
- **Agent command** → prefer `EMCLI_KEY`, unwrap from `dek_wrap_agent`. If
`EMCLI_KEY` is unset but `EMCLI_ADMIN_KEY` is present, fall back to the admin
slot (admin is a superset; a human holding only the admin key can still run
`list` / `send`).
The agent process holds `EMCLI_KEY` only. Admin commands refuse to unwrap from
anything but `dek_wrap_admin`, which `EMCLI_KEY` cannot open. The agent cannot
hold a secret that authorizes config changes — it is absent from its
environment, not merely gated by a flag.
## Components
### `internal/crypto`
- Generalize `KeyFromEnv` to read a named variable; add `AdminKeyFromEnv()` and
`AgentKeyFromEnv()` thin wrappers.
- DEK wrap/unwrap **reuses** the existing `Seal` / `Open` — no new primitive.
- A helper to generate a random 32-byte DEK.
### `internal/store`
Split *unlock* from *open* so the DEK can be read from a wrap slot after the DB
is open (settings rows are plaintext, so no key is needed to read them):
- `store.Open(path) (*Store, error)` — opens/creates the DB, applies the schema,
key still **locked** (`s.key == nil`).
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error` — reads the slot
for the role, unwraps the DEK, sets `s.key`. Missing slot or wrong key → clear
error.
- `(*Store).InitKeys(adminKey, agentKey []byte) error` — generates a random DEK,
seals it under both KEKs, writes `dek_wrap_admin` + `dek_wrap_agent`, sets
`s.key`.
No table changes; two new rows in the existing `settings` table.
`account.go` / mail crypto is untouched (still `crypto.Seal(s.key, …)`).
### `internal/cli`
- One `commandRole(cmd string) Role` function in `run.go` — the single source of
truth for the classification table above.
- `openStore` gains a `role` parameter and performs slot selection via
`Open` + `Unlock`.
- Each `run*` helper passes its role. `init` uses the bootstrap path
(`Open` + `InitKeys`, or `Unlock(admin)` if already initialized).
### Command classification
| Command | Role |
|-------------------------------------------|-----------|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent |
| `account`, `whitelist`, `config`, `audit` | admin |
| `init` | bootstrap (needs **both** keys) |
| `help` / no args | none (no DB access) |
## `init` & key generation UX
`init` requires **both** keys present (it writes both wrap slots). If either is
missing it errors with what's needed plus the generation hint.
Idempotency: if the wrap slots already exist, `init` does **not** regenerate the
DEK (that would orphan existing sealed passwords) — it unlocks via the admin slot
and keeps today's "already initialized; adding another account" behavior. Fresh
DB → generate DEK, then add the first account.
Documented flow (README + USER-MANUAL):
```bash
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # human keeps this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # agent launcher gets only this
emcli init
```
The agent's orchestrator is configured with **only** `EMCLI_KEY`.
## Error handling
- Agent key on an admin command → exit non-zero, stderr:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
(Admin commands print human-readable output, not JSON.)
- Admin command, neither key set → `EMCLI_ADMIN_KEY is not set`.
- Agent command, neither key set → existing `EMCLI_KEY is not set` JSON envelope
(`CodeConfig`).
- Key present but does not unwrap its slot → `wrong key for this DB`, not a raw
GCM auth-tag error.
## Testing
- **crypto:** wrap/unwrap round-trip; an admin-sealed DEK is *not* openable with
the agent key.
- **store:** after `InitKeys`, both `Unlock(admin)` and `Unlock(agent)` recover
the *same* DEK and decrypt an account password; `Unlock` with a wrong key fails
cleanly.
- **routing:** table test for `commandRole`; agent key on an admin command →
refused, non-zero, correct message; agent command works with the agent key;
agent command works with the admin key (superset fallback); `init` refused
unless both keys are set.
- **Headline security invariant:** initialize a DB, then with **only `EMCLI_KEY`**
set, attempt every admin command (`account add/list`, `whitelist …`,
`config set`, `audit`) and assert each is refused *and the DB is byte-for-byte
unchanged*. This is the test that proves the bug is fixed.
- **Existing-test fallout:** the `adminEnv` / `b64Key` helpers in `admin_test.go`
(and `run_test.go`) set `EMCLI_KEY` against a raw-key store — update them to
provision both wrap slots via `InitKeys`. Audit all `t.Setenv("EMCLI_KEY", …)`
sites.
## Documentation updates
- README "Getting started" — two-key generation flow.
- USER-MANUAL — key model, role/command table, agent-launcher configuration
(only `EMCLI_KEY`), updated `init`.
- `skills/emcli` SKILL.md / AGENTIC-MANUAL.md — the agent is given only
`EMCLI_KEY`; admin commands are not available to it.
- SPEC §4 (trust boundary) and §5 (secrets) — describe enforced two-key model.
## Out of scope (possible follow-ups)
- Key rotation commands (re-wrap the DEK under a new admin or agent key without
re-encrypting passwords — the envelope design supports this cheaply, but it is
not built now).
- A distinct read-only vs read-write *agent* tier (orthogonal; per-account
RO/RW already exists).
@@ -0,0 +1,141 @@
# Agent-readable `account list` — design
**Date:** 2026-06-23
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
An agent process is launched with only `EMCLI_KEY` (the two-key model — see
`2026-06-22-two-key-privilege-design.md`). Every agent command takes
`--account NAME`, but the agent has no way to *discover* which accounts exist:
`account list` is classified admin-only and is refused under the agent key. So
account names must be supplied to the agent out of band, which is brittle and
defeats the point of a self-directed agent.
The two-key spec deliberately gated the whole `account` command to admin because
`account add/edit/remove` mutate configuration. But `account list` is read-only
and exposes no secrets — `store.ListAccounts` never decrypts the password
(`enc_password` is scanned and discarded). Gating *discovery* behind admin is
stricter than the threat model requires.
## Goal
Let an agent holding only `EMCLI_KEY` run `account list` to discover the
accounts it may operate on, while:
- keeping `account add/edit/remove` admin-only (mutation stays gated);
- exposing to the agent only what it needs — **account name, the send-as From
address, and whether the account can send** — and *not* the IMAP host/port or
login username;
- preserving the admin's existing full-detail view unchanged.
## Constraints / decisions
Settled during brainstorming:
1. **Scope is exactly `account list`.** `whitelist list`, `config get`, and
`audit list` stay admin-only. `audit` in particular is oversight data and
must remain invisible to the agent.
2. **Privilege-dependent rendering.** The admin keeps the current full table
(`NAME MODE IMAP USER`, human-readable). The agent gets a *reduced* view
containing only name, From, and send-capability.
3. **Agent output is JSON.** The agent is a machine consumer, so its
`account list` emits the standard agent envelope (like `list`/`get`/`search`),
not a text table. The admin path stays human-readable text.
4. **No secret exposure, no schema change.** `ListAccounts` already avoids
decrypting passwords; nothing about the data model changes.
## Approach
Reclassify `account list` to the agent role, and branch rendering on the
caller's actual privilege (presence of the admin key).
### Routing (`internal/cli/run.go`)
`commandRole` becomes subcommand-aware for `account`:
- `account list``RoleAgent`
- `account add | edit | remove` (and bare `account`) → `RoleAdmin`
- all other commands unchanged.
`commandRole` currently takes `cmd string`; it changes to take the full
`args []string` so it can peek at the `account` subcommand. `Run` passes
`args` through. This keeps `commandRole` the single source of truth for the
classification table.
Authorization mechanics are unchanged: `openStore(RoleAgent)` requires
`EMCLI_KEY` (falling back to the admin key for a human who holds only that and
runs `account list`). `account add/edit/remove` still hard-require
`EMCLI_ADMIN_KEY` with no fallback.
### Rendering (`internal/cli/admin.go`, the `list` branch)
Determine privilege from the environment: `_, err := crypto.AdminKeyFromEnv()`;
`isAdmin := err == nil`. (Holding the admin key *is* being the admin in this
trust model. A human with only the admin key still gets the admin view; an agent
with only `EMCLI_KEY` gets the reduced view.)
- **Admin** → existing full table, unchanged:
```
NAME MODE IMAP USER
work RW imap.example.com:993 me@example.com
```
- **Agent** → JSON envelope to stdout:
```json
{"error":false,"error_detail":{},"data":{"accounts":[
{"name":"work","from":"me@example.com","can_send":true},
{"name":"alerts","from":"alerts@example.com","can_send":false}
]}}
```
where `from = Account.SendFrom()` (the configured From address, falling back
to the username) and `can_send = (Mode == "RW")` (RW accounts have SMTP
configured; RO cannot send). The IMAP host/port and the raw login username are
**not** emitted (when From falls back to the username it may coincide with it,
which is acceptable — the user asked for From specifically).
The reduced view reuses the existing `Success(...)` envelope and `Envelope.Write`
helper; no new output machinery.
## Error handling
- Agent key on `account add/edit/remove` → unchanged:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`, non-zero.
- Agent `account list` with neither key set → the agent-command config error
(`EMCLI_KEY is not set`), surfaced the same way other agent commands surface a
missing key. Because the agent path now emits JSON, a missing-key failure on
`account list` is a JSON `Failure(CodeConfig, …)` envelope, consistent with the
other agent commands.
- DB/list errors on the agent path → `Failure(CodeDB, …)` envelope; on the admin
path → existing `list: <err>` text to stderr.
## Testing
- **Routing:** extend the `commandRole` table test — `account list` → agent;
`account add` / `account edit` / `account remove` → admin.
- **Agent view:** with only `EMCLI_KEY`, `account list` exits 0, emits a valid
envelope, and the `data.accounts` entries carry `name`/`from`/`can_send` — and
the output does **not** contain the IMAP host or the login username.
- **Admin view:** with `EMCLI_ADMIN_KEY`, `account list` still prints the full
`NAME MODE IMAP USER` table (regression guard).
- **`can_send`:** an RW account yields `can_send:true`, an RO account
`can_send:false`; `from` reflects `SendFrom()` (explicit From, else username).
- **Security invariant (`security_invariant_test.go`):** remove
`{"account","list"}` from the refused-commands set (it is now allowed) and
replace it with a *mutating* `account add …` attempt, so the "forced agent
cannot run admin commands and the DB is byte-for-byte unchanged" invariant
still covers the `account` family.
## Documentation updates
- **USER-MANUAL:** role/command table — `account list` is agent-readable
(reduced JSON view); `account add/edit/remove` remain admin.
- **`skills/emcli` (SKILL.md / AGENTIC-MANUAL.md):** document that the agent
discovers accounts via `account list`, including the JSON shape
(`name`, `from`, `can_send`).
## Out of scope
- Agent access to `whitelist list`, `config get`, or `audit list`.
- Any change to the admin `account list` columns or to the data model.
- JSON output for the admin `account list` path (stays human-readable text).
@@ -0,0 +1,118 @@
# Send-as "From" address — design
**Date:** 2026-06-23
**Status:** Approved (pending spec review)
## Problem
An account's configuration has no field for the email address used as the
`From:` when sending mail. Today the From is silently aliased to the login
username (`internal/cli/send.go:26`, `From: acc.Username`), and neither
`store.Account` nor the `accounts` table has any `from`/`address`/`email`
column.
This works only when the login username is exactly the desired send-as
address. It breaks for:
- providers where the login is an account ID rather than an email,
- sending from an **alias** of the mailbox,
- wanting a **display name** (`Steve Cliff <me@…>`) rather than a bare address,
- Gmail App Passwords where envelope-from and header-from may differ.
## Decisions
- **Field shape:** a single freeform RFC 5322 From identity — bare
(`me@stevecliff.com`) or with a display name
(`Steve Cliff <me@stevecliff.com>`). One field, not a split
address/display-name pair.
- **Fallback:** when the from-address is blank, fall back to
`Account.Username` (current behaviour). No migration/backfill of existing
accounts required; they keep working unchanged.
## Design
### 1. Data model
Add `FromAddress string` to `store.Account` and a `from_address TEXT` column to
the `accounts` table. **Not encrypted** — it is not a secret (it appears in
every outgoing header), so it is stored as plaintext like `username`.
### 2. Fallback in one place
Add a method so the fallback rule lives in exactly one spot:
```go
// SendFrom returns the From identity for outgoing mail, falling back to the
// login username when no explicit from-address is configured.
func (a Account) SendFrom() string {
if a.FromAddress != "" {
return a.FromAddress
}
return a.Username
}
```
`internal/cli/send.go` changes `From: acc.Username``From: acc.SendFrom()`.
### 3. Envelope sender vs header From
A display-name From breaks the SMTP envelope: `c.SendMail("Steve <me@…>", …)`
is invalid — the envelope sender must be the **bare** address.
- `mail.BuildMIME` keeps using the full `m.From` for the `From:` header
(go-message renders `Name <addr>` correctly via `SetAddressList`).
- `mail.SendSMTP` derives the envelope sender as the bare address:
`gomail.ParseAddress(m.From).Address`. If parsing fails, fall back to the raw
`m.From` (preserves today's behaviour for plain addresses).
Header carries the display name; envelope carries the bare address.
### 4. Migration
The schema is v1, applied via `CREATE TABLE … IF NOT EXISTS`, which will not add
a column to an existing DB, and there is no migration runner yet.
- Add the `from_address` column to the `CREATE TABLE accounts` statement (new
DBs get it directly).
- In `store.Open`, after applying the schema, run a version-gated migration: if
the stored `schema_version` is `< 2`, execute
`ALTER TABLE accounts ADD COLUMN from_address TEXT` and set `schema_version`
to `2`.
- Bump the `schemaVersion` constant to `2`.
SQLite `ALTER TABLE … ADD COLUMN` is cheap and safe. The migration is
idempotent under the version gate.
### 5. Inputs & validation
- **CLI:** add a `--from` flag to `account add` and `account edit`. On `edit`,
follow the existing `fs.Visit` overlay pattern (only set when the flag was
passed).
- **TUI:** add a `FromAddress` field to `tui.Fields`, a `fieldDef`
(`{key: "from_address", label: "From address (optional)"}`), and wire it
through `ToAccount`, `FieldsFromAccount`, `fieldValue`, and `collect`.
- **Validation:** when the from-address is non-empty, reject it unless
`gomail.ParseAddress` accepts it (covers bare and display-name forms). A
blank from-address is always valid (→ fallback). Applied in
`Fields.Validate` and on the `account add`/`edit` flag path.
- **Persistence:** thread `from_address` through `AddAccount`, `UpdateAccount`,
`scanAccount`, and the three `SELECT` column lists in
`internal/store/account.go`. Stored via `nullStr` (blank → NULL).
### 6. Tests
- `SendFrom()`: returns the from-address when set; returns username when blank.
- `SendSMTP` envelope: when From is `Name <addr>`, the envelope sender passed to
the server is the bare `addr` (table test on the extraction helper).
- Migration: open a v1 DB with no `from_address` column → column is added,
`schema_version` becomes 2, and an existing account still sends from its
username.
- `Fields.Validate`: rejects a malformed from-address; accepts bare and
display-name forms; accepts blank.
- Round-trip: `FieldsFromAccount` then `ToAccount` preserves `FromAddress`.
## Out of scope (YAGNI)
- Separate envelope-from override field (derive it from From instead).
- Per-message From override at send time.
- Multiple aliases per account.
@@ -0,0 +1,201 @@
# emcli — Human-facing CLI grammar redesign
Date: 2026-06-27
Status: Approved (design)
## Problem
`emcli` was designed flag-first for AI agents — explicit, stable, machine-parseable. The
human-facing **admin** commands inherited that flag-heavy grammar, and the result is inconsistent
and error-prone for a person at a terminal. The "main noun" of each command is sometimes
positional, sometimes a flag:
| Command (today) | Names its target via |
|---|---|
| `config set audit_retention_days 90` | positional key + value |
| `account remove --name bobby --yes` | flag |
| `whitelist in add --account bobby --address x@y` | flags (×2) |
| `audit list --account bobby` | flag |
A human has no rule to remember. The concrete failure that triggered this work:
```
emcli whitelist in add --account bobby "Tk555@protonmail.com"
```
Go's `flag` parser read `--account bobby`, treated `"Tk555@protonmail.com"` as an ignored leftover
positional, and `--address` defaulted to `""`. `AddWhitelist` then ran
`INSERT OR IGNORE ... VALUES(id, "")` — silently inserting a blank whitelist row. No error, no
validation.
## Goals
- One consistent, predictable grammar for all human-facing (admin) commands.
- Eliminate the silently-ignored-input and empty-operand bug classes.
- Improve discoverability (`config list`, `account show`) and onboarding (whitelists fully
decoupled from account creation).
- Keep the agent JSON command surface (`list`/`get`/`search`/`ack`/`send`) **frozen**.
## Non-goals
- No change to agent JSON commands' flags, output, or behavior.
- No change to the encryption/key model, store schema semantics, or policy engine logic.
- No unrelated refactoring.
## Unifying grammar
> **`emcli <group> <verb> <operands…> [--flags]`**
>
> Primary targets — account name, address(es), config key/value — are **positional**.
> Flags carry only options/modifiers (`--in`/`--out`, `--yes`, `--limit`, account field values
> like `--imap-host`).
Two cross-cutting safety rules enforced everywhere:
1. **No silently-ignored input.** Any leftover positional argument beyond what a command expects
is an error (`unexpected argument "x"`). This alone prevents the triggering bug.
2. **No empty primary operands.** Empty account / address / key is rejected with a clear message.
### Verb aliases
Applied at the verb position for every group (and the top-level command position, additively):
- `remove` = `rm` = `del`
- `list` = `ls`
Implemented as a single `normalizeVerb(string) string` map applied before the verb `switch`.
## Command surface (after)
### account
| Command | Notes |
|---|---|
| `account add [name] [--imap-host … --username … …]` | `name` positional. No flags → interactive TUI form (unchanged). **`--whitelist-in`/`--whitelist-out` removed** (see Whitelist decoupling). `--process-backlog` kept. |
| `account edit <name> [--flags]` | `name` positional; only `name` → interactive form prefilled. **`--whitelist-in`/`--whitelist-out` removed.** |
| `account remove <name> [--yes]` | `name` positional. On a TTY, prompt `Remove account "bobby"? [y/N]`; non-TTY (piped) requires `--yes`. Aliases: `rm`, `del`. |
| `account show <name>` | **New.** Human-readable detail: mode (RO/RW), IMAP host/port/security, SMTP host/port/security, send-from address, subject regex, inbound/outbound whitelist enabled state. Never prints the password. |
| `account list` | Unchanged table. Alias: `ls`. |
### whitelist
Direction is a **required** `--in` / `--out` flag (error if neither given — no wrong-list default).
Whitelist management is fully self-contained in this group (enable/disable moved here from
`account edit`).
| Command | Notes |
|---|---|
| `whitelist add <account> <addr…> --in\|--out` | One or more addresses, each validated (see Address validation). Aliases for verb: n/a. |
| `whitelist remove <account> <addr…> --in\|--out` | One or more addresses. Aliases: `rm`, `del`. |
| `whitelist list <account> --in\|--out` | Header line shows `ENABLED`/`DISABLED`. Alias: `ls`. |
| `whitelist enable <account> --in\|--out` | **New.** If the list is empty, print a warning: `warning: inbound whitelist for "bobby" is empty — this blocks ALL inbound mail until you add addresses`. Still enables (explicit user action). |
| `whitelist disable <account> --in\|--out` | **New.** |
### config
A known-key **settings registry** backs validation and discovery. Each entry: `key`,
`description`, and a `validate(value) error`.
| Command | Notes |
|---|---|
| `config list` | **New.** Table: `KEY VALUE DESCRIPTION`. Lists every registered key, its current value (or `(unset)`), and description. Alias: `ls`. |
| `config get <key>` | Rejects unknown key. |
| `config set <key> <value>` | Rejects unknown key (`unknown setting "foo" (see: emcli config list)`); runs the key's validator. |
Initial registry: `audit_retention_days` (description "Days to keep audit-log entries"; validator:
integer ≥ 0 — same rule as today).
### audit
| Command | Notes |
|---|---|
| `audit list [account] [--limit N]` | `account` positional (optional; omitted = all accounts). `--limit` stays a flag (it's a modifier). Alias: `ls`. |
### doctor (exception — dual-use)
`doctor` is agent-runnable (`RoleAgent`) and the agentic manual documents `emcli doctor --account
gmail`. To avoid breaking documented agent usage while still giving humans the positional form, it
accepts **both**:
| Command | Notes |
|---|---|
| `doctor [account]` | Positional account (human shorthand). |
| `doctor --account <name>` | Retained for agent/script compatibility. If both a positional and `--account` are given and they differ, that's an error. |
### Frozen (untouched)
`list`, `get`, `search`, `ack`, `send``--account`/`--folder`/`--uid…` flags, JSON envelope
output. **Intentional split**, documented in help: agent commands are flag-driven for parse
stability; human admin commands are positional. `init`, `version`, `help` unchanged (except `init`
no longer references whitelists).
## Whitelist decoupling from onboarding
Verified against the code: `WhitelistInEnabled`/`WhitelistOutEnabled` are stored per-account
booleans, default `false`, read fresh on every command via `Deps.setup` (which rebuilds
`policy.InboundRule` each call) and `SendCmd`. `policy.InboundRule.Allows` / `OutboundRule.Check`
only consult them at evaluation time. Nothing in account creation, `init`, or the policy engine
needs them set at creation time, and toggling them later takes effect on the next command.
Therefore: remove `--whitelist-in` / `--whitelist-out` from `account add`, from `init`'s flow, and
from the interactive TUI account form (the `Whitelist inbound`/`Whitelist outbound` toggles in
`internal/tui/account.go`) entirely — leaving them in the form would contradict the decoupling and
re-expose the empty-enabled-whitelist footgun. Accounts are created with whitelists off; the user
sets them up afterward:
```
emcli whitelist add bobby alice@example.com bob@example.com --in
emcli whitelist enable bobby --in
```
Default (whitelist off) is the existing permissive default; RO/RW mode and subject regex still
apply regardless.
## Address validation
`whitelist add` / `whitelist remove` validate each address argument before touching the store.
Accepted shapes:
- A full address: `local@domain` (non-empty local part, a domain with at least one dot).
- A domain wildcard: `@domain` (leading `@`, domain with at least one dot).
Anything else (empty, no `@`, no domain) is rejected: `invalid address "x": expected
local@domain or @domain`. Validation lives in a small reusable helper so `add` and `remove` share
it. (Addresses are still lowercased on store as today.)
## Error & output conventions (unchanged where already good)
- Usage/errors → stderr; successful human output → stdout.
- Exit codes: `0` success, `1` runtime error, `2` usage error (preserved).
- Admin commands remain human-readable (never JSON). `account list` for an agent (agent key only)
still emits the reduced JSON view — unchanged.
## Implementation surface
- `internal/cli/admin.go` — rewrite arg parsing for `account`, `whitelist`, `config`, `audit` to
the positional grammar; add `account show`, `whitelist enable/disable`, `config list`; add the
leftover-positional and empty-operand guards; wire `normalizeVerb`.
- `internal/cli/run.go``doctor` accepts positional + `--account`; routing unchanged otherwise.
- `internal/cli/interactive.go` (`runInit`) — drop whitelist references.
- `internal/tui/account.go` — remove the `WhitelistIn`/`WhitelistOut` form fields, their rows,
and their render/parse/`ToAccount`/`FieldsFromAccount` handling (new accounts are created with
whitelists off; managed via the `whitelist` group thereafter).
- `internal/cli/help.go` — rewrite admin synopses to the new grammar; document the
agent-vs-human positional/flag split; list aliases.
- New: settings registry (small map + validators) — location `internal/cli` (e.g.
`config_registry.go`) unless it needs store access, in which case `internal/store`.
- New: address validator helper (e.g. `internal/cli` or `internal/policy`, near existing address
matching).
- `internal/store` — add `SetWhitelistEnabled(account, dir, bool)` (or equivalent) if not already
expressible via existing account update; `account show` reads existing getters.
- TTY detection helper for `account remove` confirmation (e.g. `term.IsTerminal` on stdin fd).
- Docs: `USER-MANUAL.md`, `skills/emcli/AGENTIC-MANUAL.md` (the `doctor --account` example stays
valid; verify wording), `README.md` getting-started (no whitelist at init).
- Tests: `admin_test.go`, `run_test.go`, plus new tests for aliases, leftover-arg rejection,
empty-operand rejection, address validation, `config list`/unknown-key, `whitelist
enable/disable` (incl. empty-list warning), `account show`, and `account remove` confirm path.
## What breaks (accepted)
Every current admin invocation using `--account` / `--name` / `--address` on `account`,
`whitelist`, `config` (positional already), `audit`, plus `account edit --whitelist-in/out` and
`account add --whitelist-in/out`. The agent path (skill + installer drive only the frozen JSON
commands; `doctor --account` retained) is unaffected.
## Open questions
None outstanding — all design forks resolved with the user.
+1 -1
View File
@@ -10,6 +10,7 @@ require (
github.com/emersion/go-message v0.18.2 github.com/emersion/go-message v0.18.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/mattn/go-isatty v0.0.20
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )
@@ -27,7 +28,6 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+85
View File
@@ -0,0 +1,85 @@
package cli
import (
"encoding/json"
"strings"
"testing"
)
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
// name/from/can_send, and never the IMAP host or login username.
func TestAccountListAgentJSONView(t *testing.T) {
adminEnv(t) // both keys + initialized temp DB
run(t, "account", "add", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
run(t, "account", "add", "alerts", "--mode", "RO",
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
// Drop the admin key → caller is an agent.
t.Setenv("EMCLI_ADMIN_KEY", "")
code, out, errOut := run(t, "account", "list")
if code != 0 {
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
}
var env struct {
Error bool `json:"error"`
Data struct {
Accounts []struct {
Name string `json:"name"`
From string `json:"from"`
CanSend bool `json:"can_send"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
}
if env.Error || len(env.Data.Accounts) != 2 {
t.Fatalf("want 2 accounts and no error, got %+v", env)
}
// The reduced view must not leak the IMAP host or the login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("agent view leaked host/username:\n%s", out)
}
got := map[string]struct {
from string
canSend bool
}{}
for _, a := range env.Data.Accounts {
got[a.Name] = struct {
from string
canSend bool
}{a.From, a.CanSend}
}
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
}
// alerts has no --from → SendFrom() falls back to the username.
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
}
}
// With the admin key present, `account list` stays the full human-readable table.
func TestAccountListAdminTextView(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
code, out, _ := run(t, "account", "list")
if code != 0 {
t.Fatalf("admin account list failed: code=%d", code)
}
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
if !strings.Contains(out, want) {
t.Fatalf("admin view missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"accounts"`) {
t.Fatalf("admin view should be text, not JSON:\n%s", out)
}
}
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"fmt"
"io"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// accountShow renders one account's configuration. The password is never shown.
func accountShow(st *store.Store, rest []string, out, errOut io.Writer) int {
if len(rest) == 0 {
fmt.Fprintln(errOut, "usage: emcli account show <name>")
return 2
}
if len(rest) > 1 {
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[1])
return 2
}
a, err := st.GetAccount(rest[0])
if err != nil {
fmt.Fprintf(errOut, "show: %v\n", err)
return 1
}
onOff := func(b bool) string {
if b {
return "enabled"
}
return "disabled"
}
smtp := "-"
if a.SMTPHost != "" {
smtp = fmt.Sprintf("%s:%d (%s)", a.SMTPHost, a.SMTPPort, a.SMTPSecurity)
}
subj := a.SubjectRegex
if subj == "" {
subj = "(none)"
}
// Labels padded to the width of the longest ("outbound whitelist:") so
// every value aligns in one column.
const lbl = "%-19s %s\n"
fmt.Fprintf(out, lbl, "name:", a.Name)
fmt.Fprintf(out, lbl, "mode:", a.Mode)
fmt.Fprintf(out, lbl, "imap:", fmt.Sprintf("%s:%d (%s)", a.IMAPHost, a.IMAPPort, a.IMAPSecurity))
fmt.Fprintf(out, lbl, "smtp:", smtp)
fmt.Fprintf(out, lbl, "username:", a.Username)
fmt.Fprintf(out, lbl, "send-from:", a.SendFrom())
fmt.Fprintf(out, lbl, "subject filter:", subj)
fmt.Fprintf(out, lbl, "inbound whitelist:", onOff(a.WhitelistInEnabled))
fmt.Fprintf(out, lbl, "outbound whitelist:", onOff(a.WhitelistOutEnabled))
return 0
}
+36
View File
@@ -0,0 +1,36 @@
package cli
import (
"strings"
"testing"
)
func TestAccountShow(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "shown", "--mode", "RW",
"--imap-host", "imap.x.com", "--imap-port", "993",
"--smtp-host", "smtp.x.com", "--smtp-port", "465",
"--username", "u@x.com", "--password", "secret", "--from", "me@x.com")
code, out, _ := run(t, "account", "show", "shown")
if code != 0 {
t.Fatalf("show exit=%d", code)
}
for _, want := range []string{"shown", "RW", "imap.x.com:993", "smtp.x.com:465", "u@x.com", "me@x.com"} {
if !strings.Contains(out, want) {
t.Fatalf("show missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "secret") {
t.Fatalf("show must never print the password:\n%s", out)
}
}
func TestAccountShowMissingName(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "account", "show"); code != 2 {
t.Fatal("show without a name must be a usage error")
}
if code, _, _ := run(t, "account", "show", "nope"); code == 0 {
t.Fatal("show of a missing account must be non-zero")
}
}
+288 -100
View File
@@ -1,41 +1,66 @@
package cli package cli
import ( import (
"bufio"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"strconv" "os"
"strings"
"github.com/mattn/go-isatty"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/policy"
"git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui" "git.dcglab.co.uk/steve/emcli/internal/tui"
) )
// runAccount handles `account add|list`. Human-readable output (never JSON). // confirmRemoval prompts on a TTY for a y/N answer. Non-TTY callers never reach
func runAccount(args []string, out, errOut io.Writer) int { // here (the caller requires --yes when stdin is not a terminal).
func confirmRemoval(name string, out io.Writer) bool {
fmt.Fprintf(out, "Remove account %q? [y/N]: ", name)
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
// runAccount handles `account <add|edit|remove|show|list>`. Human-readable
// output (except the agent-only reduced-JSON branch of `list`).
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) { if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "account") printCmdUsage(out, "account")
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list") fmt.Fprintln(out, "\nSubcommands: add, edit, remove, show, list")
if len(args) > 0 { if len(args) > 0 {
return 0 // explicit --help return 0
} }
return 2 return 2
} }
sub, rest := args[0], args[1:] sub := normalizeVerb(args[0])
st, err := openStore() rest := args[1:]
st, err := openStore(role)
if err != nil { if err != nil {
if sub == "list" {
_ = Failure(CodeConfig, err.Error()).Write(out)
} else {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
}
return 1 return 1
} }
defer st.Close() defer st.Close()
switch sub { switch sub {
case "add": case "add":
if len(rest) == 0 { // no flags → interactive TUI form if len(rest) == 0 { // no args → interactive TUI form
return addInteractive(st, tui.Fields{}, out, errOut) return addInteractive(st, tui.Fields{}, out, errOut)
} }
// Peel a leading positional name (if present) before flag parsing.
var name string
if !strings.HasPrefix(rest[0], "-") {
name, rest = rest[0], rest[1:]
}
fs := flag.NewFlagSet("account add", flag.ContinueOnError) fs := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
name := fs.String("name", "", "account name")
mode := fs.String("mode", "RO", "RO|RW") mode := fs.String("mode", "RO", "RO|RW")
host := fs.String("imap-host", "", "IMAP host") host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 993, "IMAP port") port := fs.Int("imap-port", 993, "IMAP port")
@@ -45,37 +70,50 @@ func runAccount(args []string, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "tls", "tls|starttls") smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
user := fs.String("username", "", "login username") user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password") pass := fs.String("password", "", "login password")
from := fs.String("from", "", "send-as address (blank = use username)")
subj := fs.String("subject-regex", "", "inbound subject filter") subj := fs.String("subject-regex", "", "inbound subject filter")
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
backlog := fs.Bool("process-backlog", false, "treat existing mail as new") backlog := fs.Bool("process-backlog", false, "treat existing mail as new")
if err := fs.Parse(rest); err != nil { if err := fs.Parse(rest); err != nil {
return 2 return 2
} }
if *name == "" || *host == "" || *user == "" { if fs.NArg() > 0 {
fmt.Fprintln(errOut, "name, imap-host, and username are required") fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
if name == "" || *host == "" || *user == "" {
fmt.Fprintln(errOut, "name, --imap-host, and --username are required")
return 2
}
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2 return 2
} }
acc := store.Account{ acc := store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec, Name: name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass, AuthType: "password", Username: *user, Password: *pass,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut, FromAddress: *from, SubjectRegex: *subj, ProcessBacklog: *backlog,
ProcessBacklog: *backlog,
} }
if *mode == "RW" { if *mode == "RW" {
acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec
} }
_, err := st.AddAccount(acc) if _, err := st.AddAccount(acc); err != nil {
if err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err) fmt.Fprintf(errOut, "add account: %v\n", err)
return 1 return 1
} }
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode) fmt.Fprintf(out, "account %q added (%s)\n", name, *mode)
return 0 return 0
case "edit": case "edit":
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
fmt.Fprintln(errOut, "usage: emcli account edit <name> [flags]")
return 2
}
name := rest[0]
flagArgs := rest[1:]
if len(flagArgs) == 0 { // only name → interactive prefilled form
return editInteractive(st, name, out, errOut)
}
fs := flag.NewFlagSet("account edit", flag.ContinueOnError) fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
mode := fs.String("mode", "", "RO|RW") mode := fs.String("mode", "", "RO|RW")
host := fs.String("imap-host", "", "IMAP host") host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 0, "IMAP port") port := fs.Int("imap-port", 0, "IMAP port")
@@ -85,23 +123,24 @@ func runAccount(args []string, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "", "tls|starttls") smtpSec := fs.String("smtp-security", "", "tls|starttls")
user := fs.String("username", "", "login username") user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password (blank keeps existing)") pass := fs.String("password", "", "login password (blank keeps existing)")
from := fs.String("from", "", "send-as address (empty reverts to username)")
subj := fs.String("subject-regex", "", "inbound subject filter") subj := fs.String("subject-regex", "", "inbound subject filter")
if err := fs.Parse(rest); err != nil { if err := fs.Parse(flagArgs); err != nil {
return 2 return 2
} }
if *name == "" { if fs.NArg() > 0 {
fmt.Fprintln(errOut, "--name is required") fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2 return 2
} }
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled if err := tui.ValidFromAddress(*from); err != nil {
return editInteractive(st, *name, out, errOut) fmt.Fprintln(errOut, err)
return 2
} }
acc, err := st.GetAccount(*name) acc, err := st.GetAccount(name)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err) fmt.Fprintf(errOut, "edit: %v\n", err)
return 1 return 1
} }
// Overlay only the flags the user actually set.
fs.Visit(func(f *flag.Flag) { fs.Visit(func(f *flag.Flag) {
switch f.Name { switch f.Name {
case "mode": case "mode":
@@ -122,47 +161,82 @@ func runAccount(args []string, out, errOut io.Writer) int {
acc.Username = *user acc.Username = *user
case "password": case "password":
acc.Password = *pass acc.Password = *pass
case "from":
acc.FromAddress = *from
case "subject-regex": case "subject-regex":
acc.SubjectRegex = *subj acc.SubjectRegex = *subj
} }
}) })
// acc.Password holds the existing (decrypted) password from GetAccount; the // GetAccount loaded the existing decrypted password into acc; fs.Visit
// Visit above overwrites it only when --password was passed. UpdateAccount // overwrites acc.Password only when --password was passed; UpdateAccount
// re-seals whatever non-empty value is present, so the password is preserved. // re-seals whatever non-empty password is present, so omitting --password
// on edit preserves the existing password unchanged.
if err := st.UpdateAccount(acc); err != nil { if err := st.UpdateAccount(acc); err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err) fmt.Fprintf(errOut, "edit: %v\n", err)
return 1 return 1
} }
fmt.Fprintf(out, "account %q updated\n", *name) fmt.Fprintf(out, "account %q updated\n", name)
return 0 return 0
case "remove": case "remove":
fs := flag.NewFlagSet("account remove", flag.ContinueOnError) if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
fs.SetOutput(errOut) fmt.Fprintln(errOut, "usage: emcli account remove <name> [--yes]")
name := fs.String("name", "", "account name (required)")
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest); err != nil {
return 2 return 2
} }
if *name == "" { name := rest[0]
fmt.Fprintln(errOut, "--name is required") fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest[1:]); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2 return 2
} }
if !*yes { if !*yes {
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name) if !isatty.IsTerminal(os.Stdin.Fd()) {
fmt.Fprintf(errOut, "refusing to remove %q without --yes (no terminal for confirmation)\n", name)
return 2 return 2
} }
if err := st.DeleteAccount(*name); err != nil { if !confirmRemoval(name, out) {
fmt.Fprintln(out, "aborted")
return 1
}
}
if err := st.DeleteAccount(name); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err) fmt.Fprintf(errOut, "remove: %v\n", err)
return 1 return 1
} }
fmt.Fprintf(out, "account %q removed\n", *name) fmt.Fprintf(out, "account %q removed\n", name)
return 0 return 0
case "show":
return accountShow(st, rest, out, errOut)
case "list": case "list":
if len(rest) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0])
return 2
}
_, adminErr := crypto.AdminKeyFromEnv()
isAdmin := adminErr == nil
accs, err := st.ListAccounts() accs, err := st.ListAccounts()
if err != nil { if err != nil {
if isAdmin {
fmt.Fprintf(errOut, "list: %v\n", err) fmt.Fprintf(errOut, "list: %v\n", err)
} else {
_ = Failure(CodeDB, err.Error()).Write(out)
}
return 1 return 1
} }
if !isAdmin {
items := make([]map[string]any, 0, len(accs))
for _, a := range accs {
items = append(items, map[string]any{
"name": a.Name, "from": a.SendFrom(), "can_send": a.Mode == "RW",
})
}
_ = Success(map[string]any{"accounts": items}).Write(out)
return 0
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER") fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs { for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
@@ -170,7 +244,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
} }
return 0 return 0
default: default:
fmt.Fprintf(errOut, "unknown account subcommand %q\n", sub) fmt.Fprintf(errOut, "unknown account subcommand %q (want add|edit|remove|show|list)\n", sub)
return 2 return 2
} }
} }
@@ -190,8 +264,8 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
return nil return nil
} }
// runConfig handles `config set <key> <value>` and `config get <key>`. // runConfig handles `config <list|get|set>` against the settings registry.
func runConfig(args []string, out, errOut io.Writer) int { func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) { if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "config") printCmdUsage(out, "config")
if len(args) > 0 { if len(args) > 0 {
@@ -199,12 +273,9 @@ func runConfig(args []string, out, errOut io.Writer) int {
} }
return 2 return 2
} }
if len(args) < 2 { sub := normalizeVerb(args[0])
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]") rest := args[1:]
return 2 st, err := openStore(role)
}
sub, key := args[0], args[1]
st, err := openStore()
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
@@ -212,16 +283,51 @@ func runConfig(args []string, out, errOut io.Writer) int {
defer st.Close() defer st.Close()
switch sub { switch sub {
case "list":
if len(rest) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0])
return 2
}
fmt.Fprintf(out, "%-22s %-8s %s\n", "KEY", "VALUE", "DESCRIPTION")
for _, k := range settingKeys() {
v, err := st.GetSetting(k)
if err != nil {
v = "(unset)"
}
fmt.Fprintf(out, "%-22s %-8s %s\n", k, v, settingsRegistry[k].desc)
}
return 0
case "get":
if len(rest) != 1 {
fmt.Fprintln(errOut, "usage: emcli config get <key>")
return 2
}
key := rest[0]
if _, ok := settingsRegistry[key]; !ok {
fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
return 2
}
v, err := st.GetSetting(key)
if err != nil {
fmt.Fprintf(errOut, "config get: %s not set\n", key)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, v)
return 0
case "set": case "set":
if len(args) < 3 { if len(rest) != 2 {
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>") fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
return 2 return 2
} }
value := args[2] key, value := rest[0], rest[1]
if key == "audit_retention_days" { def, ok := settingsRegistry[key]
n, err := strconv.Atoi(value) if !ok {
if err != nil || n < 0 { fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value) return 2
}
if def.validate != nil {
if err := def.validate(value); err != nil {
fmt.Fprintf(errOut, "%s %v\n", key, err)
return 2 return 2
} }
} }
@@ -231,52 +337,62 @@ func runConfig(args []string, out, errOut io.Writer) int {
} }
fmt.Fprintf(out, "%s = %s\n", key, value) fmt.Fprintf(out, "%s = %s\n", key, value)
return 0 return 0
case "get":
v, err := st.GetSetting(key)
if err != nil {
fmt.Fprintf(errOut, "config get: %s not set\n", key)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, v)
return 0
default: default:
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub) fmt.Fprintf(errOut, "unknown config subcommand %q (want list|get|set)\n", sub)
return 2 return 2
} }
} }
// runAudit handles `audit list [--account <name>] [--limit N]`. // runAudit handles `audit list [account] [--limit N]`.
func runAudit(args []string, out, errOut io.Writer) int { func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) { if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "audit") printCmdUsage(out, "audit")
return 0 return 0
} }
if len(args) == 0 || args[0] != "list" { if len(args) == 0 || normalizeVerb(args[0]) != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]") fmt.Fprintln(errOut, "usage: emcli audit list [account] [--limit N]")
return 2 return 2
} }
// Peel an optional positional account before flag parsing.
rest := args[1:]
var account string
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
account, rest = rest[0], rest[1:]
}
fs := flag.NewFlagSet("audit list", flag.ContinueOnError) fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
account := fs.String("account", "", "filter by account")
limit := fs.Int("limit", 50, "max rows") limit := fs.Int("limit", 50, "max rows")
if err := fs.Parse(args[1:]); err != nil { if err := fs.Parse(rest); err != nil {
return 2 return 2
} }
st, err := openStore() if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
st, err := openStore(role)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
} }
defer st.Close() defer st.Close()
if err := auditList(st, *account, *limit, out); err != nil { if err := auditList(st, account, *limit, out); err != nil {
fmt.Fprintf(errOut, "audit list: %v\n", err) fmt.Fprintf(errOut, "audit list: %v\n", err)
return 1 return 1
} }
return 0 return 0
} }
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`. // flowName renders a direction for human-facing prose.
func runWhitelist(args []string, out, errOut io.Writer) int { func flowName(dir store.Direction) string {
if dir == store.DirOut {
return "outbound"
}
return "inbound"
}
// runWhitelist handles `whitelist <add|remove|list|enable|disable> <account>
// [addr…] --in|--out`.
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) { if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "whitelist") printCmdUsage(out, "whitelist")
if len(args) > 0 { if len(args) > 0 {
@@ -284,24 +400,43 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
} }
return 2 return 2
} }
if len(args) < 2 { sub := normalizeVerb(args[0])
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]") switch sub {
case "add", "remove", "list", "enable", "disable": // valid
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list|enable|disable)\n", sub)
return 2 return 2
} }
dir := store.Direction(args[0])
sub, rest := args[1], args[2:] // Split the remaining tokens into the direction flag and positionals.
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError) var dir store.Direction
fs.SetOutput(errOut) var dirSet bool
account := fs.String("account", "", "account name") var pos []string
address := fs.String("address", "", "email or @domain") for _, a := range args[1:] {
if err := fs.Parse(rest); err != nil { switch a {
case "--in", "-in":
dir, dirSet = store.DirIn, true
case "--out", "-out":
dir, dirSet = store.DirOut, true
default:
if strings.HasPrefix(a, "-") {
fmt.Fprintf(errOut, "unknown flag %q (use --in or --out)\n", a)
return 2 return 2
} }
if *account == "" { pos = append(pos, a)
fmt.Fprintln(errOut, "--account is required") }
}
if !dirSet {
fmt.Fprintln(errOut, "direction is required: pass --in or --out")
return 2 return 2
} }
st, err := openStore() if len(pos) == 0 {
fmt.Fprintln(errOut, "account is required")
return 2
}
account, addrs := pos[0], pos[1:]
st, err := openStore(role)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
@@ -309,30 +444,83 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
defer st.Close() defer st.Close()
switch sub { switch sub {
case "add": case "add", "remove":
if err := st.AddWhitelist(*account, dir, *address); err != nil { if len(addrs) == 0 {
fmt.Fprintf(errOut, "add: %v\n", err) fmt.Fprintln(errOut, "at least one address is required")
return 2
}
for _, addr := range addrs {
if err := policy.ValidWhitelistAddress(addr); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
}
for _, addr := range addrs {
if sub == "add" {
err = st.AddWhitelist(account, dir, addr)
} else {
err = st.RemoveWhitelist(account, dir, addr)
}
if err != nil {
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
return 1 return 1
} }
fmt.Fprintf(out, "added %s to %s whitelist of %q\n", *address, dir, *account)
case "remove":
if err := st.RemoveWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
} }
fmt.Fprintf(out, "removed %s\n", *address) verb := "added"
if sub == "remove" {
verb = "removed"
}
fmt.Fprintf(out, "%s %d address(es) in the %s whitelist of %q\n", verb, len(addrs), dir, account)
return 0
case "list": case "list":
addrs, err := st.ListWhitelist(*account, dir) if len(addrs) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
return 2
}
acc, err := st.GetAccount(account)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err) fmt.Fprintf(errOut, "list: %v\n", err)
return 1 return 1
} }
for _, a := range addrs { enabled := acc.WhitelistInEnabled
if dir == store.DirOut {
enabled = acc.WhitelistOutEnabled
}
state := "DISABLED"
if enabled {
state = "ENABLED"
}
entries, err := st.ListWhitelist(account, dir)
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
fmt.Fprintf(out, "%s whitelist of %q: %s\n", dir, account, state)
for _, a := range entries {
fmt.Fprintln(out, a) fmt.Fprintln(out, a)
} }
default: return 0
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub) case "enable", "disable":
if len(addrs) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
return 2 return 2
} }
enable := sub == "enable"
if enable {
if entries, _ := st.ListWhitelist(account, dir); len(entries) == 0 {
fmt.Fprintf(errOut, "warning: %s whitelist for %q is empty — this blocks ALL %s mail until you add addresses\n", dir, account, flowName(dir))
}
}
if err := st.SetWhitelistEnabled(account, dir, enable); err != nil {
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
return 1
}
state := "disabled"
if enable {
state = "enabled"
}
fmt.Fprintf(out, "%s whitelist of %q %s\n", dir, account, state)
return 0
}
return 0 return 0
} }
+155 -16
View File
@@ -7,15 +7,28 @@ import (
"testing" "testing"
"time" "time"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/store"
) )
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path. // adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
func adminEnv(t *testing.T) string { func adminEnv(t *testing.T) string {
t.Helper() t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db") db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_KEY", b64Key()) t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db) t.Setenv("EMCLI_DB", db)
st, err := store.Open(db)
if err != nil {
t.Fatalf("Open: %v", err)
}
adminKey, _ := crypto.AdminKeyFromEnv()
agentKey, _ := crypto.AgentKeyFromEnv()
if err := st.InitKeys(adminKey, agentKey); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
return db return db
} }
@@ -39,18 +52,40 @@ func TestConfigSetGet(t *testing.T) {
func TestConfigSetRejectsBadRetention(t *testing.T) { func TestConfigSetRejectsBadRetention(t *testing.T) {
adminEnv(t) adminEnv(t)
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code == 0 { if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code != 2 {
t.Fatal("negative retention must be rejected") t.Fatal("negative retention must be a usage error")
} }
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code == 0 { if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code != 2 {
t.Fatal("non-integer retention must be rejected") t.Fatal("non-integer retention must be a usage error")
}
}
func TestConfigRejectsUnknownKey(t *testing.T) {
adminEnv(t)
if code, _, e := run(t, "config", "set", "bogus", "1"); code != 2 || !strings.Contains(e, "unknown setting") {
t.Fatalf("set unknown key: code=%d err=%q", code, e)
}
if code, _, e := run(t, "config", "get", "bogus"); code != 2 || !strings.Contains(e, "unknown setting") {
t.Fatalf("get unknown key: code=%d err=%q", code, e)
}
}
func TestConfigList(t *testing.T) {
adminEnv(t)
run(t, "config", "set", "audit_retention_days", "42")
code, out, _ := run(t, "config", "ls") // alias
if code != 0 {
t.Fatalf("config ls exit=%d", code)
}
if !strings.Contains(out, "audit_retention_days") || !strings.Contains(out, "42") || !strings.Contains(out, "KEY") {
t.Fatalf("config list output wrong:\n%s", out)
} }
} }
func TestAccountRemove(t *testing.T) { func TestAccountRemove(t *testing.T) {
adminEnv(t) adminEnv(t)
run(t, "account", "add", "--name", "gone", "--imap-host", "h", "--username", "u@x.com") run(t, "account", "add", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "--name", "gone", "--yes"); code != 0 { if code, _, e := run(t, "account", "remove", "gone", "--yes"); code != 0 {
t.Fatalf("remove failed: %s", e) t.Fatalf("remove failed: %s", e)
} }
_, out, _ := run(t, "account", "list") _, out, _ := run(t, "account", "list")
@@ -61,25 +96,39 @@ func TestAccountRemove(t *testing.T) {
func TestAccountRemoveMissing(t *testing.T) { func TestAccountRemoveMissing(t *testing.T) {
adminEnv(t) adminEnv(t)
if code, _, _ := run(t, "account", "remove", "--name", "nope", "--yes"); code == 0 { if code, _, _ := run(t, "account", "remove", "nope", "--yes"); code == 0 {
t.Fatal("removing a missing account must be non-zero") t.Fatal("removing a missing account must be non-zero")
} }
} }
func TestAccountRemoveNoTTYNeedsYes(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "keep", "--imap-host", "h", "--username", "u@x.com")
// Under `go test`, stdin is not a TTY, so without --yes this must refuse.
code, _, errOut := run(t, "account", "remove", "keep")
if code != 2 || !strings.Contains(errOut, "--yes") {
t.Fatalf("non-TTY remove without --yes must refuse: code=%d err=%q", code, errOut)
}
}
func TestAccountEditPartialPreservesOtherFields(t *testing.T) { func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
db := adminEnv(t) db := adminEnv(t)
run(t, "account", "add", "--name", "ed", "--mode", "RO", run(t, "account", "add", "ed", "--mode", "RO",
"--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig") "--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig")
// Edit only mode + add SMTP; imap-host, username, password must be preserved. if code, _, e := run(t, "account", "edit", "ed", "--mode", "RW",
if code, _, e := run(t, "account", "edit", "--name", "ed", "--mode", "RW",
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 { "--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e) t.Fatalf("edit failed: %s", e)
} }
st, err := store.Open(db, mustKey()) st, err := store.Open(db)
if err != nil { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
defer st.Close() defer st.Close()
adminKey, _ := crypto.AdminKeyFromEnv()
agentKey, _ := crypto.AgentKeyFromEnv()
if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil {
t.Fatalf("Unlock: %v", err)
}
got, err := st.GetAccount("ed") got, err := st.GetAccount("ed")
if err != nil { if err != nil {
t.Fatalf("GetAccount: %v", err) t.Fatalf("GetAccount: %v", err)
@@ -92,12 +141,76 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
} }
} }
func TestWhitelistRequiresDirection(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
code, _, errOut := run(t, "whitelist", "add", "bobby", "a@x.com")
if code != 2 || !strings.Contains(errOut, "--in") || !strings.Contains(errOut, "--out") {
t.Fatalf("missing direction must name --in/--out: code=%d err=%q", code, errOut)
}
}
func TestWhitelistAddListRemove(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "whitelist", "add", "bobby", "a@x.com", "@y.com", "--out"); code != 0 {
t.Fatalf("add failed: %s", e)
}
code, out, _ := run(t, "whitelist", "list", "bobby", "--out")
if code != 0 || !strings.Contains(out, "a@x.com") || !strings.Contains(out, "@y.com") || !strings.Contains(out, "DISABLED") {
t.Fatalf("list wrong: code=%d out=%q", code, out)
}
if code, _, e := run(t, "whitelist", "rm", "bobby", "a@x.com", "--out"); code != 0 { // alias
t.Fatalf("rm failed: %s", e)
}
_, out, _ = run(t, "whitelist", "ls", "bobby", "--out") // alias
if strings.Contains(out, "a@x.com") {
t.Fatalf("address not removed:\n%s", out)
}
}
func TestWhitelistRejectsBadAddress(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "whitelist", "add", "bobby", "notanaddress", "--in"); code != 2 || !strings.Contains(e, "invalid address") {
t.Fatalf("bad address must be rejected: code=%d err=%q", code, e)
}
// The original bug: a missing address must not silently insert a blank row.
if code, _, _ := run(t, "whitelist", "add", "bobby", "--in"); code != 2 {
t.Fatal("add with no address must be a usage error")
}
}
func TestWhitelistEnableDisable(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
// Enabling an empty whitelist warns but succeeds.
code, _, errOut := run(t, "whitelist", "enable", "bobby", "--in")
if code != 0 || !strings.Contains(errOut, "empty") {
t.Fatalf("enable empty: code=%d err=%q", code, errOut)
}
_, out, _ := run(t, "whitelist", "list", "bobby", "--in")
if !strings.Contains(out, "ENABLED") {
t.Fatalf("expected ENABLED:\n%s", out)
}
if code, _, e := run(t, "whitelist", "disable", "bobby", "--in"); code != 0 {
t.Fatalf("disable failed: %s", e)
}
_, out, _ = run(t, "whitelist", "list", "bobby", "--in")
if !strings.Contains(out, "DISABLED") {
t.Fatalf("expected DISABLED:\n%s", out)
}
}
func TestAuditListCoreRenders(t *testing.T) { func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil { if err != nil {
t.Fatalf("open: %v", err) t.Fatalf("open: %v", err)
} }
defer st.Close() defer st.Close()
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"}) _ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"}) _ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
@@ -111,5 +224,31 @@ func TestAuditListCoreRenders(t *testing.T) {
} }
} }
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen. func TestAuditListPositionalAccount(t *testing.T) {
func mustKey() []byte { return make([]byte, 32) } adminEnv(t)
// Positional account + `ls` alias must be accepted (empty log → exit 0).
if code, _, e := run(t, "audit", "ls", "someacct"); code != 0 {
t.Fatalf("audit ls <account> should succeed: code=%d err=%q", code, e)
}
// Extra positional is a usage error.
if code, _, _ := run(t, "audit", "list", "a", "b"); code != 2 {
t.Fatal("extra positional must be a usage error")
}
// The removed --account flag is now a usage error.
if code, _, _ := run(t, "audit", "list", "--account", "x"); code != 2 {
t.Fatal("removed --account flag should now be a usage error")
}
}
func TestAccountEditFromValidationRejectsMalformed(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com")
code, _, errStr := run(t, "account", "edit", "valacc", "--from", "not an address")
if code != 2 {
t.Fatalf("expected exit code 2 for malformed --from, got %d (stderr: %q)", code, errStr)
}
if errStr == "" {
t.Fatal("expected an error message on stderr for malformed --from, got none")
}
}
+4 -1
View File
@@ -58,10 +58,13 @@ func testKey() []byte {
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) { func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
t.Helper() t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
_, err = st.AddAccount(store.Account{ _, err = st.AddAccount(store.Account{
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
+38
View File
@@ -0,0 +1,38 @@
package cli
import (
"fmt"
"sort"
"strconv"
)
// settingDef describes a configurable global setting for `config list`/`set`.
type settingDef struct {
desc string
validate func(string) error
}
// settingsRegistry is the authoritative set of valid config keys. `config set`
// rejects keys absent here; `config list` enumerates them.
var settingsRegistry = map[string]settingDef{
"audit_retention_days": {
desc: "Days to keep audit-log entries (integer >= 0)",
validate: func(v string) error {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return fmt.Errorf("must be an integer >= 0, got %q", v)
}
return nil
},
},
}
// settingKeys returns the registry keys in stable sorted order.
func settingKeys() []string {
ks := make([]string, 0, len(settingsRegistry))
for k := range settingsRegistry {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
+28
View File
@@ -0,0 +1,28 @@
package cli
import "testing"
func TestSettingsRegistry(t *testing.T) {
def, ok := settingsRegistry["audit_retention_days"]
if !ok {
t.Fatal("audit_retention_days must be registered")
}
if def.desc == "" {
t.Error("registered setting needs a description")
}
if err := def.validate("30"); err != nil {
t.Errorf("validate(30) = %v, want nil", err)
}
for _, bad := range []string{"-1", "abc", ""} {
if def.validate(bad) == nil {
t.Errorf("validate(%q) = nil, want error", bad)
}
}
if _, ok := settingsRegistry["nope"]; ok {
t.Error("unknown key must not be present")
}
keys := settingKeys()
if len(keys) != len(settingsRegistry) {
t.Fatalf("settingKeys len=%d, want %d", len(keys), len(settingsRegistry))
}
}
+12
View File
@@ -16,3 +16,15 @@ func uintSlice(us []uint32) []uint64 {
} }
return out return out
} }
// normalizeVerb maps verb aliases to their canonical form. Applied at the
// subcommand-verb position of every admin group and at the top-level command.
func normalizeVerb(v string) string {
switch v {
case "rm", "del":
return "remove"
case "ls":
return "list"
}
return v
}
+16
View File
@@ -0,0 +1,16 @@
package cli
import "testing"
func TestNormalizeVerb(t *testing.T) {
cases := map[string]string{
"rm": "remove", "del": "remove", "remove": "remove",
"ls": "list", "list": "list",
"add": "add", "enable": "enable", "": "",
}
for in, want := range cases {
if got := normalizeVerb(in); got != want {
t.Errorf("normalizeVerb(%q) = %q, want %q", in, got, want)
}
}
}
+4 -1
View File
@@ -11,10 +11,13 @@ import (
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) { func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
t.Helper() t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
for _, a := range accounts { for _, a := range accounts {
if _, err := st.AddAccount(a); err != nil { if _, err := st.AddAccount(a); err != nil {
+7 -5
View File
@@ -23,11 +23,11 @@ var agentCmds = []cmdHelp{
var adminCmds = []cmdHelp{ var adminCmds = []cmdHelp{
{"init", "init", "Create the database and add the first account (interactive)."}, {"init", "init", "Create the database and add the first account (interactive)."},
{"account", "account <add|edit|remove|list> [flags]", "Manage accounts (add/edit accept flags, or run with none for an interactive form)."}, {"account", "account <add|edit|remove|show|list> [name] [flags]", "Manage accounts. `add`/`edit` take a positional name + field flags, or run with none for an interactive form."},
{"whitelist", "whitelist <in|out> <add|remove|list> --account <name> [--address A]", "Manage inbound/outbound whitelists."}, {"whitelist", "whitelist <add|remove|list|enable|disable> <account> [address…] --in|--out", "Manage inbound/outbound whitelists. Direction (--in/--out) is required."},
{"config", "config <set|get> <key> [value]", "Get or set global settings (e.g. audit_retention_days)."}, {"config", "config <list|get|set> [key] [value]", "List, get, or set global settings (e.g. audit_retention_days)."},
{"audit", "audit list [--account <name>] [--limit N]", "Show recent audit-log entries."}, {"audit", "audit list [account] [--limit N]", "Show recent audit-log entries."},
{"doctor", "doctor [--account <name>]", "Check each account's IMAP/SMTP connectivity and auth."}, {"doctor", "doctor [account]", "Check each account's IMAP/SMTP connectivity and auth."},
{"version", "version", "Print the emcli version."}, {"version", "version", "Print the emcli version."},
{"help", "help [command]", "Show this help, or detailed usage for one command."}, {"help", "help [command]", "Show this help, or detailed usage for one command."},
} }
@@ -58,6 +58,8 @@ func printMainHelp(w io.Writer) {
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary) fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
} }
fmt.Fprint(w, "\nRun \"emcli <command> --help\" for a command's flags.\n") fmt.Fprint(w, "\nRun \"emcli <command> --help\" for a command's flags.\n")
fmt.Fprint(w, "\nAliases: rm/del = remove, ls = list. Admin commands take positional\n")
fmt.Fprint(w, "operands (account/address/key); agent commands use flags (--account …).\n")
fmt.Fprint(w, "\nEnvironment:\n") fmt.Fprint(w, "\nEnvironment:\n")
fmt.Fprint(w, " EMCLI_KEY base64-encoded 32-byte AES key; required for any command that uses the database\n") fmt.Fprint(w, " EMCLI_KEY base64-encoded 32-byte AES key; required for any command that uses the database\n")
fmt.Fprint(w, " EMCLI_DB database path (default ~/.config/emcli/emcli.db; %AppData%\\emcli\\emcli.db on Windows)\n") fmt.Fprint(w, " EMCLI_DB database path (default ~/.config/emcli/emcli.db; %AppData%\\emcli\\emcli.db on Windows)\n")
+15
View File
@@ -71,3 +71,18 @@ func TestAdminCommandHelpExitsZero(t *testing.T) {
} }
} }
} }
func TestHelpReflectsNewGrammar(t *testing.T) {
_, out, _ := run(t, "help", "whitelist")
if !strings.Contains(out, "<account>") || !strings.Contains(out, "--in") {
t.Fatalf("whitelist help should show positional account + --in/--out:\n%s", out)
}
_, out, _ = run(t, "help", "account")
if !strings.Contains(out, "show") {
t.Fatalf("account help should list the new show subcommand:\n%s", out)
}
_, mainOut, mainErr := run(t, "help")
if !strings.Contains(mainOut+mainErr, "rm") && !strings.Contains(mainOut+mainErr, "alias") {
t.Fatalf("main help should mention aliases:\n%s", mainOut+mainErr)
}
}
+23 -3
View File
@@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui" "git.dcglab.co.uk/steve/emcli/internal/tui"
) )
@@ -70,19 +71,38 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
return 0 return 0
} }
// runInit creates/opens the DB and adds the first account via the TUI form, // runInit creates/opens the DB, writes both DEK wrap slots, and adds the first
// seeding a default audit retention if unset. // account via the TUI form, seeding a default audit retention if unset.
func runInit(args []string, out, errOut io.Writer) int { func runInit(args []string, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) { if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init") printCmdUsage(out, "init")
return 0 return 0
} }
st, err := openStore() adminKey, err := crypto.AdminKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
agentKey, err := crypto.AgentKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
path, err := store.DefaultDBPath()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
st, err := store.Open(path)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
} }
defer st.Close() defer st.Close()
if err := st.InitKeys(adminKey, agentKey); err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
if _, err := st.GetSetting("audit_retention_days"); err != nil { if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90") _ = st.SetSetting("audit_retention_days", "90")
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func TestCommandRole(t *testing.T) {
adminCmds := [][]string{
{"whitelist"}, {"config"}, {"audit"},
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
{"account", "list"},
}
for _, c := range adminCmds {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%v should be admin", c)
}
}
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%v should be agent", c)
}
}
}
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
// A human holding only the admin key can still run agent commands
// (admin is a superset → agent-role unlock falls back to the admin slot).
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, _ := store.Open(db)
ak, _ := crypto.AdminKeyFromEnv()
gk, _ := crypto.AgentKeyFromEnv()
st.InitKeys(ak, gk)
st.Close()
// Only the admin key now; agent command must still open the store.
t.Setenv("EMCLI_KEY", "")
s2, err := openStore(store.RoleAgent)
if err != nil {
t.Fatalf("agent role with only admin key should open: %v", err)
}
s2.Close()
}
+82 -24
View File
@@ -25,17 +25,56 @@ func realMailer(acc store.Account) (Mailer, error) {
return c, nil return c, nil
} }
// openStore loads the key and opens the DB, returning a human-readable error string. // commandRole maps a command to the privilege it requires. Admin commands
func openStore() (*store.Store, error) { // mutate configuration or expose oversight data; everything else is agent.
key, err := crypto.KeyFromEnv() func commandRole(args []string) store.Role {
if err != nil { switch args[0] {
return nil, err case "account":
// account list is a read-only discovery view available to agents;
// add/edit/remove mutate config and require admin.
// Normalize the verb so `account ls` routes the same as `account list`.
if len(args) >= 2 && normalizeVerb(args[1]) == "list" {
return store.RoleAgent
} }
return store.RoleAdmin
case "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
func openStore(role store.Role) (*store.Store, error) {
adminKey, adminErr := crypto.AdminKeyFromEnv()
agentKey, agentErr := crypto.AgentKeyFromEnv()
switch role {
case store.RoleAdmin:
if adminErr != nil {
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
}
case store.RoleAgent:
if agentErr != nil && adminErr != nil {
return nil, agentErr // "EMCLI_KEY is not set"
}
}
path, err := store.DefaultDBPath() path, err := store.DefaultDBPath()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return store.Open(path, key) st, err := store.Open(path)
if err != nil {
return nil, err
}
if err := st.Unlock(role, adminKey, agentKey); err != nil {
st.Close()
return nil, err
}
return st, nil
} }
func realSender(acc store.Account, m mail.OutgoingMessage) error { func realSender(acc store.Account, m mail.OutgoingMessage) error {
@@ -67,26 +106,44 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
} }
} }
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics). // runDoctor handles `doctor [account]` or `doctor [--account <name>]`.
func runDoctor(args []string, out, errOut io.Writer) int { func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
// Accept a positional account (human form) or --account (agent form).
var positional string
rest := args
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
positional, rest = args[0], args[1:]
}
fs := flag.NewFlagSet("doctor", flag.ContinueOnError) fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
usageFlags(fs, "doctor", errOut) usageFlags(fs, "doctor", errOut)
account := fs.String("account", "", "check only this account") accountFlag := fs.String("account", "", "check only this account")
if err := fs.Parse(args); err != nil { if err := fs.Parse(rest); err != nil {
if errors.Is(err, flag.ErrHelp) { if errors.Is(err, flag.ErrHelp) {
return 0 return 0
} }
return 2 return 2
} }
st, err := openStore() if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
account := *accountFlag
if positional != "" {
if account != "" && account != positional {
fmt.Fprintln(errOut, "give the account once, as a positional or --account, not both")
return 2
}
account = positional
}
st, err := openStore(role)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err) fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1 return 1
} }
defer st.Close() defer st.Close()
d := newDepsLive(st, out) d := newDepsLive(st, out)
if err := DoctorCmd(d, *account); err != nil { if err := DoctorCmd(d, account); err != nil {
return 1 return 1
} }
return 0 return 0
@@ -104,21 +161,22 @@ func Run(args []string, out, errOut io.Writer) int {
return 0 return 0
} }
cmd, rest := args[0], args[1:] cmd, rest := args[0], args[1:]
switch cmd { role := commandRole(args)
switch normalizeVerb(cmd) {
case "list", "get", "search", "ack": case "list", "get", "search", "ack":
return runAgent(cmd, rest, out, errOut) return runAgent(normalizeVerb(cmd), rest, role, out, errOut)
case "send": case "send":
return runSend(rest, out, errOut) return runSend(rest, role, out, errOut)
case "account": case "account":
return runAccount(rest, out, errOut) return runAccount(rest, role, out, errOut)
case "whitelist": case "whitelist":
return runWhitelist(rest, out, errOut) return runWhitelist(rest, role, out, errOut)
case "config": case "config":
return runConfig(rest, out, errOut) return runConfig(rest, role, out, errOut)
case "audit": case "audit":
return runAudit(rest, out, errOut) return runAudit(rest, role, out, errOut)
case "doctor": case "doctor":
return runDoctor(rest, out, errOut) return runDoctor(rest, role, out, errOut)
case "init": case "init":
return runInit(rest, out, errOut) return runInit(rest, out, errOut)
default: default:
@@ -128,7 +186,7 @@ func Run(args []string, out, errOut io.Writer) int {
} }
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes. // runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, out, errOut io.Writer) int { func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError) fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
usageFlags(fs, cmd, errOut) usageFlags(fs, cmd, errOut)
@@ -159,7 +217,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
_ = Failure(CodeUsage, "--account is required").Write(out) _ = Failure(CodeUsage, "--account is required").Write(out)
return 2 return 2
} }
st, err := openStore() st, err := openStore(role)
if err != nil { if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out) _ = Failure(CodeConfig, err.Error()).Write(out)
return 1 return 1
@@ -224,7 +282,7 @@ func (s *stringSlice) Set(v string) error {
} }
// runSend handles the `send` agent command (JSON envelope output). // runSend handles the `send` agent command (JSON envelope output).
func runSend(args []string, out, errOut io.Writer) int { func runSend(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("send", flag.ContinueOnError) fs := flag.NewFlagSet("send", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
usageFlags(fs, "send", errOut) usageFlags(fs, "send", errOut)
@@ -249,7 +307,7 @@ func runSend(args []string, out, errOut io.Writer) int {
_ = Failure(CodeUsage, "--account is required").Write(out) _ = Failure(CodeUsage, "--account is required").Write(out)
return 2 return 2
} }
st, err := openStore() st, err := openStore(role)
if err != nil { if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out) _ = Failure(CodeConfig, err.Error()).Write(out)
return 1 return 1
+72 -5
View File
@@ -18,17 +18,25 @@ func TestRunUnknownCommand(t *testing.T) {
} }
} }
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) { func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) {
// `account list` with no DB key should fail closed with a usage/config error, // `account list` is an agent command: with no DB key it fails closed before
// proving the key check happens before any DB work. // any DB work, emitting a JSON config-error envelope that names EMCLI_KEY.
var out, errOut bytes.Buffer var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", "") t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
code := Run([]string{"account", "list"}, &out, &errOut) code := Run([]string{"account", "list"}, &out, &errOut)
if code == 0 { if code == 0 {
t.Fatal("missing EMCLI_KEY must fail") t.Fatal("missing EMCLI_KEY must fail")
} }
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") { var env map[string]any
t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String()) if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("agent account list error must be JSON, got out=%q err=%q", out.String(), errOut.String())
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
if !strings.Contains(out.String(), "EMCLI_KEY") {
t.Fatalf("should name the missing EMCLI_KEY, got %q", out.String())
} }
} }
@@ -50,7 +58,66 @@ func TestListUsageErrorIsJSON(t *testing.T) {
} }
} }
func TestDoctorPositionalAccount(t *testing.T) {
adminEnv(t)
// No such account: doctor should reach account lookup and fail there (exit 1),
// proving the positional was accepted (not rejected as an unexpected arg).
code, _, errOut := run(t, "doctor", "ghost")
if code == 2 {
t.Fatalf("positional account must be accepted, got usage error: %q", errOut)
}
// Giving both positional and --account with different values is a usage error.
if code, _, _ := run(t, "doctor", "ghost", "--account", "other"); code != 2 {
t.Fatal("conflicting positional + --account must be a usage error")
}
}
func b64Key() string { func b64Key() string {
// 32 zero bytes, base64. // 32 zero bytes, base64.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
} }
func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
func TestTopLevelLsAlias(t *testing.T) {
adminEnv(t)
// `ls` with no --account must hit the same usage path as `list` (CodeUsage
// envelope on stdout, exit 2) — proving it routed to the agent list command.
code, out, _ := run(t, "ls")
if code != 2 || !strings.Contains(out, "account") {
t.Fatalf("ls should alias list (usage about --account): code=%d out=%q", code, out)
}
}
// TestAccountLsAliasAgentRole verifies that `account ls` is treated as an agent
// command (not admin) so a caller with only EMCLI_KEY can use it and gets the
// same reduced-JSON envelope as `account list`.
func TestAccountLsAliasAgentRole(t *testing.T) {
adminEnv(t) // sets up both keys + initialized temp DB
run(t, "account", "add", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
// Drop the admin key — caller is agent-only.
t.Setenv("EMCLI_ADMIN_KEY", "")
// `account ls` must succeed with the reduced JSON view, same as `account list`.
code, out, errOut := run(t, "account", "ls")
if code != 0 {
t.Fatalf("agent account ls should succeed: code=%d out=%q err=%q", code, out, errOut)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("account ls output is not JSON: %v\n%s", err, out)
}
if env["error"] == true {
t.Fatalf("account ls returned error envelope: %s", out)
}
// The agent view must not leak IMAP host or login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("account ls leaked host/username:\n%s", out)
}
}
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func dbBytes(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read db: %v", err)
}
return b
}
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
// command, and the DB must be unchanged after it tries.
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, err := store.Open(db)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
ak, err := crypto.AdminKeyFromEnv()
if err != nil {
t.Fatalf("AdminKeyFromEnv: %v", err)
}
gk, err := crypto.AgentKeyFromEnv()
if err != nil {
t.Fatalf("AgentKeyFromEnv: %v", err)
}
if err := st.InitKeys(ak, gk); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
// Simulate the agent's environment: admin key absent.
t.Setenv("EMCLI_ADMIN_KEY", "")
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
for _, args := range adminAttempts {
code, out, errOut := run(t, args...)
if code == 0 {
t.Errorf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
continue
}
}
if !bytes.Equal(before, dbBytes(t, db)) {
t.Fatal("DB changed despite all admin commands being refused")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string,
} }
msg := mail.OutgoingMessage{ msg := mail.OutgoingMessage{
From: acc.Username, To: to, Cc: cc, Bcc: bcc, From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body, Subject: subject, BodyText: body,
} }
recipients := msg.Recipients() recipients := msg.Recipients()
+30 -1
View File
@@ -13,10 +13,13 @@ import (
// mailer (for reply-to). The named account is created per the supplied template. // mailer (for reply-to). The named account is created per the supplied template.
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) { func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
t.Helper() t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey()) st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil { if err != nil {
t.Fatalf("store: %v", err) t.Fatalf("store: %v", err)
} }
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() }) t.Cleanup(func() { st.Close() })
if _, err := st.AddAccount(acc); err != nil { if _, err := st.AddAccount(acc); err != nil {
t.Fatalf("AddAccount: %v", err) t.Fatalf("AddAccount: %v", err)
@@ -136,6 +139,32 @@ func TestSendReplyToThreadsHeaders(t *testing.T) {
} }
} }
func TestSendUsesConfiguredFromAddress(t *testing.T) {
acc := rwAccount()
acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
d, sent, _ := sendDeps(t, acc, nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
if got := (*sent)[0].From; got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("From = %q, want configured from-address", got)
}
}
func TestSendFallsBackToUsernameAsFrom(t *testing.T) {
// rwAccount has no FromAddress, so From must be the login username.
d, sent, _ := sendDeps(t, rwAccount(), nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if got := (*sent)[0].From; got != "emcli@stevecliff.com" {
t.Fatalf("From = %q, want username fallback", got)
}
}
func TestSendReplyToFilteredSourceNotFound(t *testing.T) { func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
acc := rwAccount() acc := rwAccount()
acc.WhitelistInEnabled = true // inbound filter active acc.WhitelistInEnabled = true // inbound filter active
+24 -11
View File
@@ -1,4 +1,4 @@
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY. // Package crypto provides AES-256-GCM field encryption; keys are loaded from EMCLI_KEY (agent) or EMCLI_ADMIN_KEY (admin).
package crypto package crypto
import ( import (
@@ -7,28 +7,41 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
) )
var ( // keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
ErrNoKey = errors.New("EMCLI_KEY is not set") // named environment variable. Errors name the variable so callers get a
ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes") // role-appropriate message.
) func keyFromEnv(varName string) ([]byte, error) {
raw := os.Getenv(varName)
// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY.
func KeyFromEnv() ([]byte, error) {
raw := os.Getenv("EMCLI_KEY")
if raw == "" { if raw == "" {
return nil, ErrNoKey return nil, fmt.Errorf("%s is not set", varName)
} }
key, err := base64.StdEncoding.DecodeString(raw) key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 { if err != nil || len(key) != 32 {
return nil, ErrBadKey return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
} }
return key, nil return key, nil
} }
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
// NewDEK returns a fresh random 32-byte data-encryption key.
func NewDEK() ([]byte, error) {
dek := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
return dek, nil
}
func newGCM(key []byte) (cipher.AEAD, error) { func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
+29 -10
View File
@@ -3,6 +3,7 @@ package crypto
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"strings"
"testing" "testing"
) )
@@ -50,20 +51,38 @@ func TestOpenWrongKeyFails(t *testing.T) {
} }
} }
func TestKeyFromEnv(t *testing.T) { func TestAgentAndAdminKeyFromEnv(t *testing.T) {
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey())) good := base64.StdEncoding.EncodeToString(testKey())
k, err := KeyFromEnv()
if err != nil || len(k) != 32 { t.Setenv("EMCLI_KEY", good)
t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err) if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", good)
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
} }
t.Setenv("EMCLI_KEY", "") t.Setenv("EMCLI_ADMIN_KEY", "")
if _, err := KeyFromEnv(); err != ErrNoKey { if _, err := AdminKeyFromEnv(); err == nil ||
t.Fatalf("empty key: want ErrNoKey, got %v", err) !strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
} }
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort"))) t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := KeyFromEnv(); err != ErrBadKey { if _, err := AgentKeyFromEnv(); err == nil ||
t.Fatalf("short key: want ErrBadKey, got %v", err) !strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("short key: want length error, got %v", err)
}
}
func TestNewDEKIsRandom32(t *testing.T) {
a, err := NewDEK()
if err != nil || len(a) != 32 {
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
}
b, _ := NewDEK()
if bytes.Equal(a, b) {
t.Fatal("two DEKs must differ")
} }
} }
+13 -1
View File
@@ -46,6 +46,18 @@ func (m OutgoingMessage) Recipients() []string {
return out return out
} }
// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
// any display name. A display-name From (e.g. "Name <addr>") is a valid header
// but an invalid envelope sender, so it must be reduced to the bare address.
// Unparseable input is passed through unchanged (preserves prior behaviour for
// plain addresses).
func envelopeFrom(from string) string {
if a, err := gomail.ParseAddress(from); err == nil {
return a.Address
}
return from
}
func addrList(addrs []string) []*gomail.Address { func addrList(addrs []string) []*gomail.Address {
out := make([]*gomail.Address, 0, len(addrs)) out := make([]*gomail.Address, 0, len(addrs))
for _, a := range addrs { for _, a := range addrs {
@@ -163,7 +175,7 @@ func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
if err := c.Auth(auth); err != nil { if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err) return fmt.Errorf("smtp auth: %w", err)
} }
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil { if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
return fmt.Errorf("smtp send: %w", err) return fmt.Errorf("smtp send: %w", err)
} }
return c.Quit() return c.Quit()
+30
View File
@@ -100,6 +100,36 @@ func TestRecipientsCombinesAllFields(t *testing.T) {
} }
} }
func TestEnvelopeFromStripsDisplayName(t *testing.T) {
cases := map[string]string{
"Steve Cliff <me@stevecliff.com>": "me@stevecliff.com",
"me@stevecliff.com": "me@stevecliff.com",
"<me@stevecliff.com>": "me@stevecliff.com",
"not a valid address": "not a valid address", // unparseable ⇒ passthrough
}
for in, want := range cases {
if got := envelopeFrom(in); got != want {
t.Errorf("envelopeFrom(%q) = %q, want %q", in, got, want)
}
}
}
func TestBuildMIMEKeepsDisplayNameInHeader(t *testing.T) {
raw, err := BuildMIME(OutgoingMessage{
From: "Steve Cliff <me@stevecliff.com>",
To: []string{"you@example.com"},
Subject: "hi",
BodyText: "body",
Date: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("BuildMIME: %v", err)
}
if !strings.Contains(string(raw), "Steve Cliff") {
t.Fatalf("From header lost display name:\n%s", raw)
}
}
func TestReadHeaderParsesReferences(t *testing.T) { func TestReadHeaderParsesReferences(t *testing.T) {
raw := "From: a@x.com\r\n" + raw := "From: a@x.com\r\n" +
"To: b@x.com\r\n" + "To: b@x.com\r\n" +
+30
View File
@@ -3,6 +3,8 @@
package policy package policy
import ( import (
"errors"
"fmt"
"net/mail" "net/mail"
"strings" "strings"
) )
@@ -42,3 +44,31 @@ func MatchAddress(entries []string, addr string) bool {
} }
return false return false
} }
// ValidWhitelistAddress reports an error if s is not a usable whitelist entry.
// Accepted forms: a bare address "local@domain", or a domain wildcard "@domain".
// The domain must contain at least one dot. Display-name forms ("Bob <b@x>")
// are rejected because the store keeps the raw string, which would never match.
func ValidWhitelistAddress(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return errors.New("address must not be empty")
}
bad := fmt.Errorf("invalid address %q: expected local@domain or @domain", s)
if strings.HasPrefix(s, "@") {
domain := s[1:]
if domain == "" || strings.Contains(domain, "@") || !strings.Contains(domain, ".") {
return bad
}
return nil
}
addr, err := mail.ParseAddress(s)
if err != nil || !strings.EqualFold(addr.Address, s) {
return bad // parse failure or a display-name/extra-token form
}
at := strings.LastIndex(addr.Address, "@")
if at < 1 || !strings.Contains(addr.Address[at+1:], ".") {
return bad
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package policy
import "testing"
func TestValidWhitelistAddress(t *testing.T) {
good := []string{"tk555@protonmail.com", "a.b@sub.example.co.uk", "@example.com", "@sub.example.com"}
for _, s := range good {
if err := ValidWhitelistAddress(s); err != nil {
t.Errorf("ValidWhitelistAddress(%q) = %v, want nil", s, err)
}
}
bad := []string{"", " ", "notanaddress", "@", "@nodot", "a@nodot", "Bob <b@x.com>", "a@b@c.com"}
for _, s := range bad {
if err := ValidWhitelistAddress(s); err == nil {
t.Errorf("ValidWhitelistAddress(%q) = nil, want error", s)
}
}
}
+20 -9
View File
@@ -23,6 +23,7 @@ type Account struct {
SMTPSecurity string // tls | starttls SMTPSecurity string // tls | starttls
AuthType string // password | oauth2 AuthType string // password | oauth2
Username string Username string
FromAddress string // send-as identity; blank ⇒ fall back to Username
Password string // decrypted; empty in ListAccounts Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool WhitelistInEnabled bool
WhitelistOutEnabled bool WhitelistOutEnabled bool
@@ -30,6 +31,15 @@ type Account struct {
ProcessBacklog bool ProcessBacklog bool
} }
// SendFrom returns the From identity for outgoing mail, falling back to the
// login username when no explicit from-address is configured.
func (a Account) SendFrom() string {
if a.FromAddress != "" {
return a.FromAddress
}
return a.Username
}
func (s *Store) AddAccount(a Account) (int64, error) { func (s *Store) AddAccount(a Account) (int64, error) {
var encPw []byte var encPw []byte
if a.Password != "" { if a.Password != "" {
@@ -42,12 +52,12 @@ func (s *Store) AddAccount(a Account) (int64, error) {
res, err := s.db.Exec(` res, err := s.db.Exec(`
INSERT INTO accounts INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, (name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username, auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog) enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username, a.AuthType, a.Username, nullStr(a.FromAddress),
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled), encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog)) nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
if err != nil { if err != nil {
@@ -59,7 +69,7 @@ func (s *Store) AddAccount(a Account) (int64, error) {
func (s *Store) GetAccount(name string) (Account, error) { func (s *Store) GetAccount(name string) (Account, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username, auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts WHERE name = ?`, name) FROM accounts WHERE name = ?`, name)
a, encPw, err := scanAccount(row) a, encPw, err := scanAccount(row)
@@ -82,7 +92,7 @@ func (s *Store) GetAccount(name string) (Account, error) {
func (s *Store) ListAccounts() ([]Account, error) { func (s *Store) ListAccounts() ([]Account, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security, SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username, auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts ORDER BY name`) FROM accounts ORDER BY name`)
if err != nil { if err != nil {
@@ -108,12 +118,12 @@ func (s *Store) UpdateAccount(a Account) error {
// Build the SET clause, conditionally including secret columns. // Build the SET clause, conditionally including secret columns.
set := `mode=?, imap_host=?, imap_port=?, imap_security=?, set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
smtp_host=?, smtp_port=?, smtp_security=?, smtp_host=?, smtp_port=?, smtp_security=?,
auth_type=?, username=?, auth_type=?, username=?, from_address=?,
whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?` whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
args := []any{ args := []any{
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username, a.AuthType, a.Username, nullStr(a.FromAddress),
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled), b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog), nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
} }
@@ -154,14 +164,14 @@ func scanAccount(sc scanner) (Account, []byte, error) {
var ( var (
a Account a Account
encPw []byte encPw []byte
subj, smtpHost, smtpSec sql.NullString subj, smtpHost, smtpSec, fromAddr sql.NullString
smtpPort sql.NullInt64 smtpPort sql.NullInt64
wlIn, wlOut int wlIn, wlOut int
backlog int backlog int
) )
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity, err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&smtpHost, &smtpPort, &smtpSec, &smtpHost, &smtpPort, &smtpSec,
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog) &a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog)
if err != nil { if err != nil {
return Account{}, nil, err return Account{}, nil, err
} }
@@ -172,6 +182,7 @@ func scanAccount(sc scanner) (Account, []byte, error) {
a.WhitelistOutEnabled = wlOut != 0 a.WhitelistOutEnabled = wlOut != 0
a.ProcessBacklog = backlog != 0 a.ProcessBacklog = backlog != 0
a.SubjectRegex = subj.String a.SubjectRegex = subj.String
a.FromAddress = fromAddr.String
return a, encPw, nil return a, encPw, nil
} }
+27
View File
@@ -84,3 +84,30 @@ func TestListAccountsOmitsSecrets(t *testing.T) {
t.Fatal("ListAccounts must not return secrets") t.Fatal("ListAccounts must not return secrets")
} }
} }
func TestSendFromFallsBackToUsername(t *testing.T) {
a := Account{Username: "login@example.com"}
if got := a.SendFrom(); got != "login@example.com" {
t.Fatalf("blank from-address should fall back to username, got %q", got)
}
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if got := a.SendFrom(); got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("set from-address should win, got %q", got)
}
}
func TestAddGetAccountRoundTripsFromAddress(t *testing.T) {
s := openTemp(t)
a := sampleAccount()
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if _, err := s.AddAccount(a); err != nil {
t.Fatalf("AddAccount: %v", err)
}
got, err := s.GetAccount("work")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FromAddress not round-tripped: %q", got.FromAddress)
}
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
// Role selects which DEK wrap slot a command may unlock.
type Role int
const (
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
RoleAdmin // all commands; uses dek_wrap_admin ONLY
)
const (
settingDEKWrapAdmin = "dek_wrap_admin"
settingDEKWrapAgent = "dek_wrap_agent"
)
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
// dbPath returns the file path SQLite opened (used by tests to re-open).
func (s *Store) dbPath() string {
var p string
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
return p
}
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
// both wrap slots, and unlocks the store. If the slots already exist it does
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
// Already initialised: the DEK and both wrap slots already exist, so the
// agent key is not consumed here. Only the admin key is used to unlock the
// existing dek_wrap_admin slot; the DEK itself is preserved unchanged.
return s.Unlock(RoleAdmin, adminKey, nil)
}
dek, err := crypto.NewDEK()
if err != nil {
return err
}
wrapAdmin, err := crypto.Seal(adminKey, dek)
if err != nil {
return err
}
wrapAgent, err := crypto.Seal(agentKey, dek)
if err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
return err
}
s.key = dek
return nil
}
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
// falls back to the admin slot only when no agent key is supplied.
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
switch role {
case RoleAdmin:
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
case RoleAgent:
if len(agentKey) > 0 {
return s.unlockSlot(settingDEKWrapAgent, agentKey)
}
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
default:
return fmt.Errorf("unknown role %d", role)
}
}
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
if len(kek) == 0 {
return ErrLocked
}
enc, err := s.GetSetting(settingKey)
if errors.Is(err, sql.ErrNoRows) {
return ErrLocked
}
if err != nil {
return err
}
blob, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
}
dek, err := crypto.Open(kek, blob)
if err != nil {
return errors.New("wrong key for this DB")
}
s.key = dek
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"path/filepath"
"testing"
)
func k(b byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = b
}
return key
}
func tempStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
admin, agent := k(0xAA), k(0xBB)
st := tempStore(t)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("InitKeys: %v", err)
}
// Seal a password under the DEK that InitKeys set.
if _, err := st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
// Re-open and unlock via the AGENT slot.
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
t.Fatalf("Unlock(agent): %v", err)
}
got, err := st2.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
}
st2.Close()
// Unlock via the ADMIN slot recovers the same DEK.
st3, _ := Open(path)
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
t.Fatalf("Unlock(admin): %v", err)
}
got3, err := st3.GetAccount("a")
if err != nil || got3.Password != "pw" {
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
}
st3.Close()
}
func TestUnlockWrongKeyFails(t *testing.T) {
st := tempStore(t)
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatal(err)
}
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
t.Fatal("Unlock with wrong admin key must fail")
}
st2.Close()
}
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
// RoleAdmin must use the admin slot; passing the agent key as the admin
// key must fail — there is no fallback to the agent slot.
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
t.Fatal("agent key must not unlock the admin slot")
}
}
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
})
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("re-InitKeys: %v", err)
}
got, err := st.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
}
}
+3 -2
View File
@@ -1,8 +1,8 @@
package store package store
const schemaVersion = 1 const schemaVersion = 2
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS. // schemaSQL is the full current schema. All statements are idempotent via IF NOT EXISTS.
const schemaSQL = ` const schemaSQL = `
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS accounts (
smtp_security TEXT, smtp_security TEXT,
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')), auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
username TEXT NOT NULL, username TEXT NOT NULL,
from_address TEXT,
enc_password BLOB, enc_password BLOB,
enc_oauth_client_id BLOB, enc_oauth_client_id BLOB,
enc_oauth_client_secret BLOB, enc_oauth_client_secret BLOB,
+47 -6
View File
@@ -8,6 +8,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -19,7 +20,9 @@ type Store struct {
} }
// Open opens (creating if needed) the DB at path and applies the schema. // Open opens (creating if needed) the DB at path and applies the schema.
func Open(path string, key []byte) (*Store, error) { // The store opens LOCKED: call InitKeys (first run) or Unlock before any
// secret read/write.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create db dir: %w", err) return nil, fmt.Errorf("create db dir: %w", err)
} }
@@ -39,22 +42,60 @@ func Open(path string, key []byte) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("apply schema: %w", err) return nil, fmt.Errorf("apply schema: %w", err)
} }
s := &Store{db: db, key: key} s := &Store{db: db}
if _, err := s.GetSetting("schema_version"); err != nil { if err := s.migrate(); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
db.Close() db.Close()
return nil, err return nil, err
} }
}
return s, nil return s, nil
} }
// migrate brings an existing database up to the current schemaVersion. A brand-
// new database (no schema_version yet) already has every column from schemaSQL,
// so it is simply stamped at the current version. Each older version runs its
// forward step. The version gate makes every step idempotent across reopens.
func (s *Store) migrate() error {
v, err := s.GetSetting("schema_version")
if err != nil {
// Fresh database: schemaSQL created all columns already.
return s.SetSetting("schema_version", strconv.Itoa(schemaVersion))
}
var ver int
ver, err = strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid schema_version %q: %w", v, err)
}
if ver < 2 {
if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil {
return fmt.Errorf("migrate to v2: %w", err)
}
if err := s.SetSetting("schema_version", "2"); err != nil {
return err
}
}
return nil
}
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
// expandUserHome replaces a leading "~" or "~/" in p with the user's home
// directory. Only a leading tilde is expanded (the usual shell convention) —
// "~user" and a tilde elsewhere in the path are left untouched. This guards
// against an EMCLI_DB set to a literal "~/..." (no shell to expand it), which
// would otherwise be opened relative to the cwd and create a stray "~" dir.
func expandUserHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
}
}
return p
}
// DefaultDBPath resolves EMCLI_DB or the per-OS default location. // DefaultDBPath resolves EMCLI_DB or the per-OS default location.
func DefaultDBPath() (string, error) { func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" { if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil return expandUserHome(p), nil
} }
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" { if dir := os.Getenv("AppData"); dir != "" {
+114 -11
View File
@@ -1,49 +1,89 @@
package store package store
import ( import (
"database/sql"
"os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func testKey() []byte { // A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
k := make([]byte, 32) // tilde (no shell to expand it) can't be opened relative to the cwd and
for i := range k { // silently create a stray "~" directory.
k[i] = byte(i) func TestDefaultDBPathExpandsLeadingTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cases := map[string]string{
"~/.config/emcli/emcli.db": filepath.Join(home, ".config", "emcli", "emcli.db"),
"~": home,
}
for in, want := range cases {
t.Setenv("EMCLI_DB", in)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", in, err)
}
if got != want {
t.Fatalf("EMCLI_DB=%q -> %q, want %q", in, got, want)
}
if strings.Contains(got, "~") {
t.Fatalf("EMCLI_DB=%q left a literal tilde: %q", in, got)
}
} }
return k
} }
// openTemp opens a fresh store in a temp dir. // A non-leading tilde or "~user" is NOT a path we should rewrite — leave it be.
func TestDefaultDBPathLeavesOtherPathsUntouched(t *testing.T) {
for _, p := range []string{"/var/lib/emcli.db", "./rel/emcli.db", "~user/db"} {
t.Setenv("EMCLI_DB", p)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", p, err)
}
if got != p {
t.Fatalf("EMCLI_DB=%q was rewritten to %q", p, got)
}
}
}
// openTemp opens a fresh store in a temp dir and initialises keys so that
// account tests (which do crypto) work without needing their own setup.
func openTemp(t *testing.T) *Store { func openTemp(t *testing.T) *Store {
t.Helper() t.Helper()
p := filepath.Join(t.TempDir(), "emcli.db") p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey()) s, err := Open(p)
if err != nil { if err != nil {
t.Fatalf("Open: %v", err) t.Fatalf("Open: %v", err)
} }
if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { s.Close() }) t.Cleanup(func() { s.Close() })
return s return s
} }
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) { func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db") p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey()) s, err := Open(p)
if err != nil { if err != nil {
t.Fatalf("first Open: %v", err) t.Fatalf("first Open: %v", err)
} }
v, err := s.GetSetting("schema_version") v, err := s.GetSetting("schema_version")
if err != nil || v != "1" { if err != nil || v != "2" {
t.Fatalf("schema_version: %q err=%v", v, err) t.Fatalf("schema_version: %q err=%v", v, err)
} }
s.Close() s.Close()
// Re-open: must not error or duplicate. // Re-open: must not error or duplicate.
s2, err := Open(p, testKey()) s2, err := Open(p)
if err != nil { if err != nil {
t.Fatalf("second Open: %v", err) t.Fatalf("second Open: %v", err)
} }
defer s2.Close() defer s2.Close()
if v, _ := s2.GetSetting("schema_version"); v != "1" { if v, _ := s2.GetSetting("schema_version"); v != "2" {
t.Fatalf("schema_version after reopen: %q", v) t.Fatalf("schema_version after reopen: %q", v)
} }
} }
@@ -108,3 +148,66 @@ func TestForeignKeyCascade(t *testing.T) {
t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err) t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err)
} }
} }
func TestOpenMigratesV1AddsFromAddress(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
// Hand-build a v1 database: accounts table WITHOUT from_address, a settings
// table pinned at schema_version=1, and one pre-existing account row.
raw, err := sql.Open("sqlite", p)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
const v1Schema = `
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
mode TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_security TEXT NOT NULL,
smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
enc_password BLOB,
enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB,
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
subject_regex TEXT,
process_backlog INTEGER NOT NULL DEFAULT 0
);
INSERT INTO settings(key,value) VALUES ('schema_version','1');
INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username)
VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com');
`
if _, err := raw.Exec(v1Schema); err != nil {
t.Fatalf("seed v1 schema: %v", err)
}
raw.Close()
// Open via the store: the migration must add from_address and bump to v2.
s, err := Open(p)
if err != nil {
t.Fatalf("Open (migrate): %v", err)
}
defer s.Close()
if v, _ := s.GetSetting("schema_version"); v != "2" {
t.Fatalf("schema_version after migrate: %q, want 2", v)
}
// ListAccounts SELECTs from_address; it would error if the column were missing.
accs, err := s.ListAccounts()
if err != nil {
t.Fatalf("ListAccounts after migrate: %v", err)
}
if len(accs) != 1 {
t.Fatalf("want 1 account after migrate, got %d", len(accs))
}
if accs[0].FromAddress != "" {
t.Fatalf("legacy account FromAddress should be empty, got %q", accs[0].FromAddress)
}
if got := accs[0].SendFrom(); got != "login@example.com" {
t.Fatalf("legacy account should send from username, got %q", got)
}
}
+15
View File
@@ -86,3 +86,18 @@ func (s *Store) ListWhitelist(account string, dir Direction) ([]string, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
// SetWhitelistEnabled toggles one account's per-direction whitelist-enabled
// flag, leaving the address list and all other fields untouched.
func (s *Store) SetWhitelistEnabled(account string, dir Direction, enabled bool) error {
col := "whitelist_in_enabled"
if dir == DirOut {
col = "whitelist_out_enabled"
}
id, err := s.accountID(account)
if err != nil {
return err
}
_, err = s.db.Exec(fmt.Sprintf("UPDATE accounts SET %s=? WHERE id=?", col), b2i(enabled), id)
return err
}
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"path/filepath"
"testing"
)
func TestSetWhitelistEnabled(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
k := make([]byte, 32)
if err := st.InitKeys(k, k); err != nil {
t.Fatalf("InitKeys: %v", err)
}
if _, err := st.AddAccount(Account{Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", AuthType: "password", Username: "u@x.com"}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
if err := st.SetWhitelistEnabled("a", DirIn, true); err != nil {
t.Fatalf("SetWhitelistEnabled: %v", err)
}
got, err := st.GetAccount("a")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if !got.WhitelistInEnabled || got.WhitelistOutEnabled {
t.Fatalf("flags wrong: in=%v out=%v", got.WhitelistInEnabled, got.WhitelistOutEnabled)
}
if err := st.SetWhitelistEnabled("missing", DirIn, true); err == nil {
t.Fatal("enabling on a missing account must error")
}
}
+26 -14
View File
@@ -6,6 +6,7 @@ package tui
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/mail"
"strconv" "strconv"
"strings" "strings"
@@ -22,10 +23,24 @@ type Fields struct {
IMAPHost, IMAPPort, IMAPSecurity string IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string Username, Password string
WhitelistIn, WhitelistOut, ProcessBacklog bool FromAddress string
ProcessBacklog bool
SubjectRegex string SubjectRegex string
} }
// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
// address (bare or "Display Name <addr>"). A blank value is valid: sending
// falls back to the login username.
func ValidFromAddress(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
if _, err := mail.ParseAddress(s); err != nil {
return errors.New("from address must be a valid email address or \"Name <email>\"")
}
return nil
}
func validSecurity(s string) bool { return s == "tls" || s == "starttls" } func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
// Validate checks required fields, enum fields, and numeric ports. RW accounts // Validate checks required fields, enum fields, and numeric ports. RW accounts
@@ -60,6 +75,9 @@ func (f Fields) Validate() error {
return errors.New("smtp port must be a number") return errors.New("smtp port must be a number")
} }
} }
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
return nil return nil
} }
@@ -71,7 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
Name: strings.TrimSpace(f.Name), Mode: f.Mode, Name: strings.TrimSpace(f.Name), Mode: f.Mode,
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity, IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password, AuthType: "password", Username: f.Username, Password: f.Password,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut, FromAddress: f.FromAddress,
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog, SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
} }
if f.Mode == "RW" { if f.Mode == "RW" {
@@ -96,8 +114,7 @@ func FieldsFromAccount(a store.Account) Fields {
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity, IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity, SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username, Username: a.Username,
WhitelistIn: a.WhitelistInEnabled, FromAddress: a.FromAddress,
WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog, ProcessBacklog: a.ProcessBacklog,
SubjectRegex: a.SubjectRegex, SubjectRegex: a.SubjectRegex,
} }
@@ -122,9 +139,8 @@ var fieldDefs = []fieldDef{
{key: "smtp_port", label: "SMTP port (RW)"}, {key: "smtp_port", label: "SMTP port (RW)"},
{key: "smtp_security", label: "SMTP security (tls/starttls)"}, {key: "smtp_security", label: "SMTP security (tls/starttls)"},
{key: "username", label: "Username"}, {key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true}, {key: "password", label: "Password", password: true},
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
{key: "whitelist_out", label: "Whitelist outbound (y/n)", isBool: true},
{key: "process_backlog", label: "Process backlog (y/n)", isBool: true}, {key: "process_backlog", label: "Process backlog (y/n)", isBool: true},
{key: "subject_regex", label: "Subject regex (optional)"}, {key: "subject_regex", label: "Subject regex (optional)"},
} }
@@ -164,12 +180,10 @@ func fieldValue(f Fields, key string) string {
return f.SMTPSecurity return f.SMTPSecurity
case "username": case "username":
return f.Username return f.Username
case "from_address":
return f.FromAddress
case "password": case "password":
return f.Password return f.Password
case "whitelist_in":
return boolStr(f.WhitelistIn)
case "whitelist_out":
return boolStr(f.WhitelistOut)
case "process_backlog": case "process_backlog":
return boolStr(f.ProcessBacklog) return boolStr(f.ProcessBacklog)
case "subject_regex": case "subject_regex":
@@ -249,12 +263,10 @@ func (m AccountForm) collect() Fields {
f.SMTPSecurity = strings.ToLower(v) f.SMTPSecurity = strings.ToLower(v)
case "username": case "username":
f.Username = v f.Username = v
case "from_address":
f.FromAddress = v
case "password": case "password":
f.Password = m.inputs[i].Value() // do not trim a password f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in":
f.WhitelistIn = parseBool(v)
case "whitelist_out":
f.WhitelistOut = parseBool(v)
case "process_backlog": case "process_backlog":
f.ProcessBacklog = parseBool(v) f.ProcessBacklog = parseBool(v)
case "subject_regex": case "subject_regex":
+48 -4
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"strings"
"testing" "testing"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -69,7 +70,6 @@ func TestFieldsValidateRWNeedsSMTP(t *testing.T) {
func TestFieldsToAccount(t *testing.T) { func TestFieldsToAccount(t *testing.T) {
f := validFields() f := validFields()
f.WhitelistIn = true
f.SubjectRegex = "^urgent" f.SubjectRegex = "^urgent"
acc, pwSet := f.ToAccount() acc, pwSet := f.ToAccount()
if !pwSet { if !pwSet {
@@ -78,9 +78,12 @@ func TestFieldsToAccount(t *testing.T) {
if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 { if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 {
t.Fatalf("account not assembled: %+v", acc) t.Fatalf("account not assembled: %+v", acc)
} }
if acc.AuthType != "password" || !acc.WhitelistInEnabled || acc.SubjectRegex != "^urgent" { if acc.AuthType != "password" || acc.SubjectRegex != "^urgent" {
t.Fatalf("account flags wrong: %+v", acc) t.Fatalf("account flags wrong: %+v", acc)
} }
if acc.WhitelistInEnabled || acc.WhitelistOutEnabled {
t.Fatal("new accounts must have whitelist flags false (managed via whitelist group)")
}
if acc.Password != "pw" { if acc.Password != "pw" {
t.Fatalf("password not carried: %q", acc.Password) t.Fatalf("password not carried: %q", acc.Password)
} }
@@ -99,10 +102,10 @@ func TestFieldsFromAccountRoundTrip(t *testing.T) {
a := store.Account{ a := store.Account{
Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls", Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls",
SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls", SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls",
Username: "u@x.com", WhitelistOutEnabled: true, SubjectRegex: "re:", Username: "u@x.com", SubjectRegex: "re:",
} }
f := FieldsFromAccount(a) f := FieldsFromAccount(a)
if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || !f.WhitelistOut || f.SubjectRegex != "re:" { if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || f.SubjectRegex != "re:" {
t.Fatalf("FieldsFromAccount wrong: %+v", f) t.Fatalf("FieldsFromAccount wrong: %+v", f)
} }
// Password is never read back from an account. // Password is never read back from an account.
@@ -157,3 +160,44 @@ func TestAccountFormCancel(t *testing.T) {
t.Fatal("esc should cancel the form") t.Fatal("esc should cancel the form")
} }
} }
func TestValidateRejectsBadFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "not an address"
if err := f.Validate(); err == nil {
t.Fatal("malformed from-address should fail validation")
}
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
if err := f.Validate(); err != nil {
t.Fatalf("display-name from-address should validate: %v", err)
}
f.FromAddress = "me@stevecliff.com"
if err := f.Validate(); err != nil {
t.Fatalf("bare from-address should validate: %v", err)
}
f.FromAddress = "" // blank ⇒ fall back, always valid
if err := f.Validate(); err != nil {
t.Fatalf("blank from-address should validate: %v", err)
}
}
func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
acc, _ := f.ToAccount()
if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
}
back := FieldsFromAccount(acc)
if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
}
}
func TestFormHasNoWhitelistFields(t *testing.T) {
f := NewAccountForm(Fields{}, false)
out := f.View()
if strings.Contains(strings.ToLower(out), "whitelist") {
t.Fatalf("account form must not mention whitelists:\n%s", out)
}
}
+25 -10
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `EMCLI_VERSION` | `v0.4.1` | Release tag to fetch | | `EMCLI_VERSION` | `v0.6.0` | Release tag to fetch |
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL | | `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location | | `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
@@ -65,32 +65,47 @@ git clone https://gitea.dcglab.co.uk/steve/emcli && cd emcli
CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
``` ```
## 3. Confirm the encryption key is present ## 3. Confirm the agent key is present
emcli needs `EMCLI_KEY` (a base64-encoded 32-byte AES key) to touch its database. For agent use, emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises
**the orchestrator that launched you provides it** in the environment. `list`, `get`, `search`, `ack`, `send`, and `doctor`. Admin commands require `EMCLI_ADMIN_KEY`,
which the human holds — attempting admin commands with only `EMCLI_KEY` is refused by `emcli`.
For agent use, **the orchestrator that launched you provides `EMCLI_KEY`** in the environment.
- Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`. - Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`.
- **Never** read, print, log, pass as an argument, or generate this value. - **Never** read, print, log, pass as an argument, or generate this value.
- If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by - If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by
your orchestrator; I can't read or create it for you." your orchestrator; I can't read or create it for you."
(For a human setting emcli up the first time: generate one with `head -c 32 /dev/urandom | base64` (For a human setting emcli up the first time: generate both keys with
and store it securely. Account creation and other admin is the human's job — see the project's `head -c 32 /dev/urandom | base64` (once per key) and store them securely; then run `emcli init`
with both keys exported. Account creation and other admin is the human's job — see the project's
`USER-MANUAL.md`.) `USER-MANUAL.md`.)
## 4. Find the account(s) ## 4. Find the account(s)
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use. If the You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
user permits running admin commands, `emcli doctor` lists the configured accounts and checks that with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
each one connects and authenticates: with one entry per account:
```bash
emcli account list
# {"error":false,"error_detail":{},"data":{"accounts":[
# {"name":"gmail","from":"me@gmail.com","can_send":true},
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
```
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
(also an agent command) checks that accounts connect and authenticate:
```bash ```bash
emcli doctor # all accounts emcli doctor # all accounts
emcli doctor --account gmail emcli doctor --account gmail
``` ```
Otherwise, just take the account name from the user and start with the workflow in `SKILL.md`. Then start with the workflow in `SKILL.md`.
## You're set up ## You're set up
+13 -7
View File
@@ -17,10 +17,12 @@ sets its exit code to match.
## Security model — read this first ## Security model — read this first
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`. Account setup, - **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
passwords, whitelists, and config are the **user's** job (admin commands) — do not run or suggest `account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
running `account`, `whitelist`, `config`, `init`, or `doctor` unless the user explicitly asks you authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
to help administer. whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
`init`. `emcli` will refuse those with a privilege error.
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched - **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
missing, stop and tell the user (see "Files & first run"). missing, stop and tell the user (see "Files & first run").
@@ -43,7 +45,8 @@ https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
**Per-session preflight** (quick): run `emcli version`; if it's not found, set up via **Per-session preflight** (quick): run `emcli version`; if it's not found, set up via
`AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if `AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if
empty, tell the user their orchestrator must provide it. Then get the account name from the user. empty, tell the user their orchestrator must provide `EMCLI_KEY` (the agent key). Then get the
account name from the user.
## How to read every result ## How to read every result
@@ -117,6 +120,7 @@ read-only — tell the user; do not attempt another account without their say-so
| `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search | | `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed | | `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply | | `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g. Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output. `2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
@@ -139,7 +143,9 @@ The user configures these; you cannot change them and shouldn't try.
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user. - ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
- ✅ `get` to read, then `ack` only after you've truly processed a message. - ✅ `get` to read, then `ack` only after you've truly processed a message.
- ✅ Ask the user for the account name; keep bodies plain text. - ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password. - ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`init`) unless asked to help set up. - ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy. - ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh # bash install.sh
# #
# Environment overrides: # Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.4.1) # EMCLI_VERSION release tag to fetch (default: v0.6.0)
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli) # EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin) # EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
# #
# Release assets follow this naming scheme: # Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.4.1_linux_amd64 # emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.6.0_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset) # checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.4.1}" VERSION="${EMCLI_VERSION:-v0.6.0}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}" BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}" INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
+53 -17
View File
@@ -62,23 +62,50 @@ The binary is organized into independently testable packages:
human-readable/TUI). human-readable/TUI).
### Trust boundary ### Trust boundary
- The agent invokes only the **agent commands** (Section 7.1).
- `EMCLI_KEY` is supplied by the environment/orchestrator that launches `emcli`, never as Two keys enforce a hard privilege split — this is not convention; it is structurally enforced by
an argument the agent constructs. The agent has no command that reveals secret values. the DEK-wrapping scheme:
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has
no other path to the mail servers. - **`EMCLI_ADMIN_KEY`** — base64-encoded 32-byte key held by the human operator. Authorises ALL
commands. Admin commands unwrap the DEK from the `dek_wrap_admin` slot only; there is no fallback
to the agent slot. A process holding only `EMCLI_KEY` cannot run admin commands.
- **`EMCLI_KEY`** — base64-encoded 32-byte key supplied to the agent orchestrator. Authorises agent
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
a process with only the admin key can also run agent commands.
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
If a process holding only `EMCLI_KEY` attempts an admin command, `emcli` exits with:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
(An agent command with no key set at all yields a different `config` error: `EMCLI_KEY is not set`.)
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
constructs. The agent has no command that reveals secret values.
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
path to the mail servers.
## 5. Configuration & secrets ## 5. Configuration & secrets
- **Encryption key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). If - **Admin key:** `EMCLI_ADMIN_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for
absent or malformed, every command that touches the DB fails closed with an error admin commands and for `init`. If absent or malformed when an admin command is attempted, the
envelope; no plaintext fallback. command fails with `emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
- **Agent key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for agent
commands. If absent or malformed, every agent command fails closed with a `config` error envelope;
no plaintext fallback. `EMCLI_ADMIN_KEY` is accepted as a fallback for agent commands when
`EMCLI_KEY` is not set.
- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db` - **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db`
(`%AppData%\emcli\emcli.db` on Windows). (`%AppData%\emcli\emcli.db` on Windows).
- **Field-level encryption:** secret columns are stored as AES-256-GCM ciphertext with a - **Envelope encryption (DEK):** `emcli init` generates a random data-encryption key (DEK) that
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains protects all account secrets. The DEK is stored in the `settings` table sealed under both keys:
plaintext for debuggability. Decryption with the wrong key fails (GCM auth tag) and is - `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY` (AES-256-GCM).
surfaced as an error, never silently ignored. - `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY` (AES-256-GCM).
The DEK is never written in cleartext. Admin commands unwrap from `dek_wrap_admin` only; agent
commands unwrap from `dek_wrap_agent` (or `dek_wrap_admin` if only the admin key is present).
There is no cross-slot fallback for admin commands — a holder of only `EMCLI_KEY` cannot unwrap
the admin DEK slot.
- **`init` idempotency:** re-running `emcli init` does not regenerate the DEK; the existing wrapped
DEK rows are preserved.
- **Field-level encryption:** secret columns are encrypted with the DEK using AES-256-GCM with a
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains plaintext for
debuggability. Decryption with the wrong key fails (GCM auth tag) and is surfaced as an error,
never silently ignored.
Secret columns: account password, OAuth client secret, OAuth refresh token. Secret columns: account password, OAuth client secret, OAuth refresh token.
@@ -142,7 +169,9 @@ audit_log
settings settings
key TEXT PK key TEXT PK
value TEXT value TEXT
-- includes: audit_retention_days, schema_version -- includes: audit_retention_days, schema_version,
-- dek_wrap_admin (DEK sealed under EMCLI_ADMIN_KEY),
-- dek_wrap_agent (DEK sealed under EMCLI_KEY)
``` ```
Notes: Notes:
@@ -216,8 +245,11 @@ command that advances read state is `ack`.
### 7.2 Admin commands (human-readable / TUI) ### 7.2 Admin commands (human-readable / TUI)
- **`emcli init`** — TUI flow: creates the DB (generating schema), adds the first account, Require `EMCLI_ADMIN_KEY`.
and runs OAuth consent if the account is OAuth2.
- **`emcli init`** — TUI flow: creates the DB (generating schema + DEK), adds the first account,
and runs OAuth consent if the account is OAuth2. Requires both `EMCLI_ADMIN_KEY` and `EMCLI_KEY`
(writes both DEK wrap slots). Idempotent — re-running does not regenerate the DEK.
- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a - **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a
table (never secrets). `account add` accepts `--process-backlog` (default off) which sets table (never secrets). `account add` accepts `--process-backlog` (default off) which sets
the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID
@@ -225,8 +257,12 @@ command that advances read state is `ack`.
- **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries. - **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries.
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`). - **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
- **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries. - **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries.
- **`emcli doctor`** — verifies `EMCLI_KEY` is present and valid, the DB opens, and each
account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics. ### 7.2a `doctor` — agent-role diagnostics
`doctor` is authorised by `EMCLI_KEY` (or `EMCLI_ADMIN_KEY`). It verifies the key is present and
valid, the DB opens, and each account's IMAP/SMTP connectivity and auth succeed. Prints
human-readable diagnostics. Can be run by the agent or by a human; does not require admin privilege.
### 7.3 Defaults & limits ### 7.3 Defaults & limits
- `list --limit` default: 50; maximum: 500. - `list --limit` default: 50; maximum: 500.