27 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
32 changed files with 3584 additions and 221 deletions
+82 -44
View File
@@ -33,8 +33,9 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## 1. Key concepts
**Two kinds of commands.**
- **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.
- **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.)
- **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
@@ -124,8 +125,12 @@ DEK for an admin command, even if it somehow knows the agent key.
| Command | Role required |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` 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
@@ -171,17 +176,18 @@ emcli account add # opens the form
Or with flags (good for scripting):
```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 \
--smtp-host smtp.example.com --smtp-port 465 --smtp-security tls \
--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:**
| Flag | Default | Notes |
|---|---|---|
| `--name` | — | Account name the agent will use (required) |
| `--mode` | `RO` | `RO` (read-only) or `RW` (read + send) |
| `--imap-host` | — | IMAP server (required) |
| `--imap-port` | `993` | |
@@ -193,10 +199,10 @@ emcli account add --name work --mode RW \
| `--password` | — | Login password or app password |
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
| `--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) |
To enable whitelisting after adding an account, use `whitelist enable` (section 6).
**`--process-backlog`.** When `emcli` first sees a folder:
- **off (default):** existing mail is treated as already handled — `list --new` starts empty and
only mail that arrives *after* this point counts as new.
@@ -214,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):
```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 \
--smtp-host smtp.gmail.com --smtp-port 465 --smtp-security tls \
--username you@gmail.com --password 'xxxxxxxxxxxxxxxx'
@@ -228,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:
```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 \
--smtp-host mail.yourdomain.com --smtp-port 465 --smtp-security tls \
--username you@yourdomain.com --password 'your-password'
@@ -247,28 +253,42 @@ confirm.
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):
```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
`--password` out keeps the existing password.
```bash
emcli account edit --name 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 work --mode RW --smtp-host smtp.example.com --smtp-port 587 --smtp-security starttls
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
> toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit --name X`
> with no other flags), or the `whitelist` commands in section 6.
```bash
emcli account edit work --from 'Work Team <you@yourdomain.com>' # set the send-as address
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
emcli account remove --name work --yes
emcli account remove work --yes
```
---
@@ -281,24 +301,30 @@ Set with `--mode RO|RW` on `account add`/`edit`. `RO` accounts reject every `sen
### Inbound whitelist
Enable it on the account (`--whitelist-in`, or the interactive form), then manage entries:
Manage entries, then enable filtering when ready:
```bash
emcli whitelist in add --account gmail --address boss@example.com
emcli whitelist in add --account gmail --address @partner.com
emcli whitelist in list --account gmail
emcli whitelist in remove --account gmail --address boss@example.com
emcli whitelist add gmail boss@example.com --in
emcli whitelist add gmail @partner.com --in
emcli whitelist list gmail --in
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.
### Outbound whitelist
Enable it (`--whitelist-out`), then manage entries the same way with `whitelist out`:
Same pattern with `--out`:
```bash
emcli whitelist out add --account gmail --address @example.com
emcli whitelist out list --account gmail
emcli whitelist add gmail @example.com --out
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
@@ -315,10 +341,10 @@ send is blocked.
A regular expression on the account. If set, the agent only sees mail whose subject matches:
```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.
---
@@ -421,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
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
@@ -483,8 +514,9 @@ A blocked send (read-only account):
sends and reads nothing.
```bash
emcli doctor # check every account
emcli doctor --account gmail # check just one
emcli doctor # check every account
emcli doctor gmail # check just one (positional)
emcli doctor --account gmail # same — flag form also works
```
Example output:
@@ -515,7 +547,7 @@ Every agent action (`list`, `get`, `search`, `ack`, `send`) — allowed or block
```bash
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
```
@@ -525,6 +557,7 @@ the reason. Secrets never appear here.
### Settings
```bash
emcli config list # list all settings and their current values
emcli config set audit_retention_days 90
emcli config get audit_retention_days
```
@@ -532,6 +565,9 @@ emcli config get audit_retention_days
- `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.
`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
@@ -541,8 +577,8 @@ base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`,
`doctor`) need this key.
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
Admin commands (`account`, `whitelist`, `config`, `audit`, `init`) need this key; `EMCLI_KEY`
alone is not enough for them.
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
@@ -555,11 +591,11 @@ the DEK if one already exists) with both correct keys, then re-add any accounts
`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
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.
**`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).
- `whitelist_out` — a recipient isn't on the outbound whitelist. Add it, or review the rule.
@@ -581,18 +617,20 @@ emcli # or: emcli help / emcli --help — list all commands
emcli <command> --help # usage and flags for one command
# Admin (requires EMCLI_ADMIN_KEY)
emcli init # create DB + add first account (form)
emcli account add [flags | none for form] # add an account
emcli account list # list accounts (no secrets)
emcli account edit --name N [flags | none for form] # change an account
emcli account remove --name N --yes # delete an account
emcli whitelist in|out add|remove|list --account N [--address A]
emcli config set|get <key> [value] # e.g. audit_retention_days
emcli audit list [--account N] [--limit K]
emcli init # create DB + add first account (form)
emcli account add [<name>] [flags] # add an account; no args → interactive form
emcli account list # full table (admin) / name+from+can_send JSON (agent)
emcli account show N # display one account's settings
emcli account edit N [flags | none for form] # change an account
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 audit list [account] [--limit K]
emcli version
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
emcli doctor [--account N] # connectivity/auth check
emcli doctor [N | --account N] # connectivity/auth check
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since 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]
@@ -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.
File diff suppressed because it is too large Load Diff
@@ -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,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-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/mattn/go-isatty v0.0.20
modernc.org/sqlite v1.53.0
)
@@ -27,7 +28,6 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.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-runewidth v0.0.19 // 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")
}
}
+274 -99
View File
@@ -1,41 +1,66 @@
package cli
import (
"bufio"
"flag"
"fmt"
"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/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
// 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]) {
printCmdUsage(out, "account")
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, show, list")
if len(args) > 0 {
return 0 // explicit --help
return 0
}
return 2
}
sub, rest := args[0], args[1:]
sub := normalizeVerb(args[0])
rest := args[1:]
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
if sub == "list" {
_ = Failure(CodeConfig, err.Error()).Write(out)
} else {
fmt.Fprintf(errOut, "emcli: %v\n", err)
}
return 1
}
defer st.Close()
switch sub {
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)
}
// 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.SetOutput(errOut)
name := fs.String("name", "", "account name")
mode := fs.String("mode", "RO", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 993, "IMAP port")
@@ -47,14 +72,16 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
pass := fs.String("password", "", "login password")
from := fs.String("from", "", "send-as address (blank = use username)")
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")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" || *host == "" || *user == "" {
fmt.Fprintln(errOut, "name, imap-host, and username are required")
if fs.NArg() > 0 {
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 {
@@ -62,26 +89,31 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
return 2
}
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,
FromAddress: *from,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog,
FromAddress: *from, SubjectRegex: *subj, ProcessBacklog: *backlog,
}
if *mode == "RW" {
acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec
}
_, err := st.AddAccount(acc)
if err != nil {
if _, err := st.AddAccount(acc); err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
fmt.Fprintf(out, "account %q added (%s)\n", name, *mode)
return 0
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.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
mode := fs.String("mode", "", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 0, "IMAP port")
@@ -93,26 +125,22 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
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")
if err := fs.Parse(rest); err != nil {
if err := fs.Parse(flagArgs); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut)
}
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc, err := st.GetAccount(*name)
acc, err := st.GetAccount(name)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
// Overlay only the flags the user actually set.
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "mode":
@@ -139,43 +167,76 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
acc.SubjectRegex = *subj
}
})
// acc.Password holds the existing (decrypted) password from GetAccount; the
// Visit above overwrites it only when --password was passed. UpdateAccount
// re-seals whatever non-empty value is present, so the password is preserved.
// GetAccount loaded the existing decrypted password into acc; fs.Visit
// overwrites acc.Password only when --password was passed; UpdateAccount
// 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 {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q updated\n", *name)
fmt.Fprintf(out, "account %q updated\n", name)
return 0
case "remove":
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest); err != nil {
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
fmt.Fprintln(errOut, "usage: emcli account remove <name> [--yes]")
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
name := rest[0]
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
}
if !*yes {
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
return 2
if !isatty.IsTerminal(os.Stdin.Fd()) {
fmt.Fprintf(errOut, "refusing to remove %q without --yes (no terminal for confirmation)\n", name)
return 2
}
if !confirmRemoval(name, out) {
fmt.Fprintln(out, "aborted")
return 1
}
}
if err := st.DeleteAccount(*name); err != nil {
if err := st.DeleteAccount(name); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q removed\n", *name)
fmt.Fprintf(out, "account %q removed\n", name)
return 0
case "show":
return accountShow(st, rest, out, errOut)
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()
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
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",
@@ -183,7 +244,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
}
return 0
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
}
}
@@ -203,7 +264,7 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
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, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "config")
@@ -212,11 +273,8 @@ func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
}
return 2
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
return 2
}
sub, key := args[0], args[1]
sub := normalizeVerb(args[0])
rest := args[1:]
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
@@ -225,16 +283,51 @@ func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
defer st.Close()
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":
if len(args) < 3 {
if len(rest) != 2 {
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
return 2
}
value := args[2]
if key == "audit_retention_days" {
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value)
key, value := rest[0], rest[1]
def, ok := settingsRegistry[key]
if !ok {
fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
return 2
}
if def.validate != nil {
if err := def.validate(value); err != nil {
fmt.Fprintf(errOut, "%s %v\n", key, err)
return 2
}
}
@@ -244,35 +337,36 @@ func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
}
fmt.Fprintf(out, "%s = %s\n", key, value)
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:
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub)
fmt.Fprintf(errOut, "unknown config subcommand %q (want list|get|set)\n", sub)
return 2
}
}
// runAudit handles `audit list [--account <name>] [--limit N]`.
// runAudit handles `audit list [account] [--limit N]`.
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "audit")
return 0
}
if len(args) == 0 || args[0] != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
if len(args) == 0 || normalizeVerb(args[0]) != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [account] [--limit N]")
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.SetOutput(errOut)
account := fs.String("account", "", "filter by account")
limit := fs.Int("limit", 50, "max rows")
if err := fs.Parse(args[1:]); err != nil {
if err := fs.Parse(rest); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
st, err := openStore(role)
@@ -281,14 +375,23 @@ func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
return 1
}
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)
return 1
}
return 0
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
// flowName renders a direction for human-facing prose.
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]) {
printCmdUsage(out, "whitelist")
@@ -297,23 +400,42 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
}
return 2
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
sub := normalizeVerb(args[0])
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
}
dir := store.Direction(args[0])
sub, rest := args[1], args[2:]
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
address := fs.String("address", "", "email or @domain")
if err := fs.Parse(rest); err != nil {
// Split the remaining tokens into the direction flag and positionals.
var dir store.Direction
var dirSet bool
var pos []string
for _, a := range args[1:] {
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
}
pos = append(pos, a)
}
}
if !dirSet {
fmt.Fprintln(errOut, "direction is required: pass --in or --out")
return 2
}
if *account == "" {
fmt.Fprintln(errOut, "--account is required")
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 {
fmt.Fprintf(errOut, "emcli: %v\n", err)
@@ -322,30 +444,83 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
defer st.Close()
switch sub {
case "add":
if err := st.AddWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "add: %v\n", err)
return 1
case "add", "remove":
if len(addrs) == 0 {
fmt.Fprintln(errOut, "at least one address is required")
return 2
}
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
for _, addr := range addrs {
if err := policy.ValidWhitelistAddress(addr); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
}
fmt.Fprintf(out, "removed %s\n", *address)
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
}
}
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":
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 {
fmt.Fprintf(errOut, "list: %v\n", err)
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)
}
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
return 2
return 0
case "enable", "disable":
if len(addrs) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
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
}
+120 -14
View File
@@ -52,18 +52,40 @@ func TestConfigSetGet(t *testing.T) {
func TestConfigSetRejectsBadRetention(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code == 0 {
t.Fatal("negative retention must be rejected")
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code != 2 {
t.Fatal("negative retention must be a usage error")
}
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code == 0 {
t.Fatal("non-integer retention must be rejected")
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code != 2 {
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) {
adminEnv(t)
run(t, "account", "add", "--name", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "--name", "gone", "--yes"); code != 0 {
run(t, "account", "add", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "gone", "--yes"); code != 0 {
t.Fatalf("remove failed: %s", e)
}
_, out, _ := run(t, "account", "list")
@@ -74,17 +96,26 @@ func TestAccountRemove(t *testing.T) {
func TestAccountRemoveMissing(t *testing.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")
}
}
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) {
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")
// Edit only mode + add SMTP; imap-host, username, password must be preserved.
if code, _, e := run(t, "account", "edit", "--name", "ed", "--mode", "RW",
if code, _, e := run(t, "account", "edit", "ed", "--mode", "RW",
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e)
}
@@ -110,6 +141,67 @@ 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) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
@@ -132,12 +224,26 @@ func TestAuditListCoreRenders(t *testing.T) {
}
}
func TestAuditListPositionalAccount(t *testing.T) {
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)
// Seed an account so the failure is from --from validation, not a missing account.
run(t, "account", "add", "--name", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com")
// A malformed --from value must be rejected with exit code 2 before touching the account.
code, _, errStr := run(t, "account", "edit", "--name", "valacc", "--from", "not an address")
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)
}
+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
}
// 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)
}
}
}
+7 -5
View File
@@ -23,11 +23,11 @@ var agentCmds = []cmdHelp{
var adminCmds = []cmdHelp{
{"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)."},
{"whitelist", "whitelist <in|out> <add|remove|list> --account <name> [--address A]", "Manage inbound/outbound whitelists."},
{"config", "config <set|get> <key> [value]", "Get or set global settings (e.g. audit_retention_days)."},
{"audit", "audit list [--account <name>] [--limit N]", "Show recent audit-log entries."},
{"doctor", "doctor [--account <name>]", "Check each account's IMAP/SMTP connectivity and auth."},
{"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 <add|remove|list|enable|disable> <account> [address…] --in|--out", "Manage inbound/outbound whitelists. Direction (--in/--out) is required."},
{"config", "config <list|get|set> [key] [value]", "List, get, or set global settings (e.g. audit_retention_days)."},
{"audit", "audit list [account] [--limit N]", "Show recent audit-log entries."},
{"doctor", "doctor [account]", "Check each account's IMAP/SMTP connectivity and auth."},
{"version", "version", "Print the emcli version."},
{"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.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, " 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")
+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)
}
}
+12 -6
View File
@@ -9,16 +9,22 @@ import (
)
func TestCommandRole(t *testing.T) {
admin := []string{"account", "whitelist", "config", "audit"}
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
for _, c := range admin {
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("%s should be admin", c)
t.Errorf("%v should be admin", c)
}
}
for _, c := range agent {
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%s should be agent", c)
t.Errorf("%v should be agent", c)
}
}
}
+36 -10
View File
@@ -27,9 +27,17 @@ func realMailer(acc store.Account) (Mailer, error) {
// 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":
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.
// 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
@@ -98,18 +106,36 @@ 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, 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.SetOutput(errOut)
usageFlags(fs, "doctor", errOut)
account := fs.String("account", "", "check only this account")
if err := fs.Parse(args); err != nil {
accountFlag := fs.String("account", "", "check only this account")
if err := fs.Parse(rest); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
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 {
fmt.Fprintf(errOut, "emcli: %v\n", err)
@@ -117,7 +143,7 @@ func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
}
defer st.Close()
d := newDepsLive(st, out)
if err := DoctorCmd(d, *account); err != nil {
if err := DoctorCmd(d, account); err != nil {
return 1
}
return 0
@@ -135,10 +161,10 @@ func Run(args []string, out, errOut io.Writer) int {
return 0
}
cmd, rest := args[0], args[1:]
role := commandRole(cmd)
switch cmd {
role := commandRole(args)
switch normalizeVerb(cmd) {
case "list", "get", "search", "ack":
return runAgent(cmd, rest, role, out, errOut)
return runAgent(normalizeVerb(cmd), rest, role, out, errOut)
case "send":
return runSend(rest, role, out, errOut)
case "account":
+66 -5
View File
@@ -18,9 +18,9 @@ func TestRunUnknownCommand(t *testing.T) {
}
}
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
// `account list` with no DB key should fail closed with a usage/config error,
// proving the key check happens before any DB work.
func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) {
// `account list` is an agent command: with no DB key it fails closed before
// any DB work, emitting a JSON config-error envelope that names EMCLI_KEY.
var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
@@ -28,8 +28,15 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
if code == 0 {
t.Fatal("missing EMCLI_KEY must fail")
}
if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") {
t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String())
var env map[string]any
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())
}
}
@@ -51,6 +58,20 @@ 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 {
// 32 zero bytes, base64.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
@@ -60,3 +81,43 @@ 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)
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "list"},
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
+30
View File
@@ -3,6 +3,8 @@
package policy
import (
"errors"
"fmt"
"net/mail"
"strings"
)
@@ -42,3 +44,31 @@ func MatchAddress(entries []string, addr string) bool {
}
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)
}
}
}
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
_ "modernc.org/sqlite"
)
@@ -77,10 +78,24 @@ func (s *Store) migrate() error {
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.
func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil
return expandUserHome(p), nil
}
if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" {
+43
View File
@@ -2,10 +2,53 @@ package store
import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
)
// A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
// tilde (no shell to expand it) can't be opened relative to the cwd and
// silently create a stray "~" directory.
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)
}
}
}
// 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 {
+15
View File
@@ -86,3 +86,18 @@ func (s *Store) ListWhitelist(account string, dir Direction) ([]string, error) {
}
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")
}
}
+1 -14
View File
@@ -24,7 +24,7 @@ type Fields struct {
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool
ProcessBacklog bool
SubjectRegex string
}
@@ -90,7 +90,6 @@ func (f Fields) ToAccount() (store.Account, bool) {
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
}
if f.Mode == "RW" {
@@ -116,8 +115,6 @@ func FieldsFromAccount(a store.Account) Fields {
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username,
FromAddress: a.FromAddress,
WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog,
SubjectRegex: a.SubjectRegex,
}
@@ -144,8 +141,6 @@ var fieldDefs = []fieldDef{
{key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{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: "subject_regex", label: "Subject regex (optional)"},
}
@@ -189,10 +184,6 @@ func fieldValue(f Fields, key string) string {
return f.FromAddress
case "password":
return f.Password
case "whitelist_in":
return boolStr(f.WhitelistIn)
case "whitelist_out":
return boolStr(f.WhitelistOut)
case "process_backlog":
return boolStr(f.ProcessBacklog)
case "subject_regex":
@@ -276,10 +267,6 @@ func (m AccountForm) collect() Fields {
f.FromAddress = v
case "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":
f.ProcessBacklog = parseBool(v)
case "subject_regex":
+15 -4
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
@@ -69,7 +70,6 @@ func TestFieldsValidateRWNeedsSMTP(t *testing.T) {
func TestFieldsToAccount(t *testing.T) {
f := validFields()
f.WhitelistIn = true
f.SubjectRegex = "^urgent"
acc, pwSet := f.ToAccount()
if !pwSet {
@@ -78,9 +78,12 @@ func TestFieldsToAccount(t *testing.T) {
if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 {
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)
}
if acc.WhitelistInEnabled || acc.WhitelistOutEnabled {
t.Fatal("new accounts must have whitelist flags false (managed via whitelist group)")
}
if acc.Password != "pw" {
t.Fatalf("password not carried: %q", acc.Password)
}
@@ -99,10 +102,10 @@ func TestFieldsFromAccountRoundTrip(t *testing.T) {
a := store.Account{
Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls",
SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls",
Username: "u@x.com", WhitelistOutEnabled: true, SubjectRegex: "re:",
Username: "u@x.com", SubjectRegex: "re:",
}
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)
}
// Password is never read back from an account.
@@ -190,3 +193,11 @@ func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
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)
}
}
+16 -5
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose |
|---|---|---|
| `EMCLI_VERSION` | `v0.5.0` | 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_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
@@ -85,16 +85,27 @@ with both keys exported. Account creation and other admin is the human's job —
## 4. Find the account(s)
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:
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
```
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
+11 -9
View File
@@ -17,12 +17,12 @@ sets its exit code to match.
## Security model — read this first
- **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.
- **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.
- **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
missing, stop and tell the user (see "Files & first run").
@@ -120,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 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 account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
@@ -142,8 +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.
- ✅ `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 run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
- ❌ 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.
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh
#
# Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.5.0)
# 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_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
#
# Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.6.0_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.5.0}"
VERSION="${EMCLI_VERSION:-v0.6.0}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"