29 Commits

Author SHA1 Message Date
steve 8ed10dd503 docs: agent can discover accounts via account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:39:12 +01:00
steve 2140d9e173 feat(cli): agent-readable account list (reduced JSON view)
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a
JSON envelope of name/from/can_send, while the admin keeps the full text
table. account add/edit/remove stay admin-only.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:34:26 +01:00
steve c946516d01 chore(skill): point installer default at v0.4.1
release / release (push) Successful in 3m21s
Bump EMCLI_VERSION default (install.sh + AGENTIC-MANUAL.md + RELEASING.md) so
agents install the v0.4.1 binary (help for all commands, SMTP-port form default,
skill split). Drop the stale "placeholder until first release" note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:17:02 +01:00
steve b3390a0a20 fix(tui): default SMTP port to 465 in the account form
NewAccountForm prefilled defaults for mode, IMAP port, and both securities but
left SMTP port blank. Default it to 465 to match `account add --smtp-port`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:15:25 +01:00
steve 1b2fe99055 feat(cli): add help for all commands
emcli had only raw flag usage and no command listing; `--help` on agent commands
even emitted a JSON error envelope and exited 2. Add real help:

- Top-level `emcli` / `help` / `-h` / `--help` prints a grouped command catalogue
  (agent vs admin) with one-line summaries and the EMCLI_KEY/EMCLI_DB env vars.
- `emcli help <command>` prints that command's synopsis + summary.
- `emcli <command> --help` prints synopsis + summary + flags and exits 0. Agent
  commands keep stdout JSON-free (usage goes to stderr); admin commands print to
  stdout. Help works without EMCLI_KEY (no DB access).
- help.go holds the command catalogue; flag.ErrHelp is handled as success, and
  admin handlers short-circuit help before opening the store.

Unknown commands still error (exit 2). Full suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:11:40 +01:00
steve 7087533644 docs(skill): split setup into AGENTIC-MANUAL.md; keep SKILL.md lean
The SKILL.md body loads into context on every activation, so one-time install/
setup prose was wasted context once emcli is running. Move it out:

- New AGENTIC-MANUAL.md: get-the-files bootstrap, binary install (incl. options
  and build-from-source, folding in the old references/install.md), EMCLI_KEY,
  account discovery. Fetched only during first-time setup.
- SKILL.md trimmed (182→~145 lines) to the recurring path: security model, a short
  "Files & first run" pointer + per-session preflight, the list/get/ack/send
  workflow, JSON envelope, command table, enforcement, do/don't.
- Remove references/install.md (folded in); fix RELEASING.md pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:01:46 +01:00
steve 93dbebb982 docs(skill): make SKILL.md self-bootstrapping from the repo
An agent pointed at the repo may load only SKILL.md and then guess a wrong path
for the installer (it fetched /scripts/install.sh at repo root → 404; the file is
under skills/emcli/). Fix:

- Add a "First: get this skill's files" section: the supporting scripts/ and
  references/ files, the absolute raw base URL to fetch them, and the Gitea
  contents API to enumerate the directory.
- Install step now gives an absolute-URL fetch-then-run for the only-SKILL.md case,
  keeping `bash scripts/install.sh` for the bundled case.
- State that every scripts/… and references/… path is relative to the skill dir and
  resolvable against the raw base URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:51:15 +01:00
steve 68a29ad5c7 docs: mark CI release verified; note releases must be public for the installer
The Gitea Actions workflow published v0.4.0 successfully, so drop the "untested"
caveat. Document that release assets download anonymously — the repo/releases must
be public or install.sh gets a 404 (private repos 404 unauthenticated downloads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:45 +01:00
42 changed files with 3995 additions and 263 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
#
# Requires: Gitea Actions enabled with a runner that has Go, make, curl, and jq
# (the actions/checkout + actions/setup-go steps need the instance's Actions proxy).
# This workflow has not been exercised against this repo's runners yet; if a step
# is unavailable on your runner, the same result comes from `make release && make
# Verified: this workflow published v0.4.0 on this instance. If a step is ever
# unavailable on your runner, the same result comes from `make release && make
# publish` locally (see RELEASING.md).
name: release
on:
+6 -3
View File
@@ -9,11 +9,14 @@ it isn't permitted to see or send mail to people it isn't permitted to contact.
## Getting started
```bash
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # one-time: generate & save a key
emcli init # create the DB, add your first account
emcli doctor # confirm it connects and authenticates
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
emcli init # writes both wrap slots
emcli doctor # confirm connect/auth (agent key is enough)
```
`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it.
## Documentation
See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail
+9 -5
View File
@@ -39,14 +39,18 @@ git push origin v0.4.0 # (push via the tokenized HTTPS URL this repo uses)
```
The workflow runs `make release` and uploads the assets to the release via the Gitea API. It needs
Gitea Actions enabled with a runner that provides Go, make, curl, and jq. It hasn't been exercised
against this repo's runners yet — if it doesn't fit your runner setup, fall back to Option A.
Gitea Actions enabled with a runner that provides Go, make, curl, and jq. This is how v0.4.0 was
published. If it ever doesn't fit your runner setup, fall back to Option A.
> Note: release asset downloads are anonymous, so the repository (or at least its releases) must be
> public for `skills/emcli/scripts/install.sh` to fetch binaries without a token. A private repo
> returns 404 to unauthenticated downloads.
## After a release
The skill installer defaults to `EMCLI_VERSION=v0.4.0`. When you cut a different version, either
publish under that tag or update the default in `skills/emcli/scripts/install.sh` (and the note in
`skills/emcli/references/install.md`).
The skill installer defaults to `EMCLI_VERSION=v0.4.1`. When you cut a different version, either
publish under that tag or update the default in `skills/emcli/scripts/install.sh` (and the options
table in `skills/emcli/AGENTIC-MANUAL.md`).
## Versioning
+107 -35
View File
@@ -13,7 +13,8 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## Contents
1. [Key concepts](#1-key-concepts)
2. [Setup: the encryption key and database](#2-setup-the-encryption-key-and-database)
2. [Setup: encryption keys and database](#2-setup-encryption-keys-and-database)
- [Privilege model](#2a-privilege-model)
3. [Quick start](#3-quick-start)
4. [Adding accounts](#4-adding-accounts)
- [Gmail (app password)](#gmail-app-password)
@@ -32,10 +33,14 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## 1. Key concepts
**Two kinds of commands.**
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`, `doctor`) are for *you*,
the human. They print human-readable text or open an interactive form.
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`) are for the *agent*. They print one
line of JSON and nothing else, so a program can consume them reliably.
- **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
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
as a superset, so either key suffices for agent commands.)
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
sees its password.
@@ -61,50 +66,95 @@ acking is a deliberate, separate step.
---
## 2. Setup: the encryption key and database
## 2. Setup: encryption keys and database
`emcli` reads two environment variables:
`emcli` reads three environment variables:
| Variable | Purpose | Default |
|---|---|---|
| `EMCLI_KEY` | **Required.** Base64-encoded 32-byte key (AES-256) used to encrypt passwords at rest. | none — commands fail without it |
| `EMCLI_ADMIN_KEY` | **Required for admin.** Base64-encoded 32-byte key (AES-256). Authorises ALL commands. | none — admin commands fail without it |
| `EMCLI_KEY` | **Required for agents.** Base64-encoded 32-byte key (AES-256). Authorises agent commands only. | none — agent commands fail without it |
| `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) |
**Generate a key once** and keep it safe (store it the way the program/orchestrator that launches
`emcli` expects — e.g. a secrets manager or your shell profile):
**Generate both keys once** and keep them safe:
```bash
head -c 32 /dev/urandom | base64
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
```
Set it in your environment before running any command:
```bash
export EMCLI_KEY='paste-the-base64-key-here'
```
> **Important:** the key encrypts your account passwords. If you lose it, the stored passwords
> can't be decrypted and you'll have to re-add accounts. If you change it, the same applies.
> `emcli` never falls back to plaintext — a missing or wrong key makes every command fail safely.
> **Important:** the keys protect your account passwords via envelope encryption (see "Privilege
> model" below). If you lose `EMCLI_ADMIN_KEY`, account secrets can't be decrypted and you'll have
> to re-add accounts. `emcli` never falls back to plaintext — a missing or wrong key makes every
> command fail safely.
Account passwords are stored **encrypted**; they never appear in command output, error messages,
or the audit log.
---
## 2a. Privilege model
`emcli` enforces a two-role privilege split so a process holding only the agent key cannot
reconfigure accounts, whitelists, or audit settings.
### The two keys
| Key | Holder | Authorises |
|---|---|---|
| `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) |
| `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `ack`, `send`, `doctor`) |
`EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands
too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY
(admin privilege)` if it attempts an admin command.
### Envelope encryption (DEK)
`emcli init` generates a random data-encryption key (DEK) that seals all account secrets. The DEK
is stored in the `settings` table in two sealed copies:
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY`.
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY`.
The DEK is never written in cleartext. Admin commands unwrap the DEK from the admin slot only; they
have no fallback to the agent slot. This means a process holding only `EMCLI_KEY` cannot unlock the
DEK for an admin command, even if it somehow knows the agent key.
### Command → role table
| Command | Role required |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
`can_send` — no host or login username.
| `init` | Both keys required (writes both wrap slots) |
### Agent launcher guidance
Configure your agent's orchestrator with **only `EMCLI_KEY`**. Never give the orchestrator
`EMCLI_ADMIN_KEY`. If the agent tries to run an admin command — even by mistake — `emcli` will
refuse it at the key level, not just by convention.
---
## 3. Quick start
```bash
# 1. Set your key (see section 2)
export EMCLI_KEY='…'
# 1. Generate and export both keys (see section 2)
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # keep this yourself
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # give only this to the agent
# 2. Create the database and add your first account (interactive form)
emcli init
# 3. Check it connects and authenticates
# 3. Check it connects and authenticates (agent key is enough for doctor)
emcli doctor
# 4. The agent can now read
# 4. The agent can now read (needs only EMCLI_KEY)
emcli list --account gmail --folder INBOX --limit 10
```
@@ -146,6 +196,7 @@ emcli account add --name work --mode RW \
| `--smtp-security` | `tls` | `tls` or `starttls` |
| `--username` | — | Login username, usually your full email (required) |
| `--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 |
@@ -215,9 +266,15 @@ emcli account edit --name work --mode RW --smtp-host smtp.example.com --smtp-por
emcli account edit --name 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 --name work --from 'Work Team <you@yourdomain.com>' # set the send-as address
emcli account edit --name work --from '' # clear it (revert to username)
```
> 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 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.
**Remove an account** (requires `--yes`):
@@ -375,6 +432,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
@@ -491,10 +553,16 @@ emcli config get audit_retention_days
## 11. Troubleshooting
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
base64-encoded 32-byte key (section 2). Every command that touches the database needs it.
base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `ack`, `send`,
`doctor`) need this key.
**A command fails to decrypt / "wrong EMCLI_KEY?".** The key doesn't match the one used when the
account was added. Restore the original key, or re-add the account with the current key.
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
Admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) need this key;
`EMCLI_KEY` alone is not enough for them. (`account list` is the exception — an agent can run it.)
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
the DEK if one already exists) with both correct keys, then re-add any accounts if needed.
**`doctor` shows `IMAP FAIL` / `SMTP FAIL`.**
- *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure
@@ -524,19 +592,23 @@ running non-interactively.
## 12. Command cheat sheet
```
# Admin
# Help
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 list # full table (admin) / name+from+can_send JSON (agent)
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 doctor [--account N] # connectivity/auth check
emcli version
# Agent (one line of JSON each)
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
emcli doctor [--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]
@@ -544,4 +616,4 @@ emcli ack --account N [--folder F] --uid-list U1,U2,U3
emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]]
```
Environment: `EMCLI_KEY` (required, base64 32-byte key), `EMCLI_DB` (optional DB path).
Environment: `EMCLI_ADMIN_KEY` (required for admin commands, base64 32-byte key), `EMCLI_KEY` (required for agent commands, base64 32-byte key), `EMCLI_DB` (optional DB path).
@@ -0,0 +1,855 @@
# Two-Key Privilege Separation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enforce the agent/admin trust boundary with two environment keys — `EMCLI_ADMIN_KEY` (all commands) and `EMCLI_KEY` (agent commands only) — so a forced agent holding only `EMCLI_KEY` cannot run admin commands.
**Architecture:** Envelope encryption. `init` generates a random data-encryption key (DEK) that seals all account secrets exactly as today. The DEK is stored in `settings`, sealed twice: under the admin KEK (`dek_wrap_admin`) and under the agent KEK (`dek_wrap_agent`). Admin commands unwrap the DEK from the admin slot **only** (no agent fallback); agent commands use the agent slot (falling back to the admin slot when only the admin key is present). The store's existing `s.key` field simply becomes the DEK, so `account.go` / mail crypto is untouched.
**Tech Stack:** Go, `modernc.org/sqlite`, AES-256-GCM (existing `crypto.Seal`/`crypto.Open`), standard `flag` CLI.
## Global Constraints
- Module path: `git.dcglab.co.uk/steve/emcli`. Packages under `internal/`.
- Keys are base64-encoded **exactly 32 bytes** (AES-256). Reject anything else.
- Single static CGO-free binary; `go vet ./...` must stay clean; tests pass under `-race`.
- Secrets (keys, passwords, DEK) never appear on stdout, in the JSON envelope, or the audit log.
- Agent commands emit exactly one JSON object on stdout; admin commands print human-readable text (never JSON).
- DEK never written to disk in cleartext; wrap slots stored as base64 text in the `settings` table (`value TEXT NOT NULL`).
- No migration / no schema-version gate — `init` writes wrap slots into a fresh DB (decided in spec).
**Command → role mapping (single source of truth, implemented in Task 3):**
| Command | Role |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent |
| `account`, `whitelist`, `config`, `audit` | admin |
| `init` | bootstrap (needs **both** keys) |
| `help` / no args | none (no DB access) |
---
## Task 1: crypto — named-var key loaders + DEK generation
**Files:**
- Modify: `internal/crypto/crypto.go`
- Test: `internal/crypto/crypto_test.go`
**Interfaces:**
- Consumes: existing `Seal(key, plaintext []byte) ([]byte, error)`, `Open(key, blob []byte) ([]byte, error)` (unchanged — they double as DEK wrap/unwrap).
- Produces:
- `AgentKeyFromEnv() ([]byte, error)` — reads `EMCLI_KEY`.
- `AdminKeyFromEnv() ([]byte, error)` — reads `EMCLI_ADMIN_KEY`.
- `NewDEK() ([]byte, error)` — fresh random 32-byte key.
- Removes: `KeyFromEnv`, `ErrNoKey`, `ErrBadKey` (only `crypto_test.go` and `internal/cli/run.go:30` reference them; the latter is rewritten in Task 3).
- [ ] **Step 1: Replace the env/error section of `crypto.go`**
Replace the current `var ( ErrNoKey … ); func KeyFromEnv()` block (lines ~1430) with:
```go
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
// named environment variable. Errors name the variable so callers get a
// role-appropriate message.
func keyFromEnv(varName string) ([]byte, error) {
raw := os.Getenv(varName)
if raw == "" {
return nil, fmt.Errorf("%s is not set", varName)
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
}
return key, nil
}
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
// NewDEK returns a fresh random 32-byte data-encryption key.
func NewDEK() ([]byte, error) {
dek := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
return dek, nil
}
```
Update the import block to add `"fmt"` and drop `"errors"` if now unused (it is — no other `errors.` use remains after removing the sentinels; `Open` uses `errors.New` so KEEP `"errors"`). Net: add `"fmt"`, keep everything else.
- [ ] **Step 2: Rewrite `TestKeyFromEnv` in `crypto_test.go`**
Replace `TestKeyFromEnv` (lines ~5369) with:
```go
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
good := base64.StdEncoding.EncodeToString(testKey())
t.Setenv("EMCLI_KEY", good)
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", good)
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", "")
if _, err := AdminKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
}
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := AgentKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("short key: want length error, got %v", err)
}
}
func TestNewDEKIsRandom32(t *testing.T) {
a, err := NewDEK()
if err != nil || len(a) != 32 {
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
}
b, _ := NewDEK()
if bytes.Equal(a, b) {
t.Fatal("two DEKs must differ")
}
}
```
Add `"strings"` to the test imports (`bytes` and `encoding/base64` are already imported).
- [ ] **Step 3: Run crypto tests, expect FAIL to PASS transition**
Run: `go test ./internal/crypto/...`
Expected: PASS. (If it fails to compile because `"errors"` became unused, that means `Open` no longer references it — it does, so this should not happen; if `"fmt"` is reported unused, you forgot to add a loader. Fix and re-run.)
- [ ] **Step 4: Commit**
```bash
git add internal/crypto/crypto.go internal/crypto/crypto_test.go
git commit -m "feat(crypto): named-var key loaders (admin/agent) + NewDEK
Replace KeyFromEnv with AgentKeyFromEnv/AdminKeyFromEnv reading EMCLI_KEY
and EMCLI_ADMIN_KEY; add NewDEK for envelope encryption. Seal/Open double
as DEK wrap/unwrap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
> Note: the repo will not fully build until Task 3 (cli still references the removed `crypto.KeyFromEnv`). The crypto package and its tests are self-contained and pass.
---
## Task 2: store — unlock/init split + DEK wrap slots
**Files:**
- Modify: `internal/store/store.go`
- Create: `internal/store/keys.go`
- Test: `internal/store/keys_test.go`
**Interfaces:**
- Consumes: `crypto.NewDEK`, `crypto.Seal`, `crypto.Open` (Task 1); existing `(*Store).GetSetting`, `(*Store).SetSetting`.
- Produces:
- `type Role int` with `RoleAgent Role = iota` and `RoleAdmin`.
- `store.Open(path string) (*Store, error)`**signature change**: drops the `key` param; store opens locked.
- `(*Store).InitKeys(adminKey, agentKey []byte) error`.
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error`.
- `var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")`.
- Note for later tasks: after `Open`, `s.key` is nil; a command MUST call `Unlock` (or `InitKeys`) before any account read/write, or `crypto.Open(s.key, …)` in `account.go` will fail.
- [ ] **Step 1: Change `store.Open` to open locked (no key param)**
In `internal/store/store.go`, change the signature and the struct construction:
```go
// Open opens (creating if needed) the DB at path and applies the schema.
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
// secret read/write.
func Open(path string) (*Store, error) {
```
and replace `s := &Store{db: db, key: key}` with:
```go
s := &Store{db: db}
```
Leave the rest of `Open` (dir creation, pragma, schema, schema_version setting) unchanged. The `key []byte` field on `Store` stays as-is (now populated by `Unlock`/`InitKeys`).
- [ ] **Step 2: Write the failing test for InitKeys + Unlock**
Create `internal/store/keys_test.go`:
```go
package store
import (
"bytes"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
func k(b byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = b
}
return key
}
func tempStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
admin, agent := k(0xAA), k(0xBB)
st := tempStore(t)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("InitKeys: %v", err)
}
// Seal a password under the DEK that InitKeys set.
if _, err := st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
// Re-open and unlock via the AGENT slot.
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
t.Fatalf("Unlock(agent): %v", err)
}
got, err := st2.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
}
st2.Close()
// Unlock via the ADMIN slot recovers the same DEK.
st3, _ := Open(path)
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
t.Fatalf("Unlock(admin): %v", err)
}
got3, err := st3.GetAccount("a")
if err != nil || got3.Password != "pw" {
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
}
st3.Close()
}
func TestUnlockWrongKeyFails(t *testing.T) {
st := tempStore(t)
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatal(err)
}
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
t.Fatal("Unlock with wrong admin key must fail")
}
st2.Close()
}
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
// RoleAdmin must use the admin slot; passing the agent key as the admin
// key must fail — there is no fallback to the agent slot.
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
t.Fatal("agent key must not unlock the admin slot")
}
}
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
})
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("re-InitKeys: %v", err)
}
got, err := st.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
}
_ = bytes.Equal // keep import if unused elsewhere
}
```
- [ ] **Step 3: Run the test to verify it fails (compile error)**
Run: `go test ./internal/store/ -run TestInitKeys -v`
Expected: FAIL — compile errors (`InitKeys`, `Unlock`, `RoleAgent`, `RoleAdmin`, `dbPath` undefined).
- [ ] **Step 4: Implement `internal/store/keys.go`**
```go
package store
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
// Role selects which DEK wrap slot a command may unlock.
type Role int
const (
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
RoleAdmin // all commands; uses dek_wrap_admin ONLY
)
const (
settingDEKWrapAdmin = "dek_wrap_admin"
settingDEKWrapAgent = "dek_wrap_agent"
)
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
// dbPath returns the file path SQLite opened (used by tests to re-open).
func (s *Store) dbPath() string {
var p string
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
return p
}
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
// both wrap slots, and unlocks the store. If the slots already exist it does
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
return s.Unlock(RoleAdmin, adminKey, nil)
}
dek, err := crypto.NewDEK()
if err != nil {
return err
}
wrapAdmin, err := crypto.Seal(adminKey, dek)
if err != nil {
return err
}
wrapAgent, err := crypto.Seal(agentKey, dek)
if err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
return err
}
s.key = dek
return nil
}
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
// falls back to the admin slot only when no agent key is supplied.
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
switch role {
case RoleAdmin:
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
case RoleAgent:
if len(agentKey) > 0 {
return s.unlockSlot(settingDEKWrapAgent, agentKey)
}
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
default:
return fmt.Errorf("unknown role %d", role)
}
}
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
if len(kek) == 0 {
return ErrLocked
}
enc, err := s.GetSetting(settingKey)
if errors.Is(err, sql.ErrNoRows) {
return ErrLocked
}
if err != nil {
return err
}
blob, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
}
dek, err := crypto.Open(kek, blob)
if err != nil {
return errors.New("wrong key for this DB")
}
s.key = dek
return nil
}
```
- [ ] **Step 5: Run the store tests, expect PASS**
Run: `go test ./internal/store/... -v`
Expected: PASS, including the existing store tests. (Existing `store_test.go` may call `Open(path, key)` with two args — if so, that is fixed in Step 6.)
- [ ] **Step 6: Fix any existing `store` callers of the old `Open(path, key)` signature**
Run: `git grep -n "store.Open(\|Open(path," internal/store`
For each in-package call to `Open` with two args (e.g. in `store_test.go`), change `Open(path, someKey)` to `Open(path)` followed by `st.InitKeys(k(0xAA), k(0xBB))` (or `st.Unlock(...)` if the test re-opens an initialized DB). Re-run `go test ./internal/store/...` until green.
- [ ] **Step 7: Commit**
```bash
git add internal/store/store.go internal/store/keys.go internal/store/keys_test.go internal/store/store_test.go
git commit -m "feat(store): envelope DEK with admin/agent wrap slots
Open() now opens LOCKED; InitKeys generates a DEK sealed under both KEKs;
Unlock loads it from the role's slot (admin slot has no agent fallback).
s.key becomes the DEK, so account/mail crypto is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 3: cli — role routing, openStore(role), init bootstrap
**Files:**
- Modify: `internal/cli/run.go` (openStore, command routing, agent call sites)
- Modify: `internal/cli/admin.go` (4 `openStore()` call sites)
- Modify: `internal/cli/interactive.go` (`runInit` bootstrap)
- Modify: `internal/cli/admin_test.go` (`adminEnv` helper)
- Modify: `internal/cli/run_test.go` (b64 helpers, gating test)
**Interfaces:**
- Consumes: `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv` (Task 1); `store.Open`, `store.Role`, `store.RoleAgent`, `store.RoleAdmin`, `(*Store).Unlock`, `(*Store).InitKeys` (Task 2).
- Produces: `commandRole(cmd string) store.Role`; `openStore(role store.Role) (*store.Store, error)`.
- [ ] **Step 1: Rewrite `openStore` and add `commandRole` in `run.go`**
Replace the current `openStore` (lines ~2839) with:
```go
// commandRole maps a command to the privilege it requires. Admin commands
// mutate configuration or expose oversight data; everything else is agent.
func commandRole(cmd string) store.Role {
switch cmd {
case "account", "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
func openStore(role store.Role) (*store.Store, error) {
adminKey, adminErr := crypto.AdminKeyFromEnv()
agentKey, agentErr := crypto.AgentKeyFromEnv()
switch role {
case store.RoleAdmin:
if adminErr != nil {
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
}
case store.RoleAgent:
if agentErr != nil && adminErr != nil {
return nil, agentErr // "EMCLI_KEY is not set"
}
}
path, err := store.DefaultDBPath()
if err != nil {
return nil, err
}
st, err := store.Open(path)
if err != nil {
return nil, err
}
if err := st.Unlock(role, adminKey, agentKey); err != nil {
st.Close()
return nil, err
}
return st, nil
}
```
Remove the now-unused `crypto` import only if it becomes unused — it does NOT (openStore still uses it). Ensure `run.go` imports include `"fmt"` (already present) and `crypto`/`store` (already present).
- [ ] **Step 2: Update the three agent call sites in `run.go`**
- `runDoctor` (line ~82): `st, err := openStore(store.RoleAgent)`
- `runAgent` (line ~162): `st, err := openStore(store.RoleAgent)`
- `runSend` (line ~252): `st, err := openStore(store.RoleAgent)`
- [ ] **Step 3: Update the four admin call sites in `admin.go`**
In `runAccount` (~24), `runConfig` (~207), `runAudit` (~265), `runWhitelist` (~304): change each `st, err := openStore()` to `st, err := openStore(store.RoleAdmin)`. Confirm `admin.go` imports `git.dcglab.co.uk/steve/emcli/internal/store` (it returns `store` types already; if not imported, add it).
- [ ] **Step 4: Rewrite `runInit` bootstrap in `interactive.go`**
Replace the body of `runInit` (lines ~7597) with:
```go
func runInit(args []string, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init")
return 0
}
adminKey, err := crypto.AdminKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
agentKey, err := crypto.AgentKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
path, err := store.DefaultDBPath()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
st, err := store.Open(path)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if err := st.InitKeys(adminKey, agentKey); err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90")
}
accs, _ := st.ListAccounts()
if len(accs) > 0 {
fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs))
} else {
fmt.Fprintln(out, "Initializing emcli — add your first account.")
}
return addInteractive(st, tui.Fields{}, out, errOut)
}
```
Add imports to `interactive.go` if missing: `git.dcglab.co.uk/steve/emcli/internal/crypto` and `git.dcglab.co.uk/steve/emcli/internal/store` (it already uses `store` for `addInteractive`; `tui` and `fmt` are already imported).
- [ ] **Step 5: Update test helpers in `admin_test.go`**
Replace `adminEnv` (lines ~1420) with a version that sets both keys AND seeds the wrap slots:
```go
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
func adminEnv(t *testing.T) string {
t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, err := store.Open(db)
if err != nil {
t.Fatalf("Open: %v", err)
}
adminKey, _ := crypto.AdminKeyFromEnv()
agentKey, _ := crypto.AgentKeyFromEnv()
if err := st.InitKeys(adminKey, agentKey); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
return db
}
```
Add `"git.dcglab.co.uk/steve/emcli/internal/crypto"` to `admin_test.go` imports (`store` and `filepath` are already imported).
- [ ] **Step 6: Add `b64AgentKey` and fix the gating test in `run_test.go`**
Add next to `b64Key` (line ~53):
```go
func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
```
In `TestRunVersionIsJSONForAgentButTextHere` (lines ~2133), make the admin-key absence explicit so the test is deterministic regardless of the developer's shell:
```go
t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
code := Run([]string{"account", "list"}, &out, &errOut)
```
The assertion `strings.Contains(out+err, "EMCLI_KEY")` still holds — the admin error text contains `EMCLI_ADMIN_KEY`.
- [ ] **Step 7: Build and run the full cli suite**
Run: `go build ./... && go test ./internal/cli/... -race`
Expected: PASS. (This is the first point the whole repo builds again.) If `crypto` shows as an unused import anywhere you touched, remove it; if a `store` import is missing in `admin.go`/`interactive.go`, add it.
- [ ] **Step 8: Add routing tests in a new `internal/cli/role_test.go`**
```go
package cli
import (
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func TestCommandRole(t *testing.T) {
admin := []string{"account", "whitelist", "config", "audit"}
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
for _, c := range admin {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%s should be admin", c)
}
}
for _, c := range agent {
if commandRole(c) != store.RoleAgent {
t.Errorf("%s should be agent", c)
}
}
}
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
// A human holding only the admin key can still run agent commands
// (admin is a superset → agent-role unlock falls back to the admin slot).
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, _ := store.Open(db)
ak, _ := crypto.AdminKeyFromEnv()
gk, _ := crypto.AgentKeyFromEnv()
st.InitKeys(ak, gk)
st.Close()
// Only the admin key now; agent command must still open the store.
t.Setenv("EMCLI_KEY", "")
s2, err := openStore(store.RoleAgent)
if err != nil {
t.Fatalf("agent role with only admin key should open: %v", err)
}
s2.Close()
}
```
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentCommandWorksWithOnlyAdminKey' -v`
Expected: PASS.
- [ ] **Step 9: Commit**
```bash
git add internal/cli/run.go internal/cli/admin.go internal/cli/interactive.go \
internal/cli/admin_test.go internal/cli/run_test.go internal/cli/role_test.go
git commit -m "feat(cli): two-key role routing + init bootstrap
openStore(role) selects the DEK wrap slot; admin commands require
EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both
slots from both keys. Test helpers seed the wrap slots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 4: headline security-invariant test
**Files:**
- Create: `internal/cli/security_invariant_test.go`
**Interfaces:**
- Consumes: `store.Open`, `(*Store).InitKeys`, `crypto.AdminKeyFromEnv`, `crypto.AgentKeyFromEnv`, `run`, `b64Key`, `b64AgentKey` (Tasks 23).
- [ ] **Step 1: Write the invariant test**
```go
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func dbBytes(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read db: %v", err)
}
return b
}
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
// command, and the DB must be unchanged after it tries.
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, _ := store.Open(db)
ak, _ := crypto.AdminKeyFromEnv()
gk, _ := crypto.AgentKeyFromEnv()
if err := st.InitKeys(ak, gk); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
// Simulate the agent's environment: admin key absent.
t.Setenv("EMCLI_ADMIN_KEY", "")
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "list"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
for _, args := range adminAttempts {
code, out, errOut := run(t, args...)
if code == 0 {
t.Fatalf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
}
}
if !bytes.Equal(before, dbBytes(t, db)) {
t.Fatal("DB changed despite all admin commands being refused")
}
}
```
- [ ] **Step 2: Run it**
Run: `go test ./internal/cli/ -run TestAgentKeyCannotRunAdminCommands -v -race`
Expected: PASS. (If any admin command exits 0, the role gate is broken — fix Task 3 before continuing.)
- [ ] **Step 3: Full suite + vet**
Run: `go vet ./... && go test ./... -race`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/cli/security_invariant_test.go
git commit -m "test(cli): prove agent key cannot run admin commands
Initialize a DB, drop EMCLI_ADMIN_KEY, attempt every admin command with
only EMCLI_KEY: each is refused and the DB is byte-for-byte unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Task 5: documentation
**Files:**
- Modify: `README.md`
- Modify: `USER-MANUAL.md`
- Modify: `specifications/SPEC.md`
- Modify: `skills/emcli/SKILL.md` and `skills/emcli/AGENTIC-MANUAL.md` (whichever document key setup)
**Interfaces:** none (docs only). No code; no test cycle — verification is `grep` + a manual read.
- [ ] **Step 1: README "Getting started" — two keys**
Replace the single-key export block with:
```bash
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
emcli init # writes both wrap slots
emcli doctor # confirm connect/auth (agent key is enough)
```
Add one sentence: *"`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it."*
- [ ] **Step 2: USER-MANUAL — key model + role table**
Add a "Privilege model" section documenting: the two env vars; the DEK/envelope design in one paragraph (DEK sealed under both keys; admin slot has no agent fallback); the command→role table (copy from Global Constraints above); and the agent-launcher guidance (set only `EMCLI_KEY`). Update any existing single-`EMCLI_KEY` setup and `init` instructions to the two-key flow.
- [ ] **Step 3: SPEC §4/§5 — enforced trust boundary**
In §4 "Trust boundary": change the wording from *the agent invokes only the agent commands* (convention) to the enforced model — agent commands accept `EMCLI_KEY`; admin commands require `EMCLI_ADMIN_KEY` and unlock the admin DEK slot only. In §5 "Configuration & secrets": document `EMCLI_ADMIN_KEY`, `EMCLI_KEY`, the DEK, and the two `settings` wrap rows (`dek_wrap_admin`, `dek_wrap_agent`).
- [ ] **Step 4: skill docs — agent gets only EMCLI_KEY**
In `skills/emcli/SKILL.md` / `AGENTIC-MANUAL.md`, state that the agent is provided only `EMCLI_KEY` and therefore can run `list`/`get`/`search`/`ack`/`send`/`doctor`; admin commands are unavailable to it by design. Remove any text implying the agent can configure accounts/whitelists.
- [ ] **Step 5: Verify and commit**
Run: `git grep -n "EMCLI_KEY" README.md USER-MANUAL.md specifications/SPEC.md skills/`
Confirm every setup/init reference reflects the two-key model and no doc tells the agent to run admin commands. Then:
```bash
git add README.md USER-MANUAL.md specifications/SPEC.md skills/
git commit -m "docs: document two-key privilege model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Self-Review (completed by plan author)
**Spec coverage:**
- Two env vars / role model → Tasks 1, 3. ✓
- Envelope DEK + two wrap slots → Task 2. ✓
- `s.key` becomes DEK, account crypto untouched → Task 2 (no change to `account.go`). ✓
- Command classification (doctor=agent, audit=admin) → Task 3 `commandRole`. ✓
- Admin slot has no agent fallback (enforcement linchpin) → Task 2 `Unlock`/`unlockSlot`, proved in Task 2 `TestAdminSlotNotOpenableByAgentKey` and Task 4. ✓
- Agent→admin superset fallback → Task 2 `Unlock`, Task 3 `TestAgentCommandWorksWithOnlyAdminKey`. ✓
- `init` requires both keys; idempotent (no DEK regen) → Task 3 `runInit`, Task 2 `TestInitKeysIdempotentKeepsDEK`. ✓
- No migration / no version gate → no such code added. ✓
- Error messages (admin privilege required / EMCLI_KEY not set / wrong key) → Task 3 `openStore`, Task 2 `unlockSlot`. ✓
- Existing-test fallout (helpers) → Task 3 Steps 56. ✓
- Headline invariant test → Task 4. ✓
- Docs → Task 5. ✓
**Placeholder scan:** No TBD/TODO; all code blocks complete; the only `bytes.Equal` no-op is annotated. ✓
**Type consistency:** `store.Role`/`RoleAgent`/`RoleAdmin`, `Open(path)`, `InitKeys(adminKey, agentKey)`, `Unlock(role, adminKey, agentKey)`, `commandRole`, `openStore(role)`, `AgentKeyFromEnv`/`AdminKeyFromEnv`/`NewDEK`, `b64Key`/`b64AgentKey` are used identically across tasks. ✓
@@ -0,0 +1,497 @@
# Agent-readable `account list` Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an agent holding only `EMCLI_KEY` run `emcli account list` and get a reduced JSON view (name, from, can_send), while admin keeps the full text table and `account add/edit/remove` stay admin-only.
**Architecture:** Make `commandRole` subcommand-aware so `account list` routes to the agent role; branch the `list` renderer on whether the admin key is present (admin → existing text table; agent → standard `Success` JSON envelope). No schema change; `ListAccounts` already avoids decrypting secrets.
**Tech Stack:** Go, standard library (`flag`, `encoding/json`), existing `internal/cli` envelope helpers and `internal/crypto` key loaders.
## Global Constraints
- Agent output is the existing JSON envelope shape: `{"error":bool,"error_detail":{...},"data":{...}}` via `Success(...)` / `Failure(...)` in `internal/cli/envelope.go`.
- Admin `account list` output stays byte-for-byte the current human-readable table (`NAME MODE IMAP USER`).
- The agent (reduced) view exposes only `name`, `from`, `can_send` — never the IMAP host/port or login username.
- `from = Account.SendFrom()` (explicit `FromAddress`, else `Username`). `can_send = (Mode == "RW")`.
- `account add/edit/remove` remain admin-only (hard-require `EMCLI_ADMIN_KEY`, no fallback).
- Privilege detection: a caller is "admin" iff `crypto.AdminKeyFromEnv()` returns no error.
- Spec: `docs/superpowers/specs/2026-06-23-agent-account-list-design.md`.
---
### Task 1: Route `account list` to the agent role and render by privilege
**Files:**
- Modify: `internal/cli/run.go` (`commandRole`, its call site in `Run`)
- Modify: `internal/cli/admin.go` (the `list` branch of `runAccount`; add `crypto` import)
- Modify: `internal/cli/role_test.go` (`TestCommandRole`)
- Modify: `internal/cli/security_invariant_test.go` (refused-commands set)
- Create/Test: `internal/cli/account_list_test.go`
**Interfaces:**
- Consumes: `store.Account.SendFrom() string`, `store.Account.Mode string`, `store.Store.ListAccounts() ([]store.Account, error)`, `crypto.AdminKeyFromEnv() ([]byte, error)`, `Success(map[string]any) Envelope`, `Failure(code, msg string) Envelope`, `Envelope.Write(io.Writer) error`, test helpers `adminEnv(t)`, `run(t, args...)`.
- Produces: `commandRole(args []string) store.Role` (signature changes from `commandRole(cmd string)`). Agent `account list` emits `{"data":{"accounts":[{"name":string,"from":string,"can_send":bool}]}}`.
- [ ] **Step 1: Rewrite `TestCommandRole` for the new signature and subcommand routing**
Replace the body of `TestCommandRole` in `internal/cli/role_test.go` with:
```go
func TestCommandRole(t *testing.T) {
adminCmds := [][]string{
{"whitelist"}, {"config"}, {"audit"},
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
{"account", "list"},
}
for _, c := range adminCmds {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%v should be admin", c)
}
}
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%v should be agent", c)
}
}
}
```
Note: `init` is intentionally absent from this table. `commandRole({"init"})` falls through to the agent arm, but `Run` dispatches `init` via its own bootstrap path (which requires both keys), so its `commandRole` result is never used — asserting a role for it here would be both wrong and meaningless.
- [ ] **Step 2: Run the routing test to verify it fails to compile**
Run: `go test ./internal/cli/ -run TestCommandRole`
Expected: FAIL — compile error, `commandRole` takes `string`, called with `[]string`.
- [ ] **Step 3: Make `commandRole` subcommand-aware, update its call site, and fix the security invariant**
In `internal/cli/run.go`, replace:
```go
func commandRole(cmd string) store.Role {
switch cmd {
case "account", "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
```
with:
```go
func commandRole(args []string) store.Role {
switch args[0] {
case "account":
// account list is a read-only discovery view available to agents;
// add/edit/remove mutate config and require admin.
if len(args) >= 2 && args[1] == "list" {
return store.RoleAgent
}
return store.RoleAdmin
case "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
```
In `Run`, change the call site from `role := commandRole(cmd)` to `role := commandRole(args)`.
In `internal/cli/security_invariant_test.go`, in `TestAgentKeyCannotRunAdminCommands`, replace the `adminAttempts` entry `{"account", "list"}` so the set covers a *mutating* account command instead (account list is now allowed for agents):
```go
adminAttempts := [][]string{
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
```
- [ ] **Step 4: Run the cli package tests to verify routing + invariant pass**
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentKeyCannotRunAdminCommands'`
Expected: PASS (both). The agent can no longer be proven to refuse `account list` — that is intended; the invariant now proves `account add` is refused and the DB is unchanged.
- [ ] **Step 5: Add the rendering tests (agent JSON view + admin text view)**
Create `internal/cli/account_list_test.go`:
```go
package cli
import (
"encoding/json"
"strings"
"testing"
)
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
// name/from/can_send, and never the IMAP host or login username.
func TestAccountListAgentJSONView(t *testing.T) {
adminEnv(t) // both keys + initialized temp DB
run(t, "account", "add", "--name", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
run(t, "account", "add", "--name", "alerts", "--mode", "RO",
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
// Drop the admin key → caller is an agent.
t.Setenv("EMCLI_ADMIN_KEY", "")
code, out, errOut := run(t, "account", "list")
if code != 0 {
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
}
var env struct {
Error bool `json:"error"`
Data struct {
Accounts []struct {
Name string `json:"name"`
From string `json:"from"`
CanSend bool `json:"can_send"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
}
if env.Error || len(env.Data.Accounts) != 2 {
t.Fatalf("want 2 accounts and no error, got %+v", env)
}
// The reduced view must not leak the IMAP host or the login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("agent view leaked host/username:\n%s", out)
}
got := map[string]struct {
from string
canSend bool
}{}
for _, a := range env.Data.Accounts {
got[a.Name] = struct {
from string
canSend bool
}{a.From, a.CanSend}
}
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
}
// alerts has no --from → SendFrom() falls back to the username.
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
}
}
// With the admin key present, `account list` stays the full human-readable table.
func TestAccountListAdminTextView(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
code, out, _ := run(t, "account", "list")
if code != 0 {
t.Fatalf("admin account list failed: code=%d", code)
}
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
if !strings.Contains(out, want) {
t.Fatalf("admin view missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"accounts"`) {
t.Fatalf("admin view should be text, not JSON:\n%s", out)
}
}
```
- [ ] **Step 6: Run the rendering tests to verify the agent view fails**
Run: `go test ./internal/cli/ -run 'TestAccountListAgentJSONView|TestAccountListAdminTextView'`
Expected: `TestAccountListAdminTextView` PASS (already text); `TestAccountListAgentJSONView` FAIL — output is still the text table, so `json.Unmarshal` errors.
- [ ] **Step 7: Split the `list` branch by privilege in `runAccount`**
In `internal/cli/admin.go`, add the crypto import. Change the import block:
```go
import (
"flag"
"fmt"
"io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
```
Replace the `case "list":` block (currently):
```go
case "list":
accs, err := st.ListAccounts()
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
```
with:
```go
case "list":
// Holding the admin key means the caller is the human admin (full
// detail). An agent holds only EMCLI_KEY and gets a reduced JSON view.
_, adminErr := crypto.AdminKeyFromEnv()
isAdmin := adminErr == nil
accs, err := st.ListAccounts()
if err != nil {
if isAdmin {
fmt.Fprintf(errOut, "list: %v\n", err)
} else {
_ = Failure(CodeDB, err.Error()).Write(out)
}
return 1
}
if !isAdmin {
items := make([]map[string]any, 0, len(accs))
for _, a := range accs {
items = append(items, map[string]any{
"name": a.Name,
"from": a.SendFrom(),
"can_send": a.Mode == "RW",
})
}
_ = Success(map[string]any{"accounts": items}).Write(out)
return 0
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
```
- [ ] **Step 8: Run the full cli package test suite**
Run: `go test ./internal/cli/`
Expected: PASS (all tests, including the two new rendering tests, the routing test, and the security invariant).
- [ ] **Step 9: Run the whole module to confirm nothing else regressed**
Run: `go build ./... && go test ./...`
Expected: build clean; all packages PASS.
- [ ] **Step 10: Commit**
```bash
git add internal/cli/run.go internal/cli/admin.go internal/cli/role_test.go \
internal/cli/security_invariant_test.go internal/cli/account_list_test.go
git commit -m "feat(cli): agent-readable account list (reduced JSON view)
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a
JSON envelope of name/from/can_send, while the admin keeps the full text
table. account add/edit/remove stay admin-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Update user and agent documentation
**Files:**
- Modify: `USER-MANUAL.md` (command-kinds note, role table, cheatsheet)
- Modify: `skills/emcli/SKILL.md` (allowed-commands note, command table, do/don't)
- Modify: `skills/emcli/AGENTIC-MANUAL.md` (§4 account discovery)
**Interfaces:**
- Consumes: behavior shipped in Task 1 (agent `account list``{"data":{"accounts":[{name,from,can_send}]}}`).
- Produces: docs only; no code interface.
- [ ] **Step 1: USER-MANUAL — note that `account list` is the one agent-readable admin view**
In `USER-MANUAL.md`, in the "Two kinds of commands" block, change the Admin bullet (line ~36) from:
```
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
and are for *you*, the human. They print human-readable text or open an interactive form.
```
to:
```
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
```
- [ ] **Step 2: USER-MANUAL — update the role table**
Replace the role table rows (lines ~127-128):
```
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
```
with:
```
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
`can_send` — no host or login username.
```
- [ ] **Step 3: USER-MANUAL — annotate the cheatsheet**
In the cheatsheet (line ~597), change:
```
emcli account list # list accounts (no secrets)
```
to:
```
emcli account list # full table (admin) / name+from+can_send JSON (agent)
```
- [ ] **Step 4: SKILL.md — carve `account list` out of the forbidden-commands rule**
In `skills/emcli/SKILL.md`, change the first bullet (lines ~20-24):
```
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
with a privilege error.
```
to:
```
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
`init`. `emcli` will refuse those with a privilege error.
```
- [ ] **Step 5: SKILL.md — add `account list` to the command table**
In the command table (after the `send` row, line ~122), add:
```
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
```
- [ ] **Step 6: SKILL.md — fix the "don't" bullet**
Change (lines ~147-148):
```
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
```
to:
```
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
```
Also change the ✅ bullet (line ~145) from `Ask the user for the account name; keep bodies plain text.` to:
```
- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
```
- [ ] **Step 7: AGENTIC-MANUAL — document discovery via `account list`**
In `skills/emcli/AGENTIC-MANUAL.md`, replace the body of `## 4. Find the account(s)` (lines ~88-97):
```
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
configured accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Just take the account name from the user and start with the workflow in `SKILL.md`.
```
with:
```
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
with one entry per account:
```bash
emcli account list
# {"error":false,"error_detail":{},"data":{"accounts":[
# {"name":"gmail","from":"me@gmail.com","can_send":true},
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
```
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
(also an agent command) checks that accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Then start with the workflow in `SKILL.md`.
```
- [ ] **Step 8: Sanity-check the docs render and reference reality**
Run: `grep -n "account list" USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md`
Expected: each file shows the updated `account list` references; no remaining text claims the agent cannot run `account list`.
- [ ] **Step 9: Commit**
```bash
git add USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md
git commit -m "docs: agent can discover accounts via account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Notes for the implementer
- Run all Go commands from the repo root (`/home/steve/src/emcli`).
- The two intentional red states are Step 2 (compile error) and Step 6 (agent JSON test) in Task 1. Every other test run must be green.
- Do not change the admin text table or add columns to it — admin output must stay identical.
- `adminEnv(t)` and `run(t, ...)` live in `internal/cli/admin_test.go`; `b64Key`/`b64AgentKey` in `internal/cli/run_test.go`. No new helpers are needed.
@@ -0,0 +1,688 @@
# Send-as "From" Address Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let each account configure the email address used as the `From:` when sending, instead of always reusing the login username.
**Architecture:** Add a single freeform RFC 5322 `from_address` field to the account (bare address or `Display Name <addr>`). When blank, sending falls back to the login username — no migration of existing data. The header `From:` carries the full identity; the SMTP envelope sender is derived as the bare address. A version-gated `ALTER TABLE` migration adds the column to existing databases.
**Tech Stack:** Go, SQLite (`modernc.org/sqlite`), `github.com/emersion/go-message/mail` for MIME, `net/mail` (stdlib) for address validation, bubbletea TUI.
## Global Constraints
- Module path: `git.dcglab.co.uk/steve/emcli`.
- The `from_address` field is **not** a secret — store as plaintext (like `username`), never encrypted.
- A blank from-address is always valid and means "fall back to `Account.Username`".
- Follow existing patterns: `nullStr` for nullable text columns, `sql.NullString` in `scanAccount`, `fs.Visit` overlay for `account edit` flags.
- Tests are Go table/unit tests in the same package; reuse the existing `openTemp(t)` helper where keys are needed.
---
### Task 1: Store — field, migration, persistence
**Files:**
- Modify: `internal/store/account.go` (Account struct, AddAccount, GetAccount, ListAccounts, UpdateAccount, scanAccount)
- Modify: `internal/store/schema.go` (add column, bump schemaVersion)
- Modify: `internal/store/store.go` (run migration in Open)
- Modify: `internal/store/store_test.go` (update schema_version expectations to "2")
- Test: `internal/store/account_test.go` (SendFrom + round-trip), `internal/store/store_test.go` (migration)
**Interfaces:**
- Produces: `store.Account.FromAddress string` field; method `func (a Account) SendFrom() string`; schema at version 2 with `accounts.from_address TEXT` column.
- [ ] **Step 1: Write the failing test for SendFrom + round-trip**
Add to `internal/store/account_test.go`:
```go
func TestSendFromFallsBackToUsername(t *testing.T) {
a := Account{Username: "login@example.com"}
if got := a.SendFrom(); got != "login@example.com" {
t.Fatalf("blank from-address should fall back to username, got %q", got)
}
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if got := a.SendFrom(); got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("set from-address should win, got %q", got)
}
}
func TestAddGetAccountRoundTripsFromAddress(t *testing.T) {
s := openTemp(t)
a := sampleAccount()
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if _, err := s.AddAccount(a); err != nil {
t.Fatalf("AddAccount: %v", err)
}
got, err := s.GetAccount("work")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FromAddress not round-tripped: %q", got.FromAddress)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `go test ./internal/store/ -run 'TestSendFrom|TestAddGetAccountRoundTripsFromAddress' -v`
Expected: FAIL — `a.SendFrom undefined` and `a.FromAddress undefined`.
- [ ] **Step 3: Add the field and SendFrom method**
In `internal/store/account.go`, add `FromAddress` to the struct (right after `Username`) and the method. The struct becomes:
```go
type Account struct {
ID int64
Name string
Mode string // RO | RW
IMAPHost string
IMAPPort int
IMAPSecurity string // tls | starttls
SMTPHost string // nullable for RO accounts
SMTPPort int
SMTPSecurity string // tls | starttls
AuthType string // password | oauth2
Username string
FromAddress string // send-as identity; blank ⇒ fall back to Username
Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool
WhitelistOutEnabled bool
SubjectRegex string
ProcessBacklog bool
}
// SendFrom returns the From identity for outgoing mail, falling back to the
// login username when no explicit from-address is configured.
func (a Account) SendFrom() string {
if a.FromAddress != "" {
return a.FromAddress
}
return a.Username
}
```
- [ ] **Step 4: Thread from_address through persistence**
In `internal/store/account.go`:
AddAccount — add `from_address` to the column list and a value placeholder. The INSERT becomes:
```go
res, err := s.db.Exec(`
INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username, nullStr(a.FromAddress),
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
```
GetAccount and ListAccounts — add `from_address` to both SELECT column lists, right after `username`:
```go
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
```
UpdateAccount — add `from_address=?` to the SET clause and its arg (after `username=?` / `a.Username`):
```go
set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
smtp_host=?, smtp_port=?, smtp_security=?,
auth_type=?, username=?, from_address=?,
whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
args := []any{
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username, nullStr(a.FromAddress),
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
}
```
scanAccount — add a `fromAddr sql.NullString` local, scan it after `&a.Username`, and assign. The var block gains `fromAddr sql.NullString`; the Scan call becomes:
```go
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&smtpHost, &smtpPort, &smtpSec,
&a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog)
```
and after the existing assignments add:
```go
a.FromAddress = fromAddr.String
```
- [ ] **Step 5: Add the column to the schema and bump the version**
In `internal/store/schema.go`, change `const schemaVersion = 1` to `const schemaVersion = 2`, and add the column to the `accounts` CREATE TABLE, right after the `username` line:
```sql
username TEXT NOT NULL,
from_address TEXT,
enc_password BLOB,
```
- [ ] **Step 6: Run the round-trip + SendFrom tests to verify they pass**
Run: `go test ./internal/store/ -run 'TestSendFrom|TestAddGetAccountRoundTripsFromAddress' -v`
Expected: PASS.
- [ ] **Step 7: Write the failing migration test**
The existing `TestOpenCreatesSchemaAndIsIdempotent` will now fail because it expects `schema_version == "1"`. Update both assertions in `internal/store/store_test.go` from `"1"` to `"2"`. Then add a new migration test in `internal/store/store_test.go`:
```go
func TestOpenMigratesV1AddsFromAddress(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
// Hand-build a v1 database: accounts table WITHOUT from_address, a settings
// table pinned at schema_version=1, and one pre-existing account row.
raw, err := sql.Open("sqlite", p)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
const v1Schema = `
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
mode TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_security TEXT NOT NULL,
smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
enc_password BLOB,
enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB,
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
subject_regex TEXT,
process_backlog INTEGER NOT NULL DEFAULT 0
);
INSERT INTO settings(key,value) VALUES ('schema_version','1');
INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username)
VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com');
`
if _, err := raw.Exec(v1Schema); err != nil {
t.Fatalf("seed v1 schema: %v", err)
}
raw.Close()
// Open via the store: the migration must add from_address and bump to v2.
s, err := Open(p)
if err != nil {
t.Fatalf("Open (migrate): %v", err)
}
defer s.Close()
if v, _ := s.GetSetting("schema_version"); v != "2" {
t.Fatalf("schema_version after migrate: %q, want 2", v)
}
// ListAccounts SELECTs from_address; it would error if the column were missing.
accs, err := s.ListAccounts()
if err != nil {
t.Fatalf("ListAccounts after migrate: %v", err)
}
if len(accs) != 1 || accs[0].FromAddress != "" {
t.Fatalf("legacy account wrong after migrate: %+v", accs)
}
if got := accs[0].SendFrom(); got != "login@example.com" {
t.Fatalf("legacy account should send from username, got %q", got)
}
}
```
Ensure `internal/store/store_test.go` imports `"database/sql"` (add it to the import block).
- [ ] **Step 8: Run the migration test to verify it fails**
Run: `go test ./internal/store/ -run 'TestOpenMigratesV1AddsFromAddress|TestOpenCreatesSchemaAndIsIdempotent' -v`
Expected: migration test FAILS with a "no such column: from_address" error from `ListAccounts` (the column is in the schema for new DBs but not added to the seeded v1 DB).
- [ ] **Step 9: Add the migration runner to Open**
In `internal/store/store.go`, replace the post-schema version block with a call to a new `migrate` method. Change the tail of `Open` from:
```go
s := &Store{db: db}
if _, err := s.GetSetting("schema_version"); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
db.Close()
return nil, err
}
}
return s, nil
```
to:
```go
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
```
and add the method:
```go
// migrate brings an existing database up to the current schemaVersion. A brand-
// new database (no schema_version yet) already has every column from schemaSQL,
// so it is simply stamped at the current version. Each older version runs its
// forward step. The version gate makes every step idempotent across reopens.
func (s *Store) migrate() error {
v, err := s.GetSetting("schema_version")
if err != nil {
// Fresh database: schemaSQL created all columns already.
return s.SetSetting("schema_version", strconv.Itoa(schemaVersion))
}
ver, _ := strconv.Atoi(v)
if ver < 2 {
if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil {
return fmt.Errorf("migrate to v2: %w", err)
}
if err := s.SetSetting("schema_version", "2"); err != nil {
return err
}
}
return nil
}
```
Confirm `internal/store/store.go` already imports `fmt` and `strconv` (it does); no import changes needed.
- [ ] **Step 10: Run the full store test suite to verify it passes**
Run: `go test ./internal/store/ -v`
Expected: PASS (migration, idempotency, round-trip, and existing tests all green).
- [ ] **Step 11: Commit**
```bash
git add internal/store/account.go internal/store/schema.go internal/store/store.go internal/store/account_test.go internal/store/store_test.go
git commit -m "feat(store): add account from_address field + v2 migration
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Mail — envelope sender vs header From
**Files:**
- Modify: `internal/mail/send.go` (add `envelopeFrom` helper, use it in `SendSMTP`)
- Test: `internal/mail/send_test.go` (envelopeFrom table test + BuildMIME display-name assertion)
**Interfaces:**
- Consumes: `OutgoingMessage.From` may now hold `Display Name <addr>`.
- Produces: `func envelopeFrom(from string) string` (package-private) — bare address for the SMTP envelope.
- [ ] **Step 1: Write the failing test for envelopeFrom and the display-name header**
Add to `internal/mail/send_test.go`:
```go
func TestEnvelopeFromStripsDisplayName(t *testing.T) {
cases := map[string]string{
"Steve Cliff <me@stevecliff.com>": "me@stevecliff.com",
"me@stevecliff.com": "me@stevecliff.com",
"<me@stevecliff.com>": "me@stevecliff.com",
"not a valid address": "not a valid address", // unparseable ⇒ passthrough
}
for in, want := range cases {
if got := envelopeFrom(in); got != want {
t.Fatalf("envelopeFrom(%q) = %q, want %q", in, got, want)
}
}
}
func TestBuildMIMEKeepsDisplayNameInHeader(t *testing.T) {
raw, err := BuildMIME(OutgoingMessage{
From: "Steve Cliff <me@stevecliff.com>",
To: []string{"you@example.com"},
Subject: "hi",
BodyText: "body",
Date: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("BuildMIME: %v", err)
}
if !strings.Contains(string(raw), "Steve Cliff") {
t.Fatalf("From header lost display name:\n%s", raw)
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `go test ./internal/mail/ -run 'TestEnvelopeFromStripsDisplayName|TestBuildMIMEKeepsDisplayNameInHeader' -v`
Expected: FAIL — `envelopeFrom` undefined. (The BuildMIME test may already pass, since `SetAddressList` renders display names; the envelopeFrom test is the gating failure.)
- [ ] **Step 3: Add the envelopeFrom helper and use it in SendSMTP**
In `internal/mail/send.go`, add the helper (near `addrList`):
```go
// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
// any display name. A display-name From (e.g. "Name <addr>") is a valid header
// but an invalid envelope sender, so it must be reduced to the bare address.
// Unparseable input is passed through unchanged (preserves prior behaviour for
// plain addresses).
func envelopeFrom(from string) string {
if a, err := gomail.ParseAddress(from); err == nil {
return a.Address
}
return from
}
```
In `SendSMTP`, change the send line from:
```go
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
```
to:
```go
if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `go test ./internal/mail/ -run 'TestEnvelopeFromStripsDisplayName|TestBuildMIMEKeepsDisplayNameInHeader' -v`
Expected: PASS.
- [ ] **Step 5: Run the full mail suite**
Run: `go test ./internal/mail/`
Expected: PASS (`imap_integration_test` may skip without a live server — that is fine).
- [ ] **Step 6: Commit**
```bash
git add internal/mail/send.go internal/mail/send_test.go
git commit -m "feat(mail): derive bare envelope sender from display-name From
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: CLI + TUI — inputs, validation, and send wiring
**Files:**
- Modify: `internal/cli/send.go:26` (use `acc.SendFrom()`)
- Modify: `internal/cli/admin.go` (`--from` flag on `account add` and `account edit`)
- Modify: `internal/tui/account.go` (Fields field, fieldDef, ToAccount, FieldsFromAccount, fieldValue, collect, validation helper, Validate)
- Test: `internal/tui/account_test.go` (validation + round-trip), `internal/cli/send_test.go` (send uses configured from)
**Interfaces:**
- Consumes: `store.Account.FromAddress`, `store.Account.SendFrom()` (Task 1).
- Produces: `func ValidFromAddress(s string) error` exported from `tui` package, used by both `Fields.Validate` and `internal/cli/admin.go`.
- [ ] **Step 1: Write the failing TUI validation + round-trip tests**
Add to `internal/tui/account_test.go`:
```go
func TestValidateRejectsBadFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "not an address"
if err := f.Validate(); err == nil {
t.Fatal("malformed from-address should fail validation")
}
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
if err := f.Validate(); err != nil {
t.Fatalf("display-name from-address should validate: %v", err)
}
f.FromAddress = "me@stevecliff.com"
if err := f.Validate(); err != nil {
t.Fatalf("bare from-address should validate: %v", err)
}
f.FromAddress = "" // blank ⇒ fall back, always valid
if err := f.Validate(); err != nil {
t.Fatalf("blank from-address should validate: %v", err)
}
}
func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
acc, _ := f.ToAccount()
if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
}
back := FieldsFromAccount(acc)
if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `go test ./internal/tui/ -run 'TestValidateRejectsBadFromAddress|TestFieldsFromToAccountCarriesFromAddress' -v`
Expected: FAIL — `f.FromAddress` undefined.
- [ ] **Step 3: Add the field, validation helper, and wiring in tui/account.go**
In `internal/tui/account.go`:
Add `"net/mail"` to the import block.
Add `FromAddress` to `Fields` (after the `Username, Password` line):
```go
type Fields struct {
Name, Mode string
IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool
SubjectRegex string
}
```
Add the exported validator:
```go
// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
// address (bare or "Display Name <addr>"). A blank value is valid: sending
// falls back to the login username.
func ValidFromAddress(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
if _, err := mail.ParseAddress(s); err != nil {
return errors.New("from address must be a valid email address")
}
return nil
}
```
In `Fields.Validate`, add before the final `return nil`:
```go
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
```
In `ToAccount`, set the field on the assembled account (add to the struct literal, after `Username/Password`):
```go
AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
```
In `FieldsFromAccount`, prefill it (after `Username: a.Username,`):
```go
Username: a.Username,
FromAddress: a.FromAddress,
```
Add a `fieldDef` to `fieldDefs`, immediately after the `username` entry (so it appears next to it in the form):
```go
{key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true},
```
- [ ] **Step 4: Wire from_address through fieldValue and collect**
In `internal/tui/account.go`, find `fieldValue` (≈ line 147) and add a `case "from_address": return f.FromAddress` alongside the other string cases. Find `collect` (≈ line 228) and add the inverse mapping so the typed value is written back to `f.FromAddress` (mirror exactly how `username` is handled in that function's switch).
- [ ] **Step 5: Run the tui tests to verify they pass**
Run: `go test ./internal/tui/ -v`
Expected: PASS (new validation/round-trip tests plus existing form tests).
- [ ] **Step 6: Write the failing CLI send test**
The harness in `internal/cli/send_test.go` records every sent message into `*sent`, so assert directly on `m.From`. Add:
```go
func TestSendUsesConfiguredFromAddress(t *testing.T) {
acc := rwAccount()
acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
d, sent, _ := sendDeps(t, acc, nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
if got := (*sent)[0].From; got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("From = %q, want configured from-address", got)
}
}
func TestSendFallsBackToUsernameAsFrom(t *testing.T) {
// rwAccount has no FromAddress, so From must be the login username.
d, sent, _ := sendDeps(t, rwAccount(), nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if got := (*sent)[0].From; got != "emcli@stevecliff.com" {
t.Fatalf("From = %q, want username fallback", got)
}
}
```
- [ ] **Step 7: Run the CLI send test to verify it fails**
Run: `go test ./internal/cli/ -run 'TestSendUsesConfiguredFromAddress' -v`
Expected: FAIL — `send.go` still sets `From: acc.Username`.
- [ ] **Step 8: Wire send.go and add the --from flags**
In `internal/cli/send.go`, change:
```go
msg := mail.OutgoingMessage{
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
```
to:
```go
msg := mail.OutgoingMessage{
From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
```
In `internal/cli/admin.go`, `account add`: register the flag and validate it.
Add alongside the other `add` flags:
```go
from := fs.String("from", "", "send-as address (blank = use username)")
```
After the required-fields check, before building `acc`:
```go
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
```
Add `FromAddress: *from,` to the `store.Account{...}` literal.
In `account edit`: register the flag:
```go
from := fs.String("from", "", "send-as address (blank keeps existing)")
```
Add a case to the `fs.Visit` switch:
```go
case "from":
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return // see note below
}
acc.FromAddress = *from
```
Because `fs.Visit`'s callback cannot return an exit code, instead validate `--from` before the `fs.Visit` block (the flag value is available regardless of Visit) and set the field inside Visit:
```go
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
// ... existing GetAccount + fs.Visit ...
case "from":
acc.FromAddress = *from
```
Use this pre-Visit validation form (not the in-callback `return`).
- [ ] **Step 9: Run the CLI suite to verify it passes**
Run: `go test ./internal/cli/ -v`
Expected: PASS.
- [ ] **Step 10: Build and vet the whole module**
Run: `go build ./... && go vet ./... && go test ./...`
Expected: clean build, no vet complaints, all tests PASS.
- [ ] **Step 11: Commit**
```bash
git add internal/cli/send.go internal/cli/admin.go internal/cli/send_test.go internal/tui/account.go internal/tui/account_test.go
git commit -m "feat(cli): configurable send-as From address (flags, TUI, validation)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Notes for the implementer
- The `account list` output (`admin.go`, `case "list"`) shows NAME/MODE/IMAP/USER. Adding a FROM column is optional polish, not required — leave it unless asked.
- `USER-MANUAL.md` / `README.md` mention `account add` flags; if they enumerate flags explicitly, add `--from` there in the relevant commit. Grep first: `grep -rn 'account add\|--username' README.md USER-MANUAL.md docs/`.
- Existing send tests in `internal/cli/send_test.go` define the harness shape — read them before writing Task 3 Step 6 rather than inventing a new fake.
@@ -0,0 +1,192 @@
# Two-key privilege separation — design
**Date:** 2026-06-22
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
Today `EMCLI_KEY` does double duty: it is both the AES-256 key that decrypts the
stored mail-account passwords *and* the only gate for every command. The agent
process is launched with `EMCLI_KEY` so it can read and send mail, but that same
key authorizes the admin commands (`account`, `whitelist`, `config`, `audit`,
`init`) too. SPEC §4's "trust boundary" — *the agent invokes only agent
commands* — is convention, not enforcement. A prompt-injected / "forced" agent
holding `EMCLI_KEY` can run `account add`, edit a whitelist, or flip `config`,
dismantling the very guardrails emcli exists to enforce.
## Goal
Split privilege into two environment keys so that the boundary is *enforced*,
not merely conventional:
- `EMCLI_ADMIN_KEY` — authorizes **all** commands.
- `EMCLI_KEY` — authorizes **agent** (non-admin) commands only.
The agent's launch environment is given **only** `EMCLI_KEY`. Because the admin
secret is simply absent from that environment, no instruction can make the agent
perform an admin action — the binary refuses, and there is no key present that
could authorize it.
## Constraints / decisions
These were settled during brainstorming:
1. **Two distinct env vars** (`EMCLI_ADMIN_KEY`, `EMCLI_KEY`) — role is named and
readable in configs/skills, not inferred from a single value.
2. **Both keys must be able to decrypt account passwords.** The agent decrypts to
talk to IMAP/SMTP; admin's `account add` / `doctor` need it too. So the keys
differ in *authorization*, not in crypto capability. This forces envelope
encryption (one data key, wrapped per role).
3. **Agent-key commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`.
**Admin-only:** `account`, `whitelist`, `config`, `audit`, `init`.
4. **No migration, no schema-version gate.** No third party uses this DB and the
data is being scrapped anyway. `init` writes the new wrap slots into a fresh
DB; existing DBs are simply re-created by re-running `init`.
## Approach: envelope encryption (one DEK, wrapped per role)
### Key model
- `EMCLI_ADMIN_KEY` and `EMCLI_KEY` are base64 32-byte **key-encryption keys
(KEKs)**. Neither directly encrypts account secrets.
- `emcli init` generates a random 32-byte **data-encryption key (DEK)**. All
account secrets (`enc_password`, `enc_oauth_client_id`,
`enc_oauth_client_secret`, `enc_oauth_refresh_token`) are sealed under the DEK,
exactly as they are sealed under the raw key today.
- The DEK is stored in the `settings` table, sealed twice:
- `dek_wrap_admin = Seal(adminKey, DEK)`
- `dek_wrap_agent = Seal(agentKey, DEK)`
- The DEK never touches disk in cleartext.
### Why this enforces the boundary
The store already isolates all secret crypto behind one `s.key` field
(`account.go` calls `crypto.Seal(s.key, …)` / `crypto.Open(s.key, …)`). In the
new model **`s.key` simply becomes the DEK** — `account.go`, `send.go`, etc. do
not change. The entire change lives in *how the DEK is obtained*:
- **Admin command** → require `EMCLI_ADMIN_KEY`; unwrap the DEK from
`dek_wrap_admin` **only**. If the var is unset or fails to unwrap → hard error,
**with no fallback to the agent slot.** This is the enforcement linchpin.
- **Agent command** → prefer `EMCLI_KEY`, unwrap from `dek_wrap_agent`. If
`EMCLI_KEY` is unset but `EMCLI_ADMIN_KEY` is present, fall back to the admin
slot (admin is a superset; a human holding only the admin key can still run
`list` / `send`).
The agent process holds `EMCLI_KEY` only. Admin commands refuse to unwrap from
anything but `dek_wrap_admin`, which `EMCLI_KEY` cannot open. The agent cannot
hold a secret that authorizes config changes — it is absent from its
environment, not merely gated by a flag.
## Components
### `internal/crypto`
- Generalize `KeyFromEnv` to read a named variable; add `AdminKeyFromEnv()` and
`AgentKeyFromEnv()` thin wrappers.
- DEK wrap/unwrap **reuses** the existing `Seal` / `Open` — no new primitive.
- A helper to generate a random 32-byte DEK.
### `internal/store`
Split *unlock* from *open* so the DEK can be read from a wrap slot after the DB
is open (settings rows are plaintext, so no key is needed to read them):
- `store.Open(path) (*Store, error)` — opens/creates the DB, applies the schema,
key still **locked** (`s.key == nil`).
- `(*Store).Unlock(role Role, adminKey, agentKey []byte) error` — reads the slot
for the role, unwraps the DEK, sets `s.key`. Missing slot or wrong key → clear
error.
- `(*Store).InitKeys(adminKey, agentKey []byte) error` — generates a random DEK,
seals it under both KEKs, writes `dek_wrap_admin` + `dek_wrap_agent`, sets
`s.key`.
No table changes; two new rows in the existing `settings` table.
`account.go` / mail crypto is untouched (still `crypto.Seal(s.key, …)`).
### `internal/cli`
- One `commandRole(cmd string) Role` function in `run.go` — the single source of
truth for the classification table above.
- `openStore` gains a `role` parameter and performs slot selection via
`Open` + `Unlock`.
- Each `run*` helper passes its role. `init` uses the bootstrap path
(`Open` + `InitKeys`, or `Unlock(admin)` if already initialized).
### Command classification
| Command | Role |
|-------------------------------------------|-----------|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | agent |
| `account`, `whitelist`, `config`, `audit` | admin |
| `init` | bootstrap (needs **both** keys) |
| `help` / no args | none (no DB access) |
## `init` & key generation UX
`init` requires **both** keys present (it writes both wrap slots). If either is
missing it errors with what's needed plus the generation hint.
Idempotency: if the wrap slots already exist, `init` does **not** regenerate the
DEK (that would orphan existing sealed passwords) — it unlocks via the admin slot
and keeps today's "already initialized; adding another account" behavior. Fresh
DB → generate DEK, then add the first account.
Documented flow (README + USER-MANUAL):
```bash
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # human keeps this
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # agent launcher gets only this
emcli init
```
The agent's orchestrator is configured with **only** `EMCLI_KEY`.
## Error handling
- Agent key on an admin command → exit non-zero, stderr:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
(Admin commands print human-readable output, not JSON.)
- Admin command, neither key set → `EMCLI_ADMIN_KEY is not set`.
- Agent command, neither key set → existing `EMCLI_KEY is not set` JSON envelope
(`CodeConfig`).
- Key present but does not unwrap its slot → `wrong key for this DB`, not a raw
GCM auth-tag error.
## Testing
- **crypto:** wrap/unwrap round-trip; an admin-sealed DEK is *not* openable with
the agent key.
- **store:** after `InitKeys`, both `Unlock(admin)` and `Unlock(agent)` recover
the *same* DEK and decrypt an account password; `Unlock` with a wrong key fails
cleanly.
- **routing:** table test for `commandRole`; agent key on an admin command →
refused, non-zero, correct message; agent command works with the agent key;
agent command works with the admin key (superset fallback); `init` refused
unless both keys are set.
- **Headline security invariant:** initialize a DB, then with **only `EMCLI_KEY`**
set, attempt every admin command (`account add/list`, `whitelist …`,
`config set`, `audit`) and assert each is refused *and the DB is byte-for-byte
unchanged*. This is the test that proves the bug is fixed.
- **Existing-test fallout:** the `adminEnv` / `b64Key` helpers in `admin_test.go`
(and `run_test.go`) set `EMCLI_KEY` against a raw-key store — update them to
provision both wrap slots via `InitKeys`. Audit all `t.Setenv("EMCLI_KEY", …)`
sites.
## Documentation updates
- README "Getting started" — two-key generation flow.
- USER-MANUAL — key model, role/command table, agent-launcher configuration
(only `EMCLI_KEY`), updated `init`.
- `skills/emcli` SKILL.md / AGENTIC-MANUAL.md — the agent is given only
`EMCLI_KEY`; admin commands are not available to it.
- SPEC §4 (trust boundary) and §5 (secrets) — describe enforced two-key model.
## Out of scope (possible follow-ups)
- Key rotation commands (re-wrap the DEK under a new admin or agent key without
re-encrypting passwords — the envelope design supports this cheaply, but it is
not built now).
- A distinct read-only vs read-write *agent* tier (orthogonal; per-account
RO/RW already exists).
@@ -0,0 +1,141 @@
# Agent-readable `account list` — design
**Date:** 2026-06-23
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
An agent process is launched with only `EMCLI_KEY` (the two-key model — see
`2026-06-22-two-key-privilege-design.md`). Every agent command takes
`--account NAME`, but the agent has no way to *discover* which accounts exist:
`account list` is classified admin-only and is refused under the agent key. So
account names must be supplied to the agent out of band, which is brittle and
defeats the point of a self-directed agent.
The two-key spec deliberately gated the whole `account` command to admin because
`account add/edit/remove` mutate configuration. But `account list` is read-only
and exposes no secrets — `store.ListAccounts` never decrypts the password
(`enc_password` is scanned and discarded). Gating *discovery* behind admin is
stricter than the threat model requires.
## Goal
Let an agent holding only `EMCLI_KEY` run `account list` to discover the
accounts it may operate on, while:
- keeping `account add/edit/remove` admin-only (mutation stays gated);
- exposing to the agent only what it needs — **account name, the send-as From
address, and whether the account can send** — and *not* the IMAP host/port or
login username;
- preserving the admin's existing full-detail view unchanged.
## Constraints / decisions
Settled during brainstorming:
1. **Scope is exactly `account list`.** `whitelist list`, `config get`, and
`audit list` stay admin-only. `audit` in particular is oversight data and
must remain invisible to the agent.
2. **Privilege-dependent rendering.** The admin keeps the current full table
(`NAME MODE IMAP USER`, human-readable). The agent gets a *reduced* view
containing only name, From, and send-capability.
3. **Agent output is JSON.** The agent is a machine consumer, so its
`account list` emits the standard agent envelope (like `list`/`get`/`search`),
not a text table. The admin path stays human-readable text.
4. **No secret exposure, no schema change.** `ListAccounts` already avoids
decrypting passwords; nothing about the data model changes.
## Approach
Reclassify `account list` to the agent role, and branch rendering on the
caller's actual privilege (presence of the admin key).
### Routing (`internal/cli/run.go`)
`commandRole` becomes subcommand-aware for `account`:
- `account list``RoleAgent`
- `account add | edit | remove` (and bare `account`) → `RoleAdmin`
- all other commands unchanged.
`commandRole` currently takes `cmd string`; it changes to take the full
`args []string` so it can peek at the `account` subcommand. `Run` passes
`args` through. This keeps `commandRole` the single source of truth for the
classification table.
Authorization mechanics are unchanged: `openStore(RoleAgent)` requires
`EMCLI_KEY` (falling back to the admin key for a human who holds only that and
runs `account list`). `account add/edit/remove` still hard-require
`EMCLI_ADMIN_KEY` with no fallback.
### Rendering (`internal/cli/admin.go`, the `list` branch)
Determine privilege from the environment: `_, err := crypto.AdminKeyFromEnv()`;
`isAdmin := err == nil`. (Holding the admin key *is* being the admin in this
trust model. A human with only the admin key still gets the admin view; an agent
with only `EMCLI_KEY` gets the reduced view.)
- **Admin** → existing full table, unchanged:
```
NAME MODE IMAP USER
work RW imap.example.com:993 me@example.com
```
- **Agent** → JSON envelope to stdout:
```json
{"error":false,"error_detail":{},"data":{"accounts":[
{"name":"work","from":"me@example.com","can_send":true},
{"name":"alerts","from":"alerts@example.com","can_send":false}
]}}
```
where `from = Account.SendFrom()` (the configured From address, falling back
to the username) and `can_send = (Mode == "RW")` (RW accounts have SMTP
configured; RO cannot send). The IMAP host/port and the raw login username are
**not** emitted (when From falls back to the username it may coincide with it,
which is acceptable — the user asked for From specifically).
The reduced view reuses the existing `Success(...)` envelope and `Envelope.Write`
helper; no new output machinery.
## Error handling
- Agent key on `account add/edit/remove` → unchanged:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`, non-zero.
- Agent `account list` with neither key set → the agent-command config error
(`EMCLI_KEY is not set`), surfaced the same way other agent commands surface a
missing key. Because the agent path now emits JSON, a missing-key failure on
`account list` is a JSON `Failure(CodeConfig, …)` envelope, consistent with the
other agent commands.
- DB/list errors on the agent path → `Failure(CodeDB, …)` envelope; on the admin
path → existing `list: <err>` text to stderr.
## Testing
- **Routing:** extend the `commandRole` table test — `account list` → agent;
`account add` / `account edit` / `account remove` → admin.
- **Agent view:** with only `EMCLI_KEY`, `account list` exits 0, emits a valid
envelope, and the `data.accounts` entries carry `name`/`from`/`can_send` — and
the output does **not** contain the IMAP host or the login username.
- **Admin view:** with `EMCLI_ADMIN_KEY`, `account list` still prints the full
`NAME MODE IMAP USER` table (regression guard).
- **`can_send`:** an RW account yields `can_send:true`, an RO account
`can_send:false`; `from` reflects `SendFrom()` (explicit From, else username).
- **Security invariant (`security_invariant_test.go`):** remove
`{"account","list"}` from the refused-commands set (it is now allowed) and
replace it with a *mutating* `account add …` attempt, so the "forced agent
cannot run admin commands and the DB is byte-for-byte unchanged" invariant
still covers the `account` family.
## Documentation updates
- **USER-MANUAL:** role/command table — `account list` is agent-readable
(reduced JSON view); `account add/edit/remove` remain admin.
- **`skills/emcli` (SKILL.md / AGENTIC-MANUAL.md):** document that the agent
discovers accounts via `account list`, including the JSON shape
(`name`, `from`, `can_send`).
## Out of scope
- Agent access to `whitelist list`, `config get`, or `audit list`.
- Any change to the admin `account list` columns or to the data model.
- JSON output for the admin `account list` path (stays human-readable text).
@@ -0,0 +1,118 @@
# Send-as "From" address — design
**Date:** 2026-06-23
**Status:** Approved (pending spec review)
## Problem
An account's configuration has no field for the email address used as the
`From:` when sending mail. Today the From is silently aliased to the login
username (`internal/cli/send.go:26`, `From: acc.Username`), and neither
`store.Account` nor the `accounts` table has any `from`/`address`/`email`
column.
This works only when the login username is exactly the desired send-as
address. It breaks for:
- providers where the login is an account ID rather than an email,
- sending from an **alias** of the mailbox,
- wanting a **display name** (`Steve Cliff <me@…>`) rather than a bare address,
- Gmail App Passwords where envelope-from and header-from may differ.
## Decisions
- **Field shape:** a single freeform RFC 5322 From identity — bare
(`me@stevecliff.com`) or with a display name
(`Steve Cliff <me@stevecliff.com>`). One field, not a split
address/display-name pair.
- **Fallback:** when the from-address is blank, fall back to
`Account.Username` (current behaviour). No migration/backfill of existing
accounts required; they keep working unchanged.
## Design
### 1. Data model
Add `FromAddress string` to `store.Account` and a `from_address TEXT` column to
the `accounts` table. **Not encrypted** — it is not a secret (it appears in
every outgoing header), so it is stored as plaintext like `username`.
### 2. Fallback in one place
Add a method so the fallback rule lives in exactly one spot:
```go
// SendFrom returns the From identity for outgoing mail, falling back to the
// login username when no explicit from-address is configured.
func (a Account) SendFrom() string {
if a.FromAddress != "" {
return a.FromAddress
}
return a.Username
}
```
`internal/cli/send.go` changes `From: acc.Username``From: acc.SendFrom()`.
### 3. Envelope sender vs header From
A display-name From breaks the SMTP envelope: `c.SendMail("Steve <me@…>", …)`
is invalid — the envelope sender must be the **bare** address.
- `mail.BuildMIME` keeps using the full `m.From` for the `From:` header
(go-message renders `Name <addr>` correctly via `SetAddressList`).
- `mail.SendSMTP` derives the envelope sender as the bare address:
`gomail.ParseAddress(m.From).Address`. If parsing fails, fall back to the raw
`m.From` (preserves today's behaviour for plain addresses).
Header carries the display name; envelope carries the bare address.
### 4. Migration
The schema is v1, applied via `CREATE TABLE … IF NOT EXISTS`, which will not add
a column to an existing DB, and there is no migration runner yet.
- Add the `from_address` column to the `CREATE TABLE accounts` statement (new
DBs get it directly).
- In `store.Open`, after applying the schema, run a version-gated migration: if
the stored `schema_version` is `< 2`, execute
`ALTER TABLE accounts ADD COLUMN from_address TEXT` and set `schema_version`
to `2`.
- Bump the `schemaVersion` constant to `2`.
SQLite `ALTER TABLE … ADD COLUMN` is cheap and safe. The migration is
idempotent under the version gate.
### 5. Inputs & validation
- **CLI:** add a `--from` flag to `account add` and `account edit`. On `edit`,
follow the existing `fs.Visit` overlay pattern (only set when the flag was
passed).
- **TUI:** add a `FromAddress` field to `tui.Fields`, a `fieldDef`
(`{key: "from_address", label: "From address (optional)"}`), and wire it
through `ToAccount`, `FieldsFromAccount`, `fieldValue`, and `collect`.
- **Validation:** when the from-address is non-empty, reject it unless
`gomail.ParseAddress` accepts it (covers bare and display-name forms). A
blank from-address is always valid (→ fallback). Applied in
`Fields.Validate` and on the `account add`/`edit` flag path.
- **Persistence:** thread `from_address` through `AddAccount`, `UpdateAccount`,
`scanAccount`, and the three `SELECT` column lists in
`internal/store/account.go`. Stored via `nullStr` (blank → NULL).
### 6. Tests
- `SendFrom()`: returns the from-address when set; returns username when blank.
- `SendSMTP` envelope: when From is `Name <addr>`, the envelope sender passed to
the server is the bare `addr` (table test on the extraction helper).
- Migration: open a v1 DB with no `from_address` column → column is added,
`schema_version` becomes 2, and an existing account still sends from its
username.
- `Fields.Validate`: rejects a malformed from-address; accepts bare and
display-name forms; accepts blank.
- Round-trip: `FieldsFromAccount` then `ToAccount` preserves `FromAddress`.
## Out of scope (YAGNI)
- Separate envelope-from override field (derive it from From instead).
- Per-message From override at send time.
- Multiple aliases per account.
+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", "--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)
}
}
+75 -12
View File
@@ -6,20 +6,32 @@ import (
"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"
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
func runAccount(args []string, out, errOut io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(errOut, "usage: emcli account <add|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")
if len(args) > 0 {
return 0 // explicit --help
}
return 2
}
sub, rest := args[0], args[1:]
st, err := openStore()
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
// account list is an agent command (a JSON consumer), so its
// open/key failures are emitted as an envelope, like the other agent
// commands; the admin subcommands stay human-readable.
if sub == "list" {
_ = Failure(CodeConfig, err.Error()).Write(out)
} else {
fmt.Fprintf(errOut, "emcli: %v\n", err)
}
return 1
}
defer st.Close()
@@ -41,6 +53,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
user := fs.String("username", "", "login username")
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")
@@ -52,9 +65,14 @@ func runAccount(args []string, out, errOut io.Writer) int {
fmt.Fprintln(errOut, "name, imap-host, and username are required")
return 2
}
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc := store.Account{
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,
}
@@ -81,6 +99,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "", "tls|starttls")
user := fs.String("username", "", "login username")
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 {
return 2
@@ -92,6 +111,10 @@ func runAccount(args []string, out, errOut io.Writer) int {
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)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
@@ -118,6 +141,8 @@ func runAccount(args []string, out, errOut io.Writer) int {
acc.Username = *user
case "password":
acc.Password = *pass
case "from":
acc.FromAddress = *from
case "subject-regex":
acc.SubjectRegex = *subj
}
@@ -154,11 +179,31 @@ func runAccount(args []string, out, errOut io.Writer) int {
fmt.Fprintf(out, "account %q removed\n", *name)
return 0
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 {
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",
@@ -187,13 +232,20 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
}
// runConfig handles `config set <key> <value>` and `config get <key>`.
func runConfig(args []string, out, errOut io.Writer) int {
func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "config")
if len(args) > 0 {
return 0
}
return 2
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
return 2
}
sub, key := args[0], args[1]
st, err := openStore()
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -235,7 +287,11 @@ func runConfig(args []string, out, errOut io.Writer) int {
}
// runAudit handles `audit list [--account <name>] [--limit N]`.
func runAudit(args []string, out, errOut io.Writer) int {
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "audit")
return 0
}
if len(args) == 0 || args[0] != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
return 2
@@ -247,7 +303,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
if err := fs.Parse(args[1:]); err != nil {
return 2
}
st, err := openStore()
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -261,7 +317,14 @@ func runAudit(args []string, out, errOut io.Writer) int {
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
func runWhitelist(args []string, out, errOut io.Writer) int {
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "whitelist")
if len(args) > 0 {
return 0
}
return 2
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
return 2
@@ -279,7 +342,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
fmt.Fprintln(errOut, "--account is required")
return 2
}
st, err := openStore()
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
+39 -6
View File
@@ -7,15 +7,28 @@ import (
"testing"
"time"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
func adminEnv(t *testing.T) string {
t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_KEY", b64Key())
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, err := store.Open(db)
if err != nil {
t.Fatalf("Open: %v", err)
}
adminKey, _ := crypto.AdminKeyFromEnv()
agentKey, _ := crypto.AgentKeyFromEnv()
if err := st.InitKeys(adminKey, agentKey); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
return db
}
@@ -75,11 +88,16 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e)
}
st, err := store.Open(db, mustKey())
st, err := store.Open(db)
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
adminKey, _ := crypto.AdminKeyFromEnv()
agentKey, _ := crypto.AgentKeyFromEnv()
if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil {
t.Fatalf("Unlock: %v", err)
}
got, err := st.GetAccount("ed")
if err != nil {
t.Fatalf("GetAccount: %v", err)
@@ -93,11 +111,14 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
}
func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
@@ -111,5 +132,17 @@ func TestAuditListCoreRenders(t *testing.T) {
}
}
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
func mustKey() []byte { return make([]byte, 32) }
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")
if code != 2 {
t.Fatalf("expected exit code 2 for malformed --from, got %d (stderr: %q)", code, errStr)
}
if errStr == "" {
t.Fatal("expected an error message on stderr for malformed --from, got none")
}
}
+4 -1
View File
@@ -58,10 +58,13 @@ func testKey() []byte {
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() })
_, err = st.AddAccount(store.Account{
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
+4 -1
View File
@@ -11,10 +11,13 @@ import (
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() })
for _, a := range accounts {
if _, err := st.AddAccount(a); err != nil {
+83
View File
@@ -0,0 +1,83 @@
package cli
import (
"flag"
"fmt"
"io"
)
type cmdHelp struct {
name string
synopsis string
summary string
}
// agentCmds emit machine-readable JSON; adminCmds are human-readable.
var agentCmds = []cmdHelp{
{"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."},
{"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."},
{"search", "search --account <name> [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
{"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."},
{"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."},
}
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."},
{"version", "version", "Print the emcli version."},
{"help", "help [command]", "Show this help, or detailed usage for one command."},
}
func helpIndex() map[string]cmdHelp {
m := make(map[string]cmdHelp, len(agentCmds)+len(adminCmds))
for _, c := range append(append([]cmdHelp{}, agentCmds...), adminCmds...) {
m[c.name] = c
}
return m
}
// helpRequested reports whether an argument is a help flag/word.
func helpRequested(s string) bool {
return s == "help" || s == "-h" || s == "--help"
}
// printMainHelp writes the top-level command catalogue.
func printMainHelp(w io.Writer) {
fmt.Fprint(w, "emcli — guard-railed email gateway for agents\n\n")
fmt.Fprint(w, "Usage:\n emcli <command> [flags]\n\n")
fmt.Fprint(w, "Agent commands (machine-readable JSON on stdout):\n")
for _, c := range agentCmds {
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
}
fmt.Fprint(w, "\nAdmin commands (human-readable):\n")
for _, c := range adminCmds {
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, "\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")
}
// printCmdUsage writes "Usage: emcli <synopsis>" and the summary for one command.
func printCmdUsage(w io.Writer, name string) {
if h, ok := helpIndex()[name]; ok {
fmt.Fprintf(w, "Usage: emcli %s\n\n%s\n", h.synopsis, h.summary)
return
}
fmt.Fprintf(w, "Usage: emcli %s\n", name)
}
// usageFlags makes a flag set print the command's synopsis/summary followed by
// its flags whenever flag prints usage (on -h/--help or a flag error).
func usageFlags(fs *flag.FlagSet, name string, w io.Writer) {
fs.Usage = func() {
printCmdUsage(w, name)
fmt.Fprintln(w, "\nFlags:")
fs.PrintDefaults()
}
}
+73
View File
@@ -0,0 +1,73 @@
package cli
import (
"strings"
"testing"
)
func TestMainHelpListsAllCommands(t *testing.T) {
// help / --help / -h / no-args all print the command catalogue, exit 0,
// and require no EMCLI_KEY (help must work before any DB access).
for _, args := range [][]string{{"help"}, {"--help"}, {"-h"}, {}} {
code, out, errOut := run(t, args...)
text := out + errOut
if code != 0 {
t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text)
}
for _, want := range []string{
"Usage", "list", "get", "search", "ack", "send",
"account", "whitelist", "config", "audit", "doctor", "version",
"EMCLI_KEY", "EMCLI_DB",
} {
if !strings.Contains(text, want) {
t.Fatalf("%v: help missing %q\n%s", args, want, text)
}
}
}
}
func TestHelpForSpecificCommand(t *testing.T) {
code, out, errOut := run(t, "help", "send")
text := out + errOut
if code != 0 {
t.Fatalf("help send exit=%d", code)
}
if !strings.Contains(text, "Usage: emcli send") || !strings.Contains(text, "--to") {
t.Fatalf("help send missing synopsis:\n%s", text)
}
}
func TestAgentHelpDoesNotEmitJSON(t *testing.T) {
// `list --help` must NOT print a JSON envelope on stdout (an agent parses
// stdout) and must exit 0 — even with no EMCLI_KEY set.
code, out, errOut := run(t, "list", "--help")
if code != 0 {
t.Fatalf("list --help exit=%d (out=%q err=%q)", code, out, errOut)
}
if strings.TrimSpace(out) != "" {
t.Fatalf("agent help must keep stdout clean, got: %q", out)
}
if !strings.Contains(errOut, "Usage: emcli list") || !strings.Contains(errOut, "--account") {
t.Fatalf("list --help should print usage+flags on stderr:\n%s", errOut)
}
}
func TestSendHelpExitsZero(t *testing.T) {
code, _, errOut := run(t, "send", "--help")
if code != 0 || !strings.Contains(errOut, "--to") {
t.Fatalf("send --help: code=%d err=%q", code, errOut)
}
}
func TestAdminCommandHelpExitsZero(t *testing.T) {
for _, c := range []string{"account", "whitelist", "config", "audit", "doctor"} {
code, out, errOut := run(t, c, "--help")
text := out + errOut
if code != 0 {
t.Fatalf("%s --help exit=%d\n%s", c, code, text)
}
if !strings.Contains(text, "Usage: emcli "+c) {
t.Fatalf("%s --help missing usage line:\n%s", c, text)
}
}
}
+27 -3
View File
@@ -6,6 +6,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
@@ -70,15 +71,38 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
return 0
}
// runInit creates/opens the DB and adds the first account via the TUI form,
// seeding a default audit retention if unset.
// runInit creates/opens the DB, writes both DEK wrap slots, and adds the first
// account via the TUI form, seeding a default audit retention if unset.
func runInit(args []string, out, errOut io.Writer) int {
st, err := openStore()
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "init")
return 0
}
adminKey, err := crypto.AdminKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
agentKey, err := crypto.AgentKeyFromEnv()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
path, err := store.DefaultDBPath()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
st, err := store.Open(path)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if err := st.InitKeys(adminKey, agentKey); err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90")
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func TestCommandRole(t *testing.T) {
adminCmds := [][]string{
{"whitelist"}, {"config"}, {"audit"},
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
{"account", "list"},
}
for _, c := range adminCmds {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%v should be admin", c)
}
}
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%v should be agent", c)
}
}
}
func TestAgentCommandWorksWithOnlyAdminKey(t *testing.T) {
// A human holding only the admin key can still run agent commands
// (admin is a superset → agent-role unlock falls back to the admin slot).
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, _ := store.Open(db)
ak, _ := crypto.AdminKeyFromEnv()
gk, _ := crypto.AgentKeyFromEnv()
st.InitKeys(ak, gk)
st.Close()
// Only the admin key now; agent command must still open the store.
t.Setenv("EMCLI_KEY", "")
s2, err := openStore(store.RoleAgent)
if err != nil {
t.Fatalf("agent role with only admin key should open: %v", err)
}
s2.Close()
}
+79 -22
View File
@@ -1,6 +1,7 @@
package cli
import (
"errors"
"flag"
"fmt"
"io"
@@ -24,17 +25,55 @@ func realMailer(acc store.Account) (Mailer, error) {
return c, nil
}
// openStore loads the key and opens the DB, returning a human-readable error string.
func openStore() (*store.Store, error) {
key, err := crypto.KeyFromEnv()
if err != nil {
return nil, err
// commandRole maps a command to the privilege it requires. Admin commands
// mutate configuration or expose oversight data; everything else is agent.
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
}
}
// openStore resolves the keys for the role, opens the DB, and unlocks the DEK.
// Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent
// commands use EMCLI_KEY (falling back to the admin key if that is all there is).
func openStore(role store.Role) (*store.Store, error) {
adminKey, adminErr := crypto.AdminKeyFromEnv()
agentKey, agentErr := crypto.AgentKeyFromEnv()
switch role {
case store.RoleAdmin:
if adminErr != nil {
return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)")
}
case store.RoleAgent:
if agentErr != nil && adminErr != nil {
return nil, agentErr // "EMCLI_KEY is not set"
}
}
path, err := store.DefaultDBPath()
if err != nil {
return nil, err
}
return store.Open(path, key)
st, err := store.Open(path)
if err != nil {
return nil, err
}
if err := st.Unlock(role, adminKey, agentKey); err != nil {
st.Close()
return nil, err
}
return st, nil
}
func realSender(acc store.Account, m mail.OutgoingMessage) error {
@@ -67,14 +106,18 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
}
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
func runDoctor(args []string, out, errOut io.Writer) int {
func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
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 {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
st, err := openStore()
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
@@ -89,26 +132,32 @@ func runDoctor(args []string, out, errOut io.Writer) int {
// Run routes a command line and returns an exit code.
func Run(args []string, out, errOut io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(errOut, "emcli: no command given")
return 2
if len(args) == 0 || helpRequested(args[0]) {
// `emcli`, `emcli help`, `emcli -h`, `emcli --help`, and `emcli help <cmd>`.
if len(args) >= 2 {
printCmdUsage(out, args[1])
} else {
printMainHelp(out)
}
return 0
}
cmd, rest := args[0], args[1:]
role := commandRole(args)
switch cmd {
case "list", "get", "search", "ack":
return runAgent(cmd, rest, out, errOut)
return runAgent(cmd, rest, role, out, errOut)
case "send":
return runSend(rest, out, errOut)
return runSend(rest, role, out, errOut)
case "account":
return runAccount(rest, out, errOut)
return runAccount(rest, role, out, errOut)
case "whitelist":
return runWhitelist(rest, out, errOut)
return runWhitelist(rest, role, out, errOut)
case "config":
return runConfig(rest, out, errOut)
return runConfig(rest, role, out, errOut)
case "audit":
return runAudit(rest, out, errOut)
return runAudit(rest, role, out, errOut)
case "doctor":
return runDoctor(rest, out, errOut)
return runDoctor(rest, role, out, errOut)
case "init":
return runInit(rest, out, errOut)
default:
@@ -118,9 +167,10 @@ func Run(args []string, out, errOut io.Writer) int {
}
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, cmd, errOut)
account := fs.String("account", "", "account name")
folder := fs.String("folder", "INBOX", "folder/mailbox")
onlyNew := fs.Bool("new", false, "only new (unacked) messages")
@@ -135,6 +185,9 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0 // usage already printed to stderr; help isn't an error
}
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
@@ -145,7 +198,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore()
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
@@ -210,9 +263,10 @@ func (s *stringSlice) Set(v string) error {
}
// runSend handles the `send` agent command (JSON envelope output).
func runSend(args []string, out, errOut io.Writer) int {
func runSend(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("send", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "send", errOut)
account := fs.String("account", "", "account name")
var to, cc, bcc, attach stringSlice
fs.Var(&to, "to", "recipient (repeatable / comma-separated)")
@@ -224,6 +278,9 @@ func runSend(args []string, out, errOut io.Writer) int {
replyTo := fs.Uint("reply-to", 0, "source UID to reply to (threading)")
folder := fs.String("folder", "INBOX", "folder of the reply source")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
@@ -231,7 +288,7 @@ func runSend(args []string, out, errOut io.Writer) int {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore()
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
+18 -5
View File
@@ -18,17 +18,25 @@ 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", "")
code := Run([]string{"account", "list"}, &out, &errOut)
if code == 0 {
t.Fatal("missing EMCLI_KEY must fail")
}
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") {
t.Fatalf("should mention EMCLI_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())
}
}
@@ -54,3 +62,8 @@ func b64Key() string {
// 32 zero bytes, base64.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}
func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
+66
View File
@@ -0,0 +1,66 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func dbBytes(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read db: %v", err)
}
return b
}
// A forced agent holding ONLY EMCLI_KEY must not be able to run any admin
// command, and the DB must be unchanged after it tries.
func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
t.Setenv("EMCLI_KEY", b64AgentKey())
t.Setenv("EMCLI_DB", db)
st, err := store.Open(db)
if err != nil {
t.Fatalf("store.Open: %v", err)
}
ak, err := crypto.AdminKeyFromEnv()
if err != nil {
t.Fatalf("AdminKeyFromEnv: %v", err)
}
gk, err := crypto.AgentKeyFromEnv()
if err != nil {
t.Fatalf("AgentKeyFromEnv: %v", err)
}
if err := st.InitKeys(ak, gk); err != nil {
t.Fatalf("InitKeys: %v", err)
}
st.Close()
// Simulate the agent's environment: admin key absent.
t.Setenv("EMCLI_ADMIN_KEY", "")
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
for _, args := range adminAttempts {
code, out, errOut := run(t, args...)
if code == 0 {
t.Errorf("admin command %v must be refused with only EMCLI_KEY (out=%q err=%q)", args, out, errOut)
continue
}
}
if !bytes.Equal(before, dbBytes(t, db)) {
t.Fatal("DB changed despite all admin commands being refused")
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string,
}
msg := mail.OutgoingMessage{
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
recipients := msg.Recipients()
+30 -1
View File
@@ -13,10 +13,13 @@ import (
// mailer (for reply-to). The named account is created per the supplied template.
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
if err := st.InitKeys(testKey(), testKey()); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { st.Close() })
if _, err := st.AddAccount(acc); err != nil {
t.Fatalf("AddAccount: %v", err)
@@ -136,6 +139,32 @@ func TestSendReplyToThreadsHeaders(t *testing.T) {
}
}
func TestSendUsesConfiguredFromAddress(t *testing.T) {
acc := rwAccount()
acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
d, sent, _ := sendDeps(t, acc, nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
if got := (*sent)[0].From; got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("From = %q, want configured from-address", got)
}
}
func TestSendFallsBackToUsernameAsFrom(t *testing.T) {
// rwAccount has no FromAddress, so From must be the login username.
d, sent, _ := sendDeps(t, rwAccount(), nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if got := (*sent)[0].From; got != "emcli@stevecliff.com" {
t.Fatalf("From = %q, want username fallback", got)
}
}
func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
acc := rwAccount()
acc.WhitelistInEnabled = true // inbound filter active
+24 -11
View File
@@ -1,4 +1,4 @@
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY.
// Package crypto provides AES-256-GCM field encryption; keys are loaded from EMCLI_KEY (agent) or EMCLI_ADMIN_KEY (admin).
package crypto
import (
@@ -7,28 +7,41 @@ import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
)
var (
ErrNoKey = errors.New("EMCLI_KEY is not set")
ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes")
)
// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY.
func KeyFromEnv() ([]byte, error) {
raw := os.Getenv("EMCLI_KEY")
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
// named environment variable. Errors name the variable so callers get a
// role-appropriate message.
func keyFromEnv(varName string) ([]byte, error) {
raw := os.Getenv(varName)
if raw == "" {
return nil, ErrNoKey
return nil, fmt.Errorf("%s is not set", varName)
}
key, err := base64.StdEncoding.DecodeString(raw)
if err != nil || len(key) != 32 {
return nil, ErrBadKey
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
}
return key, nil
}
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
// NewDEK returns a fresh random 32-byte data-encryption key.
func NewDEK() ([]byte, error) {
dek := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
return nil, err
}
return dek, nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
+29 -10
View File
@@ -3,6 +3,7 @@ package crypto
import (
"bytes"
"encoding/base64"
"strings"
"testing"
)
@@ -50,20 +51,38 @@ func TestOpenWrongKeyFails(t *testing.T) {
}
}
func TestKeyFromEnv(t *testing.T) {
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey()))
k, err := KeyFromEnv()
if err != nil || len(k) != 32 {
t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err)
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
good := base64.StdEncoding.EncodeToString(testKey())
t.Setenv("EMCLI_KEY", good)
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_ADMIN_KEY", good)
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
}
t.Setenv("EMCLI_KEY", "")
if _, err := KeyFromEnv(); err != ErrNoKey {
t.Fatalf("empty key: want ErrNoKey, got %v", err)
t.Setenv("EMCLI_ADMIN_KEY", "")
if _, err := AdminKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
}
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
if _, err := KeyFromEnv(); err != ErrBadKey {
t.Fatalf("short key: want ErrBadKey, got %v", err)
if _, err := AgentKeyFromEnv(); err == nil ||
!strings.Contains(err.Error(), "32 bytes") {
t.Fatalf("short key: want length error, got %v", err)
}
}
func TestNewDEKIsRandom32(t *testing.T) {
a, err := NewDEK()
if err != nil || len(a) != 32 {
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
}
b, _ := NewDEK()
if bytes.Equal(a, b) {
t.Fatal("two DEKs must differ")
}
}
+13 -1
View File
@@ -46,6 +46,18 @@ func (m OutgoingMessage) Recipients() []string {
return out
}
// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
// any display name. A display-name From (e.g. "Name <addr>") is a valid header
// but an invalid envelope sender, so it must be reduced to the bare address.
// Unparseable input is passed through unchanged (preserves prior behaviour for
// plain addresses).
func envelopeFrom(from string) string {
if a, err := gomail.ParseAddress(from); err == nil {
return a.Address
}
return from
}
func addrList(addrs []string) []*gomail.Address {
out := make([]*gomail.Address, 0, len(addrs))
for _, a := range addrs {
@@ -163,7 +175,7 @@ func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return c.Quit()
+30
View File
@@ -100,6 +100,36 @@ func TestRecipientsCombinesAllFields(t *testing.T) {
}
}
func TestEnvelopeFromStripsDisplayName(t *testing.T) {
cases := map[string]string{
"Steve Cliff <me@stevecliff.com>": "me@stevecliff.com",
"me@stevecliff.com": "me@stevecliff.com",
"<me@stevecliff.com>": "me@stevecliff.com",
"not a valid address": "not a valid address", // unparseable ⇒ passthrough
}
for in, want := range cases {
if got := envelopeFrom(in); got != want {
t.Errorf("envelopeFrom(%q) = %q, want %q", in, got, want)
}
}
}
func TestBuildMIMEKeepsDisplayNameInHeader(t *testing.T) {
raw, err := BuildMIME(OutgoingMessage{
From: "Steve Cliff <me@stevecliff.com>",
To: []string{"you@example.com"},
Subject: "hi",
BodyText: "body",
Date: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("BuildMIME: %v", err)
}
if !strings.Contains(string(raw), "Steve Cliff") {
t.Fatalf("From header lost display name:\n%s", raw)
}
}
func TestReadHeaderParsesReferences(t *testing.T) {
raw := "From: a@x.com\r\n" +
"To: b@x.com\r\n" +
+25 -14
View File
@@ -23,6 +23,7 @@ type Account struct {
SMTPSecurity string // tls | starttls
AuthType string // password | oauth2
Username string
FromAddress string // send-as identity; blank ⇒ fall back to Username
Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool
WhitelistOutEnabled bool
@@ -30,6 +31,15 @@ type Account struct {
ProcessBacklog bool
}
// SendFrom returns the From identity for outgoing mail, falling back to the
// login username when no explicit from-address is configured.
func (a Account) SendFrom() string {
if a.FromAddress != "" {
return a.FromAddress
}
return a.Username
}
func (s *Store) AddAccount(a Account) (int64, error) {
var encPw []byte
if a.Password != "" {
@@ -42,12 +52,12 @@ func (s *Store) AddAccount(a Account) (int64, error) {
res, err := s.db.Exec(`
INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username,
a.AuthType, a.Username, nullStr(a.FromAddress),
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
if err != nil {
@@ -59,7 +69,7 @@ func (s *Store) AddAccount(a Account) (int64, error) {
func (s *Store) GetAccount(name string) (Account, error) {
row := s.db.QueryRow(`
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts WHERE name = ?`, name)
a, encPw, err := scanAccount(row)
@@ -82,7 +92,7 @@ func (s *Store) GetAccount(name string) (Account, error) {
func (s *Store) ListAccounts() ([]Account, error) {
rows, err := s.db.Query(`
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
auth_type,username,
auth_type,username,from_address,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts ORDER BY name`)
if err != nil {
@@ -108,12 +118,12 @@ func (s *Store) UpdateAccount(a Account) error {
// Build the SET clause, conditionally including secret columns.
set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
smtp_host=?, smtp_port=?, smtp_security=?,
auth_type=?, username=?,
auth_type=?, username=?, from_address=?,
whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
args := []any{
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
a.AuthType, a.Username,
a.AuthType, a.Username, nullStr(a.FromAddress),
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
}
@@ -152,16 +162,16 @@ type scanner interface{ Scan(dest ...any) error }
func scanAccount(sc scanner) (Account, []byte, error) {
var (
a Account
encPw []byte
subj, smtpHost, smtpSec sql.NullString
smtpPort sql.NullInt64
wlIn, wlOut int
backlog int
a Account
encPw []byte
subj, smtpHost, smtpSec, fromAddr sql.NullString
smtpPort sql.NullInt64
wlIn, wlOut int
backlog int
)
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&smtpHost, &smtpPort, &smtpSec,
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog)
&a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog)
if err != nil {
return Account{}, nil, err
}
@@ -172,6 +182,7 @@ func scanAccount(sc scanner) (Account, []byte, error) {
a.WhitelistOutEnabled = wlOut != 0
a.ProcessBacklog = backlog != 0
a.SubjectRegex = subj.String
a.FromAddress = fromAddr.String
return a, encPw, nil
}
+27
View File
@@ -84,3 +84,30 @@ func TestListAccountsOmitsSecrets(t *testing.T) {
t.Fatal("ListAccounts must not return secrets")
}
}
func TestSendFromFallsBackToUsername(t *testing.T) {
a := Account{Username: "login@example.com"}
if got := a.SendFrom(); got != "login@example.com" {
t.Fatalf("blank from-address should fall back to username, got %q", got)
}
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if got := a.SendFrom(); got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("set from-address should win, got %q", got)
}
}
func TestAddGetAccountRoundTripsFromAddress(t *testing.T) {
s := openTemp(t)
a := sampleAccount()
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
if _, err := s.AddAccount(a); err != nil {
t.Fatalf("AddAccount: %v", err)
}
got, err := s.GetAccount("work")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FromAddress not round-tripped: %q", got.FromAddress)
}
}
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
// Role selects which DEK wrap slot a command may unlock.
type Role int
const (
RoleAgent Role = iota // agent commands; uses dek_wrap_agent (admin slot as fallback)
RoleAdmin // all commands; uses dek_wrap_admin ONLY
)
const (
settingDEKWrapAdmin = "dek_wrap_admin"
settingDEKWrapAgent = "dek_wrap_agent"
)
// ErrLocked means the DB has no DEK wrap slots yet (never initialized).
var ErrLocked = errors.New("emcli DB is not initialized; run `emcli init`")
// dbPath returns the file path SQLite opened (used by tests to re-open).
func (s *Store) dbPath() string {
var p string
_ = s.db.QueryRow("PRAGMA database_list").Scan(new(int), new(string), &p)
return p
}
// InitKeys generates a DEK (only if absent), seals it under both KEKs, writes
// both wrap slots, and unlocks the store. If the slots already exist it does
// NOT regenerate the DEK — it unlocks via the admin slot (idempotent re-init).
func (s *Store) InitKeys(adminKey, agentKey []byte) error {
if _, err := s.GetSetting(settingDEKWrapAdmin); err == nil {
// Already initialised: the DEK and both wrap slots already exist, so the
// agent key is not consumed here. Only the admin key is used to unlock the
// existing dek_wrap_admin slot; the DEK itself is preserved unchanged.
return s.Unlock(RoleAdmin, adminKey, nil)
}
dek, err := crypto.NewDEK()
if err != nil {
return err
}
wrapAdmin, err := crypto.Seal(adminKey, dek)
if err != nil {
return err
}
wrapAgent, err := crypto.Seal(agentKey, dek)
if err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAdmin, base64.StdEncoding.EncodeToString(wrapAdmin)); err != nil {
return err
}
if err := s.SetSetting(settingDEKWrapAgent, base64.StdEncoding.EncodeToString(wrapAgent)); err != nil {
return err
}
s.key = dek
return nil
}
// Unlock loads the DEK into the store by decrypting the wrap slot for role.
// RoleAdmin uses the admin slot ONLY. RoleAgent prefers the agent slot and
// falls back to the admin slot only when no agent key is supplied.
func (s *Store) Unlock(role Role, adminKey, agentKey []byte) error {
switch role {
case RoleAdmin:
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
case RoleAgent:
if len(agentKey) > 0 {
return s.unlockSlot(settingDEKWrapAgent, agentKey)
}
return s.unlockSlot(settingDEKWrapAdmin, adminKey)
default:
return fmt.Errorf("unknown role %d", role)
}
}
func (s *Store) unlockSlot(settingKey string, kek []byte) error {
if len(kek) == 0 {
return ErrLocked
}
enc, err := s.GetSetting(settingKey)
if errors.Is(err, sql.ErrNoRows) {
return ErrLocked
}
if err != nil {
return err
}
blob, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return fmt.Errorf("corrupt wrap slot %q: %w", settingKey, err)
}
dek, err := crypto.Open(kek, blob)
if err != nil {
return errors.New("wrong key for this DB")
}
s.key = dek
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package store
import (
"path/filepath"
"testing"
)
func k(b byte) []byte {
key := make([]byte, 32)
for i := range key {
key[i] = b
}
return key
}
func tempStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "emcli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestInitKeysThenUnlockBothSlotsRecoverSameDEK(t *testing.T) {
admin, agent := k(0xAA), k(0xBB)
st := tempStore(t)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("InitKeys: %v", err)
}
// Seal a password under the DEK that InitKeys set.
if _, err := st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
// Re-open and unlock via the AGENT slot.
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAgent, nil, agent); err != nil {
t.Fatalf("Unlock(agent): %v", err)
}
got, err := st2.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("agent-slot decrypt: pw=%q err=%v", got.Password, err)
}
st2.Close()
// Unlock via the ADMIN slot recovers the same DEK.
st3, _ := Open(path)
if err := st3.Unlock(RoleAdmin, admin, nil); err != nil {
t.Fatalf("Unlock(admin): %v", err)
}
got3, err := st3.GetAccount("a")
if err != nil || got3.Password != "pw" {
t.Fatalf("admin-slot decrypt: pw=%q err=%v", got3.Password, err)
}
st3.Close()
}
func TestUnlockWrongKeyFails(t *testing.T) {
st := tempStore(t)
if err := st.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatal(err)
}
path := st.dbPath()
st.Close()
st2, _ := Open(path)
if err := st2.Unlock(RoleAdmin, k(0x11), nil); err == nil {
t.Fatal("Unlock with wrong admin key must fail")
}
st2.Close()
}
func TestAdminSlotNotOpenableByAgentKey(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
// RoleAdmin must use the admin slot; passing the agent key as the admin
// key must fail — there is no fallback to the agent slot.
if err := st.Unlock(RoleAdmin, agent, agent); err == nil {
t.Fatal("agent key must not unlock the admin slot")
}
}
func TestInitKeysIdempotentKeepsDEK(t *testing.T) {
st := tempStore(t)
admin, agent := k(0xAA), k(0xBB)
if err := st.InitKeys(admin, agent); err != nil {
t.Fatal(err)
}
st.AddAccount(Account{
Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993,
IMAPSecurity: "tls", AuthType: "password", Username: "u", Password: "pw",
})
// Second InitKeys must NOT regenerate the DEK (would orphan the password).
if err := st.InitKeys(admin, agent); err != nil {
t.Fatalf("re-InitKeys: %v", err)
}
got, err := st.GetAccount("a")
if err != nil || got.Password != "pw" {
t.Fatalf("password lost after re-init: pw=%q err=%v", got.Password, err)
}
}
+3 -2
View File
@@ -1,8 +1,8 @@
package store
const schemaVersion = 1
const schemaVersion = 2
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS.
// schemaSQL is the full current schema. All statements are idempotent via IF NOT EXISTS.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS accounts (
smtp_security TEXT,
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
username TEXT NOT NULL,
from_address TEXT,
enc_password BLOB,
enc_oauth_client_id BLOB,
enc_oauth_client_secret BLOB,
+33 -7
View File
@@ -19,7 +19,9 @@ type Store struct {
}
// Open opens (creating if needed) the DB at path and applies the schema.
func Open(path string, key []byte) (*Store, error) {
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
// secret read/write.
func Open(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
@@ -39,16 +41,40 @@ func Open(path string, key []byte) (*Store, error) {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
s := &Store{db: db, key: key}
if _, err := s.GetSetting("schema_version"); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
db.Close()
return nil, err
}
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// migrate brings an existing database up to the current schemaVersion. A brand-
// new database (no schema_version yet) already has every column from schemaSQL,
// so it is simply stamped at the current version. Each older version runs its
// forward step. The version gate makes every step idempotent across reopens.
func (s *Store) migrate() error {
v, err := s.GetSetting("schema_version")
if err != nil {
// Fresh database: schemaSQL created all columns already.
return s.SetSetting("schema_version", strconv.Itoa(schemaVersion))
}
var ver int
ver, err = strconv.Atoi(v)
if err != nil {
return fmt.Errorf("invalid schema_version %q: %w", v, err)
}
if ver < 2 {
if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil {
return fmt.Errorf("migrate to v2: %w", err)
}
if err := s.SetSetting("schema_version", "2"); err != nil {
return err
}
}
return nil
}
func (s *Store) Close() error { return s.db.Close() }
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
+74 -14
View File
@@ -1,49 +1,46 @@
package store
import (
"database/sql"
"path/filepath"
"testing"
)
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
// openTemp opens a fresh store in a temp dir.
// 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 {
t.Helper()
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
s, err := Open(p)
if err != nil {
t.Fatalf("Open: %v", err)
}
if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil {
t.Fatalf("InitKeys: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
s, err := Open(p)
if err != nil {
t.Fatalf("first Open: %v", err)
}
v, err := s.GetSetting("schema_version")
if err != nil || v != "1" {
if err != nil || v != "2" {
t.Fatalf("schema_version: %q err=%v", v, err)
}
s.Close()
// Re-open: must not error or duplicate.
s2, err := Open(p, testKey())
s2, err := Open(p)
if err != nil {
t.Fatalf("second Open: %v", err)
}
defer s2.Close()
if v, _ := s2.GetSetting("schema_version"); v != "1" {
if v, _ := s2.GetSetting("schema_version"); v != "2" {
t.Fatalf("schema_version after reopen: %q", v)
}
}
@@ -108,3 +105,66 @@ func TestForeignKeyCascade(t *testing.T) {
t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err)
}
}
func TestOpenMigratesV1AddsFromAddress(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
// Hand-build a v1 database: accounts table WITHOUT from_address, a settings
// table pinned at schema_version=1, and one pre-existing account row.
raw, err := sql.Open("sqlite", p)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
const v1Schema = `
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
mode TEXT NOT NULL,
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_security TEXT NOT NULL,
smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
enc_password BLOB,
enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB,
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
subject_regex TEXT,
process_backlog INTEGER NOT NULL DEFAULT 0
);
INSERT INTO settings(key,value) VALUES ('schema_version','1');
INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username)
VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com');
`
if _, err := raw.Exec(v1Schema); err != nil {
t.Fatalf("seed v1 schema: %v", err)
}
raw.Close()
// Open via the store: the migration must add from_address and bump to v2.
s, err := Open(p)
if err != nil {
t.Fatalf("Open (migrate): %v", err)
}
defer s.Close()
if v, _ := s.GetSetting("schema_version"); v != "2" {
t.Fatalf("schema_version after migrate: %q, want 2", v)
}
// ListAccounts SELECTs from_address; it would error if the column were missing.
accs, err := s.ListAccounts()
if err != nil {
t.Fatalf("ListAccounts after migrate: %v", err)
}
if len(accs) != 1 {
t.Fatalf("want 1 account after migrate, got %d", len(accs))
}
if accs[0].FromAddress != "" {
t.Fatalf("legacy account FromAddress should be empty, got %q", accs[0].FromAddress)
}
if got := accs[0].SendFrom(); got != "login@example.com" {
t.Fatalf("legacy account should send from username, got %q", got)
}
}
+29 -1
View File
@@ -6,6 +6,7 @@ package tui
import (
"errors"
"fmt"
"net/mail"
"strconv"
"strings"
@@ -22,10 +23,24 @@ type Fields struct {
IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool
SubjectRegex string
}
// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
// address (bare or "Display Name <addr>"). A blank value is valid: sending
// falls back to the login username.
func ValidFromAddress(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
if _, err := mail.ParseAddress(s); err != nil {
return errors.New("from address must be a valid email address or \"Name <email>\"")
}
return nil
}
func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
// Validate checks required fields, enum fields, and numeric ports. RW accounts
@@ -60,6 +75,9 @@ func (f Fields) Validate() error {
return errors.New("smtp port must be a number")
}
}
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
return nil
}
@@ -71,6 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
Name: strings.TrimSpace(f.Name), Mode: f.Mode,
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,
}
@@ -95,7 +114,8 @@ func FieldsFromAccount(a store.Account) Fields {
Name: a.Name, Mode: a.Mode,
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username,
Username: a.Username,
FromAddress: a.FromAddress,
WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog,
@@ -122,6 +142,7 @@ var fieldDefs = []fieldDef{
{key: "smtp_port", label: "SMTP port (RW)"},
{key: "smtp_security", label: "SMTP security (tls/starttls)"},
{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},
@@ -164,6 +185,8 @@ func fieldValue(f Fields, key string) string {
return f.SMTPSecurity
case "username":
return f.Username
case "from_address":
return f.FromAddress
case "password":
return f.Password
case "whitelist_in":
@@ -203,6 +226,9 @@ func NewAccountForm(initial Fields, editing bool) AccountForm {
if initial.IMAPSecurity == "" {
initial.IMAPSecurity = "tls"
}
if initial.SMTPPort == "" {
initial.SMTPPort = "465"
}
if initial.SMTPSecurity == "" {
initial.SMTPSecurity = "tls"
}
@@ -246,6 +272,8 @@ func (m AccountForm) collect() Fields {
f.SMTPSecurity = strings.ToLower(v)
case "username":
f.Username = v
case "from_address":
f.FromAddress = v
case "password":
f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in":
+44
View File
@@ -111,6 +111,17 @@ func TestFieldsFromAccountRoundTrip(t *testing.T) {
}
}
func TestNewAccountFormDefaultsSMTPPort(t *testing.T) {
f := NewAccountForm(Fields{}, false).collect()
if f.SMTPPort != "465" {
t.Fatalf("SMTP port should default to 465, got %q", f.SMTPPort)
}
// The other prefilled defaults must remain intact.
if f.IMAPPort != "993" || f.Mode != "RO" || f.IMAPSecurity != "tls" || f.SMTPSecurity != "tls" {
t.Fatalf("existing defaults regressed: %+v", f)
}
}
func TestAccountFormSubmitValid(t *testing.T) {
m := NewAccountForm(validFields(), false)
// Enter submits; with valid fields the form completes.
@@ -146,3 +157,36 @@ func TestAccountFormCancel(t *testing.T) {
t.Fatal("esc should cancel the form")
}
}
func TestValidateRejectsBadFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "not an address"
if err := f.Validate(); err == nil {
t.Fatal("malformed from-address should fail validation")
}
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
if err := f.Validate(); err != nil {
t.Fatalf("display-name from-address should validate: %v", err)
}
f.FromAddress = "me@stevecliff.com"
if err := f.Validate(); err != nil {
t.Fatalf("bare from-address should validate: %v", err)
}
f.FromAddress = "" // blank ⇒ fall back, always valid
if err := f.Validate(); err != nil {
t.Fatalf("blank from-address should validate: %v", err)
}
}
func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
acc, _ := f.ToAccount()
if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
}
back := FieldsFromAccount(acc)
if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
}
}
+114
View File
@@ -0,0 +1,114 @@
# emcli — setup manual (for agents)
Read this **once**, when emcli isn't set up yet (the binary isn't installed, or you only have
`SKILL.md` from being pointed at the repo). The recurring "how to read and send mail" lives in
`SKILL.md` — don't reload this file for normal use.
## 1. Get this skill's files
This skill is a folder, not a single file: it ships `AGENTIC-MANUAL.md` (this file), a `scripts/`
directory, and a `references/` directory. If you were pointed at the repository and have only
`SKILL.md` in context, fetch what you need before proceeding.
Every `scripts/…` and `references/…` path mentioned anywhere in this skill is **relative to the
skill directory**, whose raw base URL is:
```
https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
```
So a file that isn't already on disk can be fetched from `<base>/<path>`. The files are:
| Path | Purpose |
|---|---|
| `SKILL.md` | The lean, everyday usage guide |
| `AGENTIC-MANUAL.md` | This setup guide (one-time) |
| `scripts/install.sh` | Installs the emcli binary |
| `references/commands.md` | Full agent command reference (flags, JSON shapes, error codes) |
To enumerate the directory programmatically (then recurse into `scripts` and `references`), use the
Gitea contents API: `https://gitea.dcglab.co.uk/api/v1/repos/steve/emcli/contents/skills/emcli`.
## 2. Install the binary
First check whether it's already installed: `emcli version`. If that prints a version, skip to
step 3.
If you have the skill files locally:
```bash
bash scripts/install.sh
```
If you only have this manual (pointed at the repo), fetch the installer first, then run it:
```bash
curl -fsSL https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/scripts/install.sh -o emcli-install.sh
bash emcli-install.sh
```
`install.sh` detects your OS/arch, downloads the matching release binary, verifies its SHA-256
checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), and confirms it runs.
**Installer options** (environment variables):
| Variable | Default | Purpose |
|---|---|---|
| `EMCLI_VERSION` | `v0.5.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 |
**Build from source instead** (needs Go; the binary is CGO-free):
```bash
git clone https://gitea.dcglab.co.uk/steve/emcli && cd emcli
CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
```
## 3. Confirm the agent key is present
emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises
`list`, `get`, `search`, `ack`, `send`, and `doctor`. Admin commands require `EMCLI_ADMIN_KEY`,
which the human holds — attempting admin commands with only `EMCLI_KEY` is refused by `emcli`.
For agent use, **the orchestrator that launched you provides `EMCLI_KEY`** in the environment.
- Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`.
- **Never** read, print, log, pass as an argument, or generate this value.
- If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by
your orchestrator; I can't read or create it for you."
(For a human setting emcli up the first time: generate both keys with
`head -c 32 /dev/urandom | base64` (once per key) and store them securely; then run `emcli init`
with both keys exported. Account creation and other admin is the human's job — see the project's
`USER-MANUAL.md`.)
## 4. Find the account(s)
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`.
## You're set up
Installed, key present, account name in hand → switch to `SKILL.md` for the everyday `list` / `get`
/ `search` / `ack` / `send` workflow. You shouldn't need this manual again unless the binary goes
missing.
+27 -22
View File
@@ -17,35 +17,36 @@ sets its exit code to match.
## Security model — read this first
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`. Account setup,
passwords, whitelists, and config are the **user's** job (admin commands) — do not run or suggest
running `account`, `whitelist`, `config`, `init`, or `doctor` unless the user explicitly asks you
to help administer.
- **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 Setup).
missing, stop and tell the user (see "Files & first run").
- **Some mail is intentionally invisible.** The user may restrict which senders you can see and who
you can email. Blocked or filtered results are normal — handle them, don't try to work around
them (see Enforcement).
## Setup (do this once per session, before the first command)
## Files & first run
1. **Check the binary is available.** Run `emcli version`. If the command is not found, install it:
This skill ships more than this file. Paths like `AGENTIC-MANUAL.md` and `references/commands.md`
are relative to this skill's directory; if one isn't on disk, fetch it from the raw base URL + path:
```bash
bash scripts/install.sh
```
```
https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
```
This downloads the binary from the project's releases and puts it on your PATH
(`~/.local/bin` by default). See [references/install.md](references/install.md) for options.
- **First-time setup** — installing the binary, the `EMCLI_KEY`, finding accounts: read
**`AGENTIC-MANUAL.md`**. Only needed when emcli isn't set up yet.
- **Full command detail** — every flag, JSON shapes, error codes: `references/commands.md`.
2. **Check the key is present.** Confirm the `EMCLI_KEY` environment variable is set (e.g.
`test -n "$EMCLI_KEY"`). **Do not print its value.** If it is empty, do not proceed — tell the
user: "emcli needs the EMCLI_KEY environment variable set by your orchestrator; I can't read or
create it for you."
3. **Find out which account(s) exist.** Ask the user for the account name (e.g. `gmail`, `work`),
or, if permitted, run `emcli doctor` once to see configured accounts and that they connect.
**Per-session preflight** (quick): run `emcli version`; if it's not found, set up via
`AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if
empty, tell the user their orchestrator must provide `EMCLI_KEY` (the agent key). Then get the
account name from the user.
## How to read every result
@@ -119,12 +120,14 @@ 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.
**Full reference** (every flag, exact JSON shapes for each command, attachment encoding, error
codes, and the enforcement rules): [references/commands.md](references/commands.md).
codes, and the enforcement rules): `references/commands.md` — read it from disk, or fetch it from
the raw base URL in "Files & first run" above if you don't have it locally.
## Enforcement awareness — work *with* the rules
@@ -140,7 +143,9 @@ The user configures these; you cannot change them and shouldn't try.
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
- ✅ `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`/`init`) unless asked to help set up.
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
-62
View File
@@ -1,62 +0,0 @@
# Installing the emcli binary
The skill's `scripts/install.sh` downloads a prebuilt binary from the project's release assets.
## Quick install
```bash
bash scripts/install.sh
```
It detects your OS (`linux`/`darwin`/`windows`) and architecture (`amd64`/`arm64`), downloads the
matching asset, verifies its SHA-256 checksum when a `checksums.txt` is published, makes it
executable, and confirms it runs.
## Options (environment variables)
| Variable | Default | Purpose |
|---|---|---|
| `EMCLI_VERSION` | `v0.4.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 |
Example — install a specific version to a system directory:
```bash
EMCLI_VERSION=v0.4.0 EMCLI_INSTALL_DIR=/usr/local/bin bash scripts/install.sh
```
## Release asset naming
The release publishes one binary per platform plus a checksum file:
```
emcli_0.4.0_linux_amd64
emcli_0.4.0_linux_arm64
emcli_0.4.0_darwin_amd64
emcli_0.4.0_darwin_arm64
emcli_0.4.0_windows_amd64.exe
checksums.txt # sha256, one "<sum> <asset>" line per asset
```
> `v0.4.0` and these assets are placeholders until the first tagged release exists. Update
> `EMCLI_VERSION` (or the default in `install.sh`) once a real release is cut.
## Building from source instead
If you have Go and prefer to build rather than download:
```bash
git clone https://gitea.dcglab.co.uk/steve/emcli
cd emcli
CGO_ENABLED=0 go build -o emcli ./cmd/emcli
# then move ./emcli onto your PATH
```
## After installing
`emcli` needs the `EMCLI_KEY` environment variable (a base64-encoded 32-byte AES key) to touch its
database. For agent use, the **orchestrator provides this** — the agent should not generate or read
it. A human setting up emcli for the first time generates one with
`head -c 32 /dev/urandom | base64` and saves it securely. See the project User Manual for full admin
setup.
+4 -5
View File
@@ -7,18 +7,17 @@
# bash install.sh
#
# Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.4.0)
# EMCLI_VERSION release tag to fetch (default: v0.5.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)
#
# NOTE: v0.4.0 and its release assets are placeholders until the first tagged
# release is published. The asset naming below is the scheme the release will use:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.4.0_linux_amd64
# Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.4.0}"
VERSION="${EMCLI_VERSION:-v0.5.0}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
+53 -17
View File
@@ -62,23 +62,50 @@ The binary is organized into independently testable packages:
human-readable/TUI).
### Trust boundary
- The agent invokes only the **agent commands** (Section 7.1).
- `EMCLI_KEY` is supplied by the environment/orchestrator that launches `emcli`, never as
an argument the agent constructs. The agent has no command that reveals secret values.
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has
no other path to the mail servers.
Two keys enforce a hard privilege split — this is not convention; it is structurally enforced by
the DEK-wrapping scheme:
- **`EMCLI_ADMIN_KEY`** — base64-encoded 32-byte key held by the human operator. Authorises ALL
commands. Admin commands unwrap the DEK from the `dek_wrap_admin` slot only; there is no fallback
to the agent slot. A process holding only `EMCLI_KEY` cannot run admin commands.
- **`EMCLI_KEY`** — base64-encoded 32-byte key supplied to the agent orchestrator. Authorises agent
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
a process with only the admin key can also run agent commands.
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
If a process holding only `EMCLI_KEY` attempts an admin command, `emcli` exits with:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
(An agent command with no key set at all yields a different `config` error: `EMCLI_KEY is not set`.)
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
constructs. The agent has no command that reveals secret values.
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
path to the mail servers.
## 5. Configuration & secrets
- **Encryption key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). If
absent or malformed, every command that touches the DB fails closed with an error
envelope; no plaintext fallback.
- **Admin key:** `EMCLI_ADMIN_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for
admin commands and for `init`. If absent or malformed when an admin command is attempted, the
command fails with `emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
- **Agent key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for agent
commands. If absent or malformed, every agent command fails closed with a `config` error envelope;
no plaintext fallback. `EMCLI_ADMIN_KEY` is accepted as a fallback for agent commands when
`EMCLI_KEY` is not set.
- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db`
(`%AppData%\emcli\emcli.db` on Windows).
- **Field-level encryption:** secret columns are stored as AES-256-GCM ciphertext with a
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains
plaintext for debuggability. Decryption with the wrong key fails (GCM auth tag) and is
surfaced as an error, never silently ignored.
- **Envelope encryption (DEK):** `emcli init` generates a random data-encryption key (DEK) that
protects all account secrets. The DEK is stored in the `settings` table sealed under both keys:
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY` (AES-256-GCM).
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY` (AES-256-GCM).
The DEK is never written in cleartext. Admin commands unwrap from `dek_wrap_admin` only; agent
commands unwrap from `dek_wrap_agent` (or `dek_wrap_admin` if only the admin key is present).
There is no cross-slot fallback for admin commands — a holder of only `EMCLI_KEY` cannot unwrap
the admin DEK slot.
- **`init` idempotency:** re-running `emcli init` does not regenerate the DEK; the existing wrapped
DEK rows are preserved.
- **Field-level encryption:** secret columns are encrypted with the DEK using AES-256-GCM with a
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains plaintext for
debuggability. Decryption with the wrong key fails (GCM auth tag) and is surfaced as an error,
never silently ignored.
Secret columns: account password, OAuth client secret, OAuth refresh token.
@@ -142,7 +169,9 @@ audit_log
settings
key TEXT PK
value TEXT
-- includes: audit_retention_days, schema_version
-- includes: audit_retention_days, schema_version,
-- dek_wrap_admin (DEK sealed under EMCLI_ADMIN_KEY),
-- dek_wrap_agent (DEK sealed under EMCLI_KEY)
```
Notes:
@@ -216,8 +245,11 @@ command that advances read state is `ack`.
### 7.2 Admin commands (human-readable / TUI)
- **`emcli init`** — TUI flow: creates the DB (generating schema), adds the first account,
and runs OAuth consent if the account is OAuth2.
Require `EMCLI_ADMIN_KEY`.
- **`emcli init`** — TUI flow: creates the DB (generating schema + DEK), adds the first account,
and runs OAuth consent if the account is OAuth2. Requires both `EMCLI_ADMIN_KEY` and `EMCLI_KEY`
(writes both DEK wrap slots). Idempotent — re-running does not regenerate the DEK.
- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a
table (never secrets). `account add` accepts `--process-backlog` (default off) which sets
the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID
@@ -225,8 +257,12 @@ command that advances read state is `ack`.
- **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries.
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
- **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries.
- **`emcli doctor`** — verifies `EMCLI_KEY` is present and valid, the DB opens, and each
account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics.
### 7.2a `doctor` — agent-role diagnostics
`doctor` is authorised by `EMCLI_KEY` (or `EMCLI_ADMIN_KEY`). It verifies the key is present and
valid, the DB opens, and each account's IMAP/SMTP connectivity and auth succeed. Prints
human-readable diagnostics. Can be run by the agent or by a human; does not require admin privilege.
### 7.3 Defaults & limits
- `list --limit` default: 50; maximum: 500.