Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c5e0a26f3 | |||
| 456d25d4f3 | |||
| 3bea73f857 | |||
| c651b00d08 | |||
| 8ed10dd503 | |||
| 2140d9e173 | |||
| 64ff32ab29 | |||
| 7039371f70 | |||
| e1b4ec38e5 | |||
| bd06b4b900 | |||
| 8e5c06a4cb | |||
| 32f5a8d933 | |||
| b6e68ddeae | |||
| 6a99e5bb6e | |||
| c5e42ffbae | |||
| cdffb15004 | |||
| a4c49d4aca | |||
| 852bb1dc5b | |||
| 76ada04442 | |||
| add9515b5c | |||
| 456e15a2f8 | |||
| 5c7dd252db | |||
| 9d946b1b03 | |||
| cb0425f18d | |||
| c52f30898b | |||
| 77ba5a146f | |||
| 2bc2c1b50e |
@@ -9,11 +9,14 @@ it isn't permitted to see or send mail to people it isn't permitted to contact.
|
|||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # one-time: generate & save a key
|
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
|
||||||
emcli init # create the DB, add your first account
|
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
|
||||||
emcli doctor # confirm it connects and authenticates
|
emcli init # writes both wrap slots
|
||||||
|
emcli doctor # confirm connect/auth (agent key is enough)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`emcli init` needs both keys. Give the agent's orchestrator only `EMCLI_KEY`; admin commands (`account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY` and will refuse to run without it.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail
|
See the **[User Manual](USER-MANUAL.md)** for full setup, account configuration (including Gmail
|
||||||
|
|||||||
+103
-35
@@ -13,7 +13,8 @@ This manual is for **using and administering** `emcli`. It assumes you have the
|
|||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
1. [Key concepts](#1-key-concepts)
|
1. [Key concepts](#1-key-concepts)
|
||||||
2. [Setup: the encryption key and database](#2-setup-the-encryption-key-and-database)
|
2. [Setup: encryption keys and database](#2-setup-encryption-keys-and-database)
|
||||||
|
- [Privilege model](#2a-privilege-model)
|
||||||
3. [Quick start](#3-quick-start)
|
3. [Quick start](#3-quick-start)
|
||||||
4. [Adding accounts](#4-adding-accounts)
|
4. [Adding accounts](#4-adding-accounts)
|
||||||
- [Gmail (app password)](#gmail-app-password)
|
- [Gmail (app password)](#gmail-app-password)
|
||||||
@@ -32,10 +33,14 @@ This manual is for **using and administering** `emcli`. It assumes you have the
|
|||||||
## 1. Key concepts
|
## 1. Key concepts
|
||||||
|
|
||||||
**Two kinds of commands.**
|
**Two kinds of commands.**
|
||||||
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`, `doctor`) are for *you*,
|
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
|
||||||
the human. They print human-readable text or open an interactive form.
|
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
|
||||||
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`) are for the *agent*. They print one
|
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
|
||||||
line of JSON and nothing else, so a program can consume them reliably.
|
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
|
||||||
|
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
|
||||||
|
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
|
||||||
|
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
|
||||||
|
as a superset, so either key suffices for agent commands.)
|
||||||
|
|
||||||
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
|
**Accounts** are named (e.g. `gmail`, `work`). The agent refers to an account by name and never
|
||||||
sees its password.
|
sees its password.
|
||||||
@@ -61,50 +66,95 @@ acking is a deliberate, separate step.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Setup: the encryption key and database
|
## 2. Setup: encryption keys and database
|
||||||
|
|
||||||
`emcli` reads two environment variables:
|
`emcli` reads three environment variables:
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
| Variable | Purpose | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `EMCLI_KEY` | **Required.** Base64-encoded 32-byte key (AES-256) used to encrypt passwords at rest. | none — commands fail without it |
|
| `EMCLI_ADMIN_KEY` | **Required for admin.** Base64-encoded 32-byte key (AES-256). Authorises ALL commands. | none — admin commands fail without it |
|
||||||
|
| `EMCLI_KEY` | **Required for agents.** Base64-encoded 32-byte key (AES-256). Authorises agent commands only. | none — agent commands fail without it |
|
||||||
| `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) |
|
| `EMCLI_DB` | Path to the database file. | `~/.config/emcli/emcli.db` (Linux/macOS), `%AppData%\emcli\emcli.db` (Windows) |
|
||||||
|
|
||||||
**Generate a key once** and keep it safe (store it the way the program/orchestrator that launches
|
**Generate both keys once** and keep them safe:
|
||||||
`emcli` expects — e.g. a secrets manager or your shell profile):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
head -c 32 /dev/urandom | base64
|
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # you (human) keep this
|
||||||
|
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # the agent launcher gets ONLY this
|
||||||
```
|
```
|
||||||
|
|
||||||
Set it in your environment before running any command:
|
> **Important:** the keys protect your account passwords via envelope encryption (see "Privilege
|
||||||
|
> model" below). If you lose `EMCLI_ADMIN_KEY`, account secrets can't be decrypted and you'll have
|
||||||
```bash
|
> to re-add accounts. `emcli` never falls back to plaintext — a missing or wrong key makes every
|
||||||
export EMCLI_KEY='paste-the-base64-key-here'
|
> command fail safely.
|
||||||
```
|
|
||||||
|
|
||||||
> **Important:** the key encrypts your account passwords. If you lose it, the stored passwords
|
|
||||||
> can't be decrypted and you'll have to re-add accounts. If you change it, the same applies.
|
|
||||||
> `emcli` never falls back to plaintext — a missing or wrong key makes every command fail safely.
|
|
||||||
|
|
||||||
Account passwords are stored **encrypted**; they never appear in command output, error messages,
|
Account passwords are stored **encrypted**; they never appear in command output, error messages,
|
||||||
or the audit log.
|
or the audit log.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2a. Privilege model
|
||||||
|
|
||||||
|
`emcli` enforces a two-role privilege split so a process holding only the agent key cannot
|
||||||
|
reconfigure accounts, whitelists, or audit settings.
|
||||||
|
|
||||||
|
### The two keys
|
||||||
|
|
||||||
|
| Key | Holder | Authorises |
|
||||||
|
|---|---|---|
|
||||||
|
| `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) |
|
||||||
|
| `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `ack`, `send`, `doctor`) |
|
||||||
|
|
||||||
|
`EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands
|
||||||
|
too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY
|
||||||
|
(admin privilege)` if it attempts an admin command.
|
||||||
|
|
||||||
|
### Envelope encryption (DEK)
|
||||||
|
|
||||||
|
`emcli init` generates a random data-encryption key (DEK) that seals all account secrets. The DEK
|
||||||
|
is stored in the `settings` table in two sealed copies:
|
||||||
|
|
||||||
|
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY`.
|
||||||
|
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY`.
|
||||||
|
|
||||||
|
The DEK is never written in cleartext. Admin commands unwrap the DEK from the admin slot only; they
|
||||||
|
have no fallback to the agent slot. This means a process holding only `EMCLI_KEY` cannot unlock the
|
||||||
|
DEK for an admin command, even if it somehow knows the agent key.
|
||||||
|
|
||||||
|
### Command → role table
|
||||||
|
|
||||||
|
| Command | Role required |
|
||||||
|
|---|---|
|
||||||
|
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
|
||||||
|
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
|
||||||
|
|
||||||
|
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
|
||||||
|
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
|
||||||
|
`can_send` — no host or login username.
|
||||||
|
| `init` | Both keys required (writes both wrap slots) |
|
||||||
|
|
||||||
|
### Agent launcher guidance
|
||||||
|
|
||||||
|
Configure your agent's orchestrator with **only `EMCLI_KEY`**. Never give the orchestrator
|
||||||
|
`EMCLI_ADMIN_KEY`. If the agent tries to run an admin command — even by mistake — `emcli` will
|
||||||
|
refuse it at the key level, not just by convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 3. Quick start
|
## 3. Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Set your key (see section 2)
|
# 1. Generate and export both keys (see section 2)
|
||||||
export EMCLI_KEY='…'
|
export EMCLI_ADMIN_KEY="$(head -c 32 /dev/urandom | base64)" # keep this yourself
|
||||||
|
export EMCLI_KEY="$(head -c 32 /dev/urandom | base64)" # give only this to the agent
|
||||||
|
|
||||||
# 2. Create the database and add your first account (interactive form)
|
# 2. Create the database and add your first account (interactive form)
|
||||||
emcli init
|
emcli init
|
||||||
|
|
||||||
# 3. Check it connects and authenticates
|
# 3. Check it connects and authenticates (agent key is enough for doctor)
|
||||||
emcli doctor
|
emcli doctor
|
||||||
|
|
||||||
# 4. The agent can now read
|
# 4. The agent can now read (needs only EMCLI_KEY)
|
||||||
emcli list --account gmail --folder INBOX --limit 10
|
emcli list --account gmail --folder INBOX --limit 10
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -146,6 +196,7 @@ emcli account add --name work --mode RW \
|
|||||||
| `--smtp-security` | `tls` | `tls` or `starttls` |
|
| `--smtp-security` | `tls` | `tls` or `starttls` |
|
||||||
| `--username` | — | Login username, usually your full email (required) |
|
| `--username` | — | Login username, usually your full email (required) |
|
||||||
| `--password` | — | Login password or app password |
|
| `--password` | — | Login password or app password |
|
||||||
|
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
|
||||||
| `--subject-regex` | — | Inbound subject filter (optional) |
|
| `--subject-regex` | — | Inbound subject filter (optional) |
|
||||||
| `--whitelist-in` | off | Enable inbound whitelist |
|
| `--whitelist-in` | off | Enable inbound whitelist |
|
||||||
| `--whitelist-out` | off | Enable outbound 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
|
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
|
```bash
|
||||||
> toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit --name X`
|
emcli account edit --name work --from 'Work Team <you@yourdomain.com>' # set the send-as address
|
||||||
> with no other flags), or the `whitelist` commands in section 6.
|
emcli account edit --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`):
|
**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
|
client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed
|
||||||
to see.
|
to see.
|
||||||
|
|
||||||
|
The message's `From:` is the account's send-as address (`--from`, set on `account add`/`edit`); if
|
||||||
|
none is configured it falls back to the login username. A display-name address like
|
||||||
|
`Steve Cliff <me@example.com>` shows the name in the recipient's client while the bare address is
|
||||||
|
used for the SMTP envelope.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. The JSON envelope
|
## 8. The JSON envelope
|
||||||
@@ -491,10 +553,16 @@ emcli config get audit_retention_days
|
|||||||
## 11. Troubleshooting
|
## 11. Troubleshooting
|
||||||
|
|
||||||
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
|
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
|
||||||
base64-encoded 32-byte key (section 2). Every command that touches the database needs it.
|
base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `ack`, `send`,
|
||||||
|
`doctor`) need this key.
|
||||||
|
|
||||||
**A command fails to decrypt / "wrong EMCLI_KEY?".** The key doesn't match the one used when the
|
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
|
||||||
account was added. Restore the original key, or re-add the account with the current key.
|
Admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) need this key;
|
||||||
|
`EMCLI_KEY` alone is not enough for them. (`account list` is the exception — an agent can run it.)
|
||||||
|
|
||||||
|
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
|
||||||
|
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
|
||||||
|
the DEK if one already exists) with both correct keys, then re-add any accounts if needed.
|
||||||
|
|
||||||
**`doctor` shows `IMAP FAIL` / `SMTP FAIL`.**
|
**`doctor` shows `IMAP FAIL` / `SMTP FAIL`.**
|
||||||
- *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure
|
- *Invalid credentials / authentication failed* — wrong username or password. For Gmail, make sure
|
||||||
@@ -528,19 +596,19 @@ running non-interactively.
|
|||||||
emcli # or: emcli help / emcli --help — list all commands
|
emcli # or: emcli help / emcli --help — list all commands
|
||||||
emcli <command> --help # usage and flags for one command
|
emcli <command> --help # usage and flags for one command
|
||||||
|
|
||||||
# Admin
|
# Admin (requires EMCLI_ADMIN_KEY)
|
||||||
emcli init # create DB + add first account (form)
|
emcli init # create DB + add first account (form)
|
||||||
emcli account add [flags | none for form] # add an account
|
emcli account add [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 edit --name N [flags | none for form] # change an account
|
||||||
emcli account remove --name N --yes # delete an account
|
emcli account remove --name N --yes # delete an account
|
||||||
emcli whitelist in|out add|remove|list --account N [--address A]
|
emcli whitelist in|out add|remove|list --account N [--address A]
|
||||||
emcli config set|get <key> [value] # e.g. audit_retention_days
|
emcli config set|get <key> [value] # e.g. audit_retention_days
|
||||||
emcli audit list [--account N] [--limit K]
|
emcli audit list [--account N] [--limit K]
|
||||||
emcli doctor [--account N] # connectivity/auth check
|
|
||||||
emcli version
|
emcli version
|
||||||
|
|
||||||
# Agent (one line of JSON each)
|
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
|
||||||
|
emcli doctor [--account N] # connectivity/auth check
|
||||||
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
|
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
|
||||||
emcli get --account N [--folder F] --uid U
|
emcli get --account N [--folder F] --uid U
|
||||||
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
|
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
|
||||||
@@ -548,4 +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]]
|
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 ~14–30) 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 ~53–69) 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 ~28–39) 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 ~75–97) 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 ~14–20) 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 ~21–33), 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 2–3).
|
||||||
|
|
||||||
|
- [ ] **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 5–6. ✓
|
||||||
|
- 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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-13
@@ -6,12 +6,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
||||||
func runAccount(args []string, out, errOut io.Writer) int {
|
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
if len(args) == 0 || helpRequested(args[0]) {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "account")
|
printCmdUsage(out, "account")
|
||||||
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
|
||||||
@@ -21,9 +22,16 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, rest := args[0], args[1:]
|
sub, rest := args[0], args[1:]
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
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
|
return 1
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
@@ -45,6 +53,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
|
smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
|
||||||
user := fs.String("username", "", "login username")
|
user := fs.String("username", "", "login username")
|
||||||
pass := fs.String("password", "", "login password")
|
pass := fs.String("password", "", "login password")
|
||||||
|
from := fs.String("from", "", "send-as address (blank = use username)")
|
||||||
subj := fs.String("subject-regex", "", "inbound subject filter")
|
subj := fs.String("subject-regex", "", "inbound subject filter")
|
||||||
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
|
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
|
||||||
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
|
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
|
||||||
@@ -56,9 +65,14 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
fmt.Fprintln(errOut, "name, imap-host, and username are required")
|
fmt.Fprintln(errOut, "name, imap-host, and username are required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
if err := tui.ValidFromAddress(*from); err != nil {
|
||||||
|
fmt.Fprintln(errOut, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
acc := store.Account{
|
acc := store.Account{
|
||||||
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
|
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
|
||||||
AuthType: "password", Username: *user, Password: *pass,
|
AuthType: "password", Username: *user, Password: *pass,
|
||||||
|
FromAddress: *from,
|
||||||
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
|
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
|
||||||
ProcessBacklog: *backlog,
|
ProcessBacklog: *backlog,
|
||||||
}
|
}
|
||||||
@@ -85,6 +99,7 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
smtpSec := fs.String("smtp-security", "", "tls|starttls")
|
smtpSec := fs.String("smtp-security", "", "tls|starttls")
|
||||||
user := fs.String("username", "", "login username")
|
user := fs.String("username", "", "login username")
|
||||||
pass := fs.String("password", "", "login password (blank keeps existing)")
|
pass := fs.String("password", "", "login password (blank keeps existing)")
|
||||||
|
from := fs.String("from", "", "send-as address (empty reverts to username)")
|
||||||
subj := fs.String("subject-regex", "", "inbound subject filter")
|
subj := fs.String("subject-regex", "", "inbound subject filter")
|
||||||
if err := fs.Parse(rest); err != nil {
|
if err := fs.Parse(rest); err != nil {
|
||||||
return 2
|
return 2
|
||||||
@@ -96,6 +111,10 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
|
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
|
||||||
return editInteractive(st, *name, out, errOut)
|
return editInteractive(st, *name, out, errOut)
|
||||||
}
|
}
|
||||||
|
if err := tui.ValidFromAddress(*from); err != nil {
|
||||||
|
fmt.Fprintln(errOut, err)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
acc, err := st.GetAccount(*name)
|
acc, err := st.GetAccount(*name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "edit: %v\n", err)
|
fmt.Fprintf(errOut, "edit: %v\n", err)
|
||||||
@@ -122,6 +141,8 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
acc.Username = *user
|
acc.Username = *user
|
||||||
case "password":
|
case "password":
|
||||||
acc.Password = *pass
|
acc.Password = *pass
|
||||||
|
case "from":
|
||||||
|
acc.FromAddress = *from
|
||||||
case "subject-regex":
|
case "subject-regex":
|
||||||
acc.SubjectRegex = *subj
|
acc.SubjectRegex = *subj
|
||||||
}
|
}
|
||||||
@@ -158,11 +179,31 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
|||||||
fmt.Fprintf(out, "account %q removed\n", *name)
|
fmt.Fprintf(out, "account %q removed\n", *name)
|
||||||
return 0
|
return 0
|
||||||
case "list":
|
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()
|
accs, err := st.ListAccounts()
|
||||||
if err != nil {
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
if !isAdmin {
|
||||||
|
items := make([]map[string]any, 0, len(accs))
|
||||||
|
for _, a := range accs {
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"name": a.Name,
|
||||||
|
"from": a.SendFrom(),
|
||||||
|
"can_send": a.Mode == "RW",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = Success(map[string]any{"accounts": items}).Write(out)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
|
||||||
for _, a := range accs {
|
for _, a := range accs {
|
||||||
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
|
||||||
@@ -191,7 +232,7 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
// 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]) {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "config")
|
printCmdUsage(out, "config")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -204,7 +245,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
sub, key := args[0], args[1]
|
sub, key := args[0], args[1]
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -246,7 +287,7 @@ func runConfig(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
// 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]) {
|
if len(args) > 0 && helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "audit")
|
printCmdUsage(out, "audit")
|
||||||
return 0
|
return 0
|
||||||
@@ -262,7 +303,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
|||||||
if err := fs.Parse(args[1:]); err != nil {
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -276,7 +317,7 @@ func runAudit(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
// 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]) {
|
if len(args) == 0 || helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "whitelist")
|
printCmdUsage(out, "whitelist")
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
@@ -289,7 +330,19 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
dir := store.Direction(args[0])
|
dir := store.Direction(args[0])
|
||||||
|
if dir != store.DirIn && dir != store.DirOut {
|
||||||
|
fmt.Fprintf(errOut, "whitelist direction must be \"in\" or \"out\", got %q\n", args[0])
|
||||||
|
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
sub, rest := args[1], args[2:]
|
sub, rest := args[1], args[2:]
|
||||||
|
switch sub {
|
||||||
|
case "add", "remove", "list": // valid
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list)\n", sub)
|
||||||
|
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
|
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
account := fs.String("account", "", "account name")
|
account := fs.String("account", "", "account name")
|
||||||
@@ -301,7 +354,7 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
|
|||||||
fmt.Fprintln(errOut, "--account is required")
|
fmt.Fprintln(errOut, "--account is required")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -330,9 +383,6 @@ func runWhitelist(args []string, out, errOut io.Writer) int {
|
|||||||
for _, a := range addrs {
|
for _, a := range addrs {
|
||||||
fmt.Fprintln(out, a)
|
fmt.Fprintln(out, a)
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
|
|
||||||
return 2
|
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,28 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
|
// adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB.
|
||||||
func adminEnv(t *testing.T) string {
|
func adminEnv(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||||
t.Setenv("EMCLI_KEY", b64Key())
|
t.Setenv("EMCLI_ADMIN_KEY", b64Key())
|
||||||
|
t.Setenv("EMCLI_KEY", b64AgentKey())
|
||||||
t.Setenv("EMCLI_DB", db)
|
t.Setenv("EMCLI_DB", db)
|
||||||
|
|
||||||
|
st, err := store.Open(db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||||
|
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||||
|
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
|
st.Close()
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,11 +88,16 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
|
|||||||
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
|
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
|
||||||
t.Fatalf("edit failed: %s", e)
|
t.Fatalf("edit failed: %s", e)
|
||||||
}
|
}
|
||||||
st, err := store.Open(db, mustKey())
|
st, err := store.Open(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
adminKey, _ := crypto.AdminKeyFromEnv()
|
||||||
|
agentKey, _ := crypto.AgentKeyFromEnv()
|
||||||
|
if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil {
|
||||||
|
t.Fatalf("Unlock: %v", err)
|
||||||
|
}
|
||||||
got, err := st.GetAccount("ed")
|
got, err := st.GetAccount("ed")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetAccount: %v", err)
|
t.Fatalf("GetAccount: %v", err)
|
||||||
@@ -92,12 +110,60 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A missing direction (e.g. `whitelist list`) must report the real problem —
|
||||||
|
// the in|out direction — not the misleading "--account is required".
|
||||||
|
func TestWhitelistMissingDirectionReported(t *testing.T) {
|
||||||
|
adminEnv(t)
|
||||||
|
code, _, errOut := run(t, "whitelist", "list", "--account", "bobby")
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("missing direction must be a usage error")
|
||||||
|
}
|
||||||
|
if strings.Contains(errOut, "--account is required") {
|
||||||
|
t.Fatalf("misleading error; want a direction complaint, got: %q", errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "in") || !strings.Contains(errOut, "out") {
|
||||||
|
t.Fatalf("error should name the in|out direction, got: %q", errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing subcommand (e.g. `whitelist out --account x`) must report the real
|
||||||
|
// problem — the add|remove|list subcommand — not "--account is required".
|
||||||
|
func TestWhitelistMissingSubcommandReported(t *testing.T) {
|
||||||
|
adminEnv(t)
|
||||||
|
code, _, errOut := run(t, "whitelist", "out", "--account", "bobby")
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("missing subcommand must be a usage error")
|
||||||
|
}
|
||||||
|
if strings.Contains(errOut, "--account is required") {
|
||||||
|
t.Fatalf("misleading error; want a subcommand complaint, got: %q", errOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(errOut, "add") || !strings.Contains(errOut, "list") {
|
||||||
|
t.Fatalf("error should name the add|remove|list subcommand, got: %q", errOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The happy path still works after the direction/subcommand validation.
|
||||||
|
func TestWhitelistListWorks(t *testing.T) {
|
||||||
|
adminEnv(t)
|
||||||
|
run(t, "account", "add", "--name", "bobby", "--imap-host", "h", "--username", "u@x.com")
|
||||||
|
if code, _, e := run(t, "whitelist", "out", "add", "--account", "bobby", "--address", "@x.com"); code != 0 {
|
||||||
|
t.Fatalf("add failed: %s", e)
|
||||||
|
}
|
||||||
|
code, out, _ := run(t, "whitelist", "out", "list", "--account", "bobby")
|
||||||
|
if code != 0 || !strings.Contains(out, "@x.com") {
|
||||||
|
t.Fatalf("list: code=%d out=%q", code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuditListCoreRenders(t *testing.T) {
|
func TestAuditListCoreRenders(t *testing.T) {
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open: %v", err)
|
t.Fatalf("open: %v", err)
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
|
||||||
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
|
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
|
||||||
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
|
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
|
||||||
@@ -111,5 +177,17 @@ func TestAuditListCoreRenders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
|
func TestAccountEditFromValidationRejectsMalformed(t *testing.T) {
|
||||||
func mustKey() []byte { return make([]byte, 32) }
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ func testKey() []byte {
|
|||||||
|
|
||||||
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
|
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store: %v", err)
|
t.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
_, err = st.AddAccount(store.Account{
|
_, err = st.AddAccount(store.Account{
|
||||||
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
|
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ import (
|
|||||||
|
|
||||||
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
|
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store: %v", err)
|
t.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
for _, a := range accounts {
|
for _, a := range accounts {
|
||||||
if _, err := st.AddAccount(a); err != nil {
|
if _, err := st.AddAccount(a); err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"git.dcglab.co.uk/steve/emcli/internal/crypto"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||||
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||||
)
|
)
|
||||||
@@ -70,19 +71,38 @@ func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInit creates/opens the DB and adds the first account via the TUI form,
|
// runInit creates/opens the DB, writes both DEK wrap slots, and adds the first
|
||||||
// seeding a default audit retention if unset.
|
// account via the TUI form, seeding a default audit retention if unset.
|
||||||
func runInit(args []string, out, errOut io.Writer) int {
|
func runInit(args []string, out, errOut io.Writer) int {
|
||||||
if len(args) > 0 && helpRequested(args[0]) {
|
if len(args) > 0 && helpRequested(args[0]) {
|
||||||
printCmdUsage(out, "init")
|
printCmdUsage(out, "init")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
adminKey, err := crypto.AdminKeyFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
agentKey, err := crypto.AgentKeyFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
path, err := store.DefaultDBPath()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
st, err := store.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
if err := st.InitKeys(adminKey, agentKey); err != nil {
|
||||||
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := st.GetSetting("audit_retention_days"); err != nil {
|
if _, err := st.GetSetting("audit_retention_days"); err != nil {
|
||||||
_ = st.SetSetting("audit_retention_days", "90")
|
_ = st.SetSetting("audit_retention_days", "90")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+58
-19
@@ -25,17 +25,55 @@ func realMailer(acc store.Account) (Mailer, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// openStore loads the key and opens the DB, returning a human-readable error string.
|
// commandRole maps a command to the privilege it requires. Admin commands
|
||||||
func openStore() (*store.Store, error) {
|
// mutate configuration or expose oversight data; everything else is agent.
|
||||||
key, err := crypto.KeyFromEnv()
|
func commandRole(args []string) store.Role {
|
||||||
if err != nil {
|
switch args[0] {
|
||||||
return nil, err
|
case "account":
|
||||||
|
// account list is a read-only discovery view available to agents;
|
||||||
|
// add/edit/remove mutate config and require admin.
|
||||||
|
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()
|
path, err := store.DefaultDBPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return store.Open(path, key)
|
st, err := store.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := st.Unlock(role, adminKey, agentKey); err != nil {
|
||||||
|
st.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func realSender(acc store.Account, m mail.OutgoingMessage) error {
|
func realSender(acc store.Account, m mail.OutgoingMessage) error {
|
||||||
@@ -68,7 +106,7 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
|
// 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 := flag.NewFlagSet("doctor", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
usageFlags(fs, "doctor", errOut)
|
usageFlags(fs, "doctor", errOut)
|
||||||
@@ -79,7 +117,7 @@ func runDoctor(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
@@ -104,21 +142,22 @@ func Run(args []string, out, errOut io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
cmd, rest := args[0], args[1:]
|
cmd, rest := args[0], args[1:]
|
||||||
|
role := commandRole(args)
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "list", "get", "search", "ack":
|
case "list", "get", "search", "ack":
|
||||||
return runAgent(cmd, rest, out, errOut)
|
return runAgent(cmd, rest, role, out, errOut)
|
||||||
case "send":
|
case "send":
|
||||||
return runSend(rest, out, errOut)
|
return runSend(rest, role, out, errOut)
|
||||||
case "account":
|
case "account":
|
||||||
return runAccount(rest, out, errOut)
|
return runAccount(rest, role, out, errOut)
|
||||||
case "whitelist":
|
case "whitelist":
|
||||||
return runWhitelist(rest, out, errOut)
|
return runWhitelist(rest, role, out, errOut)
|
||||||
case "config":
|
case "config":
|
||||||
return runConfig(rest, out, errOut)
|
return runConfig(rest, role, out, errOut)
|
||||||
case "audit":
|
case "audit":
|
||||||
return runAudit(rest, out, errOut)
|
return runAudit(rest, role, out, errOut)
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return runDoctor(rest, out, errOut)
|
return runDoctor(rest, role, out, errOut)
|
||||||
case "init":
|
case "init":
|
||||||
return runInit(rest, out, errOut)
|
return runInit(rest, out, errOut)
|
||||||
default:
|
default:
|
||||||
@@ -128,7 +167,7 @@ func Run(args []string, out, errOut io.Writer) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
|
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
|
||||||
func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
usageFlags(fs, cmd, errOut)
|
usageFlags(fs, cmd, errOut)
|
||||||
@@ -159,7 +198,7 @@ func runAgent(cmd string, args []string, out, errOut io.Writer) int {
|
|||||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
return 1
|
return 1
|
||||||
@@ -224,7 +263,7 @@ func (s *stringSlice) Set(v string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runSend handles the `send` agent command (JSON envelope output).
|
// runSend handles the `send` agent command (JSON envelope output).
|
||||||
func runSend(args []string, out, errOut io.Writer) int {
|
func runSend(args []string, role store.Role, out, errOut io.Writer) int {
|
||||||
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
fs := flag.NewFlagSet("send", flag.ContinueOnError)
|
||||||
fs.SetOutput(errOut)
|
fs.SetOutput(errOut)
|
||||||
usageFlags(fs, "send", errOut)
|
usageFlags(fs, "send", errOut)
|
||||||
@@ -249,7 +288,7 @@ func runSend(args []string, out, errOut io.Writer) int {
|
|||||||
_ = Failure(CodeUsage, "--account is required").Write(out)
|
_ = Failure(CodeUsage, "--account is required").Write(out)
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
st, err := openStore()
|
st, err := openStore(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -18,17 +18,25 @@ func TestRunUnknownCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
|
func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) {
|
||||||
// `account list` with no DB key should fail closed with a usage/config error,
|
// `account list` is an agent command: with no DB key it fails closed before
|
||||||
// proving the key check happens before any DB work.
|
// any DB work, emitting a JSON config-error envelope that names EMCLI_KEY.
|
||||||
var out, errOut bytes.Buffer
|
var out, errOut bytes.Buffer
|
||||||
t.Setenv("EMCLI_KEY", "")
|
t.Setenv("EMCLI_KEY", "")
|
||||||
|
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||||
code := Run([]string{"account", "list"}, &out, &errOut)
|
code := Run([]string{"account", "list"}, &out, &errOut)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("missing EMCLI_KEY must fail")
|
t.Fatal("missing EMCLI_KEY must fail")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") {
|
var env map[string]any
|
||||||
t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String())
|
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||||
|
t.Fatalf("agent account list error must be JSON, got out=%q err=%q", out.String(), errOut.String())
|
||||||
|
}
|
||||||
|
if env["error"] != true {
|
||||||
|
t.Fatalf("want error envelope: %v", env)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "EMCLI_KEY") {
|
||||||
|
t.Fatalf("should name the missing EMCLI_KEY, got %q", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,3 +62,8 @@ func b64Key() string {
|
|||||||
// 32 zero bytes, base64.
|
// 32 zero bytes, base64.
|
||||||
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func b64AgentKey() string {
|
||||||
|
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
|
||||||
|
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := mail.OutgoingMessage{
|
msg := mail.OutgoingMessage{
|
||||||
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
|
From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
|
||||||
Subject: subject, BodyText: body,
|
Subject: subject, BodyText: body,
|
||||||
}
|
}
|
||||||
recipients := msg.Recipients()
|
recipients := msg.Recipients()
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import (
|
|||||||
// mailer (for reply-to). The named account is created per the supplied template.
|
// mailer (for reply-to). The named account is created per the supplied template.
|
||||||
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
|
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("store: %v", err)
|
t.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { st.Close() })
|
t.Cleanup(func() { st.Close() })
|
||||||
if _, err := st.AddAccount(acc); err != nil {
|
if _, err := st.AddAccount(acc); err != nil {
|
||||||
t.Fatalf("AddAccount: %v", err)
|
t.Fatalf("AddAccount: %v", err)
|
||||||
@@ -136,6 +139,32 @@ func TestSendReplyToThreadsHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendUsesConfiguredFromAddress(t *testing.T) {
|
||||||
|
acc := rwAccount()
|
||||||
|
acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
|
||||||
|
d, sent, _ := sendDeps(t, acc, nil)
|
||||||
|
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
|
||||||
|
t.Fatalf("SendCmd: %v", err)
|
||||||
|
}
|
||||||
|
if len(*sent) != 1 {
|
||||||
|
t.Fatalf("want 1 send, got %d", len(*sent))
|
||||||
|
}
|
||||||
|
if got := (*sent)[0].From; got != "Steve Cliff <me@stevecliff.com>" {
|
||||||
|
t.Fatalf("From = %q, want configured from-address", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendFallsBackToUsernameAsFrom(t *testing.T) {
|
||||||
|
// rwAccount has no FromAddress, so From must be the login username.
|
||||||
|
d, sent, _ := sendDeps(t, rwAccount(), nil)
|
||||||
|
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
|
||||||
|
t.Fatalf("SendCmd: %v", err)
|
||||||
|
}
|
||||||
|
if got := (*sent)[0].From; got != "emcli@stevecliff.com" {
|
||||||
|
t.Fatalf("From = %q, want username fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
|
func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
|
||||||
acc := rwAccount()
|
acc := rwAccount()
|
||||||
acc.WhitelistInEnabled = true // inbound filter active
|
acc.WhitelistInEnabled = true // inbound filter active
|
||||||
|
|||||||
+24
-11
@@ -1,4 +1,4 @@
|
|||||||
// Package crypto provides AES-256-GCM field encryption keyed from EMCLI_KEY.
|
// Package crypto provides AES-256-GCM field encryption; keys are loaded from EMCLI_KEY (agent) or EMCLI_ADMIN_KEY (admin).
|
||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,28 +7,41 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
// keyFromEnv reads and validates a base64-encoded 32-byte AES key from the
|
||||||
ErrNoKey = errors.New("EMCLI_KEY is not set")
|
// named environment variable. Errors name the variable so callers get a
|
||||||
ErrBadKey = errors.New("EMCLI_KEY must be base64 of exactly 32 bytes")
|
// role-appropriate message.
|
||||||
)
|
func keyFromEnv(varName string) ([]byte, error) {
|
||||||
|
raw := os.Getenv(varName)
|
||||||
// KeyFromEnv reads and validates the AES-256 key from EMCLI_KEY.
|
|
||||||
func KeyFromEnv() ([]byte, error) {
|
|
||||||
raw := os.Getenv("EMCLI_KEY")
|
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil, ErrNoKey
|
return nil, fmt.Errorf("%s is not set", varName)
|
||||||
}
|
}
|
||||||
key, err := base64.StdEncoding.DecodeString(raw)
|
key, err := base64.StdEncoding.DecodeString(raw)
|
||||||
if err != nil || len(key) != 32 {
|
if err != nil || len(key) != 32 {
|
||||||
return nil, ErrBadKey
|
return nil, fmt.Errorf("%s must be base64 of exactly 32 bytes", varName)
|
||||||
}
|
}
|
||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentKeyFromEnv reads the agent KEK from EMCLI_KEY (agent commands only).
|
||||||
|
func AgentKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_KEY") }
|
||||||
|
|
||||||
|
// AdminKeyFromEnv reads the admin KEK from EMCLI_ADMIN_KEY (all commands).
|
||||||
|
func AdminKeyFromEnv() ([]byte, error) { return keyFromEnv("EMCLI_ADMIN_KEY") }
|
||||||
|
|
||||||
|
// NewDEK returns a fresh random 32-byte data-encryption key.
|
||||||
|
func NewDEK() ([]byte, error) {
|
||||||
|
dek := make([]byte, 32)
|
||||||
|
if _, err := io.ReadFull(rand.Reader, dek); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dek, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newGCM(key []byte) (cipher.AEAD, error) {
|
func newGCM(key []byte) (cipher.AEAD, error) {
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package crypto
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,20 +51,38 @@ func TestOpenWrongKeyFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKeyFromEnv(t *testing.T) {
|
func TestAgentAndAdminKeyFromEnv(t *testing.T) {
|
||||||
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString(testKey()))
|
good := base64.StdEncoding.EncodeToString(testKey())
|
||||||
k, err := KeyFromEnv()
|
|
||||||
if err != nil || len(k) != 32 {
|
t.Setenv("EMCLI_KEY", good)
|
||||||
t.Fatalf("KeyFromEnv: key=%d err=%v", len(k), err)
|
if k, err := AgentKeyFromEnv(); err != nil || len(k) != 32 {
|
||||||
|
t.Fatalf("AgentKeyFromEnv: key=%d err=%v", len(k), err)
|
||||||
|
}
|
||||||
|
t.Setenv("EMCLI_ADMIN_KEY", good)
|
||||||
|
if k, err := AdminKeyFromEnv(); err != nil || len(k) != 32 {
|
||||||
|
t.Fatalf("AdminKeyFromEnv: key=%d err=%v", len(k), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv("EMCLI_KEY", "")
|
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||||
if _, err := KeyFromEnv(); err != ErrNoKey {
|
if _, err := AdminKeyFromEnv(); err == nil ||
|
||||||
t.Fatalf("empty key: want ErrNoKey, got %v", err)
|
!strings.Contains(err.Error(), "EMCLI_ADMIN_KEY") {
|
||||||
|
t.Fatalf("empty admin key: want EMCLI_ADMIN_KEY error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
|
t.Setenv("EMCLI_KEY", base64.StdEncoding.EncodeToString([]byte("tooshort")))
|
||||||
if _, err := KeyFromEnv(); err != ErrBadKey {
|
if _, err := AgentKeyFromEnv(); err == nil ||
|
||||||
t.Fatalf("short key: want ErrBadKey, got %v", err)
|
!strings.Contains(err.Error(), "32 bytes") {
|
||||||
|
t.Fatalf("short key: want length error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewDEKIsRandom32(t *testing.T) {
|
||||||
|
a, err := NewDEK()
|
||||||
|
if err != nil || len(a) != 32 {
|
||||||
|
t.Fatalf("NewDEK: len=%d err=%v", len(a), err)
|
||||||
|
}
|
||||||
|
b, _ := NewDEK()
|
||||||
|
if bytes.Equal(a, b) {
|
||||||
|
t.Fatal("two DEKs must differ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -46,6 +46,18 @@ func (m OutgoingMessage) Recipients() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envelopeFrom returns the bare address for the SMTP envelope sender, stripping
|
||||||
|
// any display name. A display-name From (e.g. "Name <addr>") is a valid header
|
||||||
|
// but an invalid envelope sender, so it must be reduced to the bare address.
|
||||||
|
// Unparseable input is passed through unchanged (preserves prior behaviour for
|
||||||
|
// plain addresses).
|
||||||
|
func envelopeFrom(from string) string {
|
||||||
|
if a, err := gomail.ParseAddress(from); err == nil {
|
||||||
|
return a.Address
|
||||||
|
}
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
|
||||||
func addrList(addrs []string) []*gomail.Address {
|
func addrList(addrs []string) []*gomail.Address {
|
||||||
out := make([]*gomail.Address, 0, len(addrs))
|
out := make([]*gomail.Address, 0, len(addrs))
|
||||||
for _, a := range addrs {
|
for _, a := range addrs {
|
||||||
@@ -163,7 +175,7 @@ func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
|
|||||||
if err := c.Auth(auth); err != nil {
|
if err := c.Auth(auth); err != nil {
|
||||||
return fmt.Errorf("smtp auth: %w", err)
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
}
|
}
|
||||||
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
|
if err := c.SendMail(envelopeFrom(m.From), m.Recipients(), bytes.NewReader(raw)); err != nil {
|
||||||
return fmt.Errorf("smtp send: %w", err)
|
return fmt.Errorf("smtp send: %w", err)
|
||||||
}
|
}
|
||||||
return c.Quit()
|
return c.Quit()
|
||||||
|
|||||||
@@ -100,6 +100,36 @@ func TestRecipientsCombinesAllFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnvelopeFromStripsDisplayName(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"Steve Cliff <me@stevecliff.com>": "me@stevecliff.com",
|
||||||
|
"me@stevecliff.com": "me@stevecliff.com",
|
||||||
|
"<me@stevecliff.com>": "me@stevecliff.com",
|
||||||
|
"not a valid address": "not a valid address", // unparseable ⇒ passthrough
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := envelopeFrom(in); got != want {
|
||||||
|
t.Errorf("envelopeFrom(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMIMEKeepsDisplayNameInHeader(t *testing.T) {
|
||||||
|
raw, err := BuildMIME(OutgoingMessage{
|
||||||
|
From: "Steve Cliff <me@stevecliff.com>",
|
||||||
|
To: []string{"you@example.com"},
|
||||||
|
Subject: "hi",
|
||||||
|
BodyText: "body",
|
||||||
|
Date: time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildMIME: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(raw), "Steve Cliff") {
|
||||||
|
t.Fatalf("From header lost display name:\n%s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReadHeaderParsesReferences(t *testing.T) {
|
func TestReadHeaderParsesReferences(t *testing.T) {
|
||||||
raw := "From: a@x.com\r\n" +
|
raw := "From: a@x.com\r\n" +
|
||||||
"To: b@x.com\r\n" +
|
"To: b@x.com\r\n" +
|
||||||
|
|||||||
+25
-14
@@ -23,6 +23,7 @@ type Account struct {
|
|||||||
SMTPSecurity string // tls | starttls
|
SMTPSecurity string // tls | starttls
|
||||||
AuthType string // password | oauth2
|
AuthType string // password | oauth2
|
||||||
Username string
|
Username string
|
||||||
|
FromAddress string // send-as identity; blank ⇒ fall back to Username
|
||||||
Password string // decrypted; empty in ListAccounts
|
Password string // decrypted; empty in ListAccounts
|
||||||
WhitelistInEnabled bool
|
WhitelistInEnabled bool
|
||||||
WhitelistOutEnabled bool
|
WhitelistOutEnabled bool
|
||||||
@@ -30,6 +31,15 @@ type Account struct {
|
|||||||
ProcessBacklog bool
|
ProcessBacklog bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendFrom returns the From identity for outgoing mail, falling back to the
|
||||||
|
// login username when no explicit from-address is configured.
|
||||||
|
func (a Account) SendFrom() string {
|
||||||
|
if a.FromAddress != "" {
|
||||||
|
return a.FromAddress
|
||||||
|
}
|
||||||
|
return a.Username
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) AddAccount(a Account) (int64, error) {
|
func (s *Store) AddAccount(a Account) (int64, error) {
|
||||||
var encPw []byte
|
var encPw []byte
|
||||||
if a.Password != "" {
|
if a.Password != "" {
|
||||||
@@ -42,12 +52,12 @@ func (s *Store) AddAccount(a Account) (int64, error) {
|
|||||||
res, err := s.db.Exec(`
|
res, err := s.db.Exec(`
|
||||||
INSERT INTO accounts
|
INSERT INTO accounts
|
||||||
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
(name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
||||||
auth_type,username,
|
auth_type,username,from_address,
|
||||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
|
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
|
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
|
||||||
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
|
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
|
||||||
a.AuthType, a.Username,
|
a.AuthType, a.Username, nullStr(a.FromAddress),
|
||||||
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
||||||
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
|
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,7 +69,7 @@ func (s *Store) AddAccount(a Account) (int64, error) {
|
|||||||
func (s *Store) GetAccount(name string) (Account, error) {
|
func (s *Store) GetAccount(name string) (Account, error) {
|
||||||
row := s.db.QueryRow(`
|
row := s.db.QueryRow(`
|
||||||
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
||||||
auth_type,username,
|
auth_type,username,from_address,
|
||||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
||||||
FROM accounts WHERE name = ?`, name)
|
FROM accounts WHERE name = ?`, name)
|
||||||
a, encPw, err := scanAccount(row)
|
a, encPw, err := scanAccount(row)
|
||||||
@@ -82,7 +92,7 @@ func (s *Store) GetAccount(name string) (Account, error) {
|
|||||||
func (s *Store) ListAccounts() ([]Account, error) {
|
func (s *Store) ListAccounts() ([]Account, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
SELECT id,name,mode,imap_host,imap_port,imap_security,smtp_host,smtp_port,smtp_security,
|
||||||
auth_type,username,
|
auth_type,username,from_address,
|
||||||
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
|
||||||
FROM accounts ORDER BY name`)
|
FROM accounts ORDER BY name`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,12 +118,12 @@ func (s *Store) UpdateAccount(a Account) error {
|
|||||||
// Build the SET clause, conditionally including secret columns.
|
// Build the SET clause, conditionally including secret columns.
|
||||||
set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
|
set := `mode=?, imap_host=?, imap_port=?, imap_security=?,
|
||||||
smtp_host=?, smtp_port=?, smtp_security=?,
|
smtp_host=?, smtp_port=?, smtp_security=?,
|
||||||
auth_type=?, username=?,
|
auth_type=?, username=?, from_address=?,
|
||||||
whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
|
whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?`
|
||||||
args := []any{
|
args := []any{
|
||||||
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
|
a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity,
|
||||||
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
|
nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity),
|
||||||
a.AuthType, a.Username,
|
a.AuthType, a.Username, nullStr(a.FromAddress),
|
||||||
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
|
||||||
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
|
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog),
|
||||||
}
|
}
|
||||||
@@ -152,16 +162,16 @@ type scanner interface{ Scan(dest ...any) error }
|
|||||||
|
|
||||||
func scanAccount(sc scanner) (Account, []byte, error) {
|
func scanAccount(sc scanner) (Account, []byte, error) {
|
||||||
var (
|
var (
|
||||||
a Account
|
a Account
|
||||||
encPw []byte
|
encPw []byte
|
||||||
subj, smtpHost, smtpSec sql.NullString
|
subj, smtpHost, smtpSec, fromAddr sql.NullString
|
||||||
smtpPort sql.NullInt64
|
smtpPort sql.NullInt64
|
||||||
wlIn, wlOut int
|
wlIn, wlOut int
|
||||||
backlog int
|
backlog int
|
||||||
)
|
)
|
||||||
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
|
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
|
||||||
&smtpHost, &smtpPort, &smtpSec,
|
&smtpHost, &smtpPort, &smtpSec,
|
||||||
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog)
|
&a.AuthType, &a.Username, &fromAddr, &encPw, &wlIn, &wlOut, &subj, &backlog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Account{}, nil, err
|
return Account{}, nil, err
|
||||||
}
|
}
|
||||||
@@ -172,6 +182,7 @@ func scanAccount(sc scanner) (Account, []byte, error) {
|
|||||||
a.WhitelistOutEnabled = wlOut != 0
|
a.WhitelistOutEnabled = wlOut != 0
|
||||||
a.ProcessBacklog = backlog != 0
|
a.ProcessBacklog = backlog != 0
|
||||||
a.SubjectRegex = subj.String
|
a.SubjectRegex = subj.String
|
||||||
|
a.FromAddress = fromAddr.String
|
||||||
return a, encPw, nil
|
return a, encPw, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,3 +84,30 @@ func TestListAccountsOmitsSecrets(t *testing.T) {
|
|||||||
t.Fatal("ListAccounts must not return secrets")
|
t.Fatal("ListAccounts must not return secrets")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSendFromFallsBackToUsername(t *testing.T) {
|
||||||
|
a := Account{Username: "login@example.com"}
|
||||||
|
if got := a.SendFrom(); got != "login@example.com" {
|
||||||
|
t.Fatalf("blank from-address should fall back to username, got %q", got)
|
||||||
|
}
|
||||||
|
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
|
||||||
|
if got := a.SendFrom(); got != "Steve Cliff <me@stevecliff.com>" {
|
||||||
|
t.Fatalf("set from-address should win, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddGetAccountRoundTripsFromAddress(t *testing.T) {
|
||||||
|
s := openTemp(t)
|
||||||
|
a := sampleAccount()
|
||||||
|
a.FromAddress = "Steve Cliff <me@stevecliff.com>"
|
||||||
|
if _, err := s.AddAccount(a); err != nil {
|
||||||
|
t.Fatalf("AddAccount: %v", err)
|
||||||
|
}
|
||||||
|
got, err := s.GetAccount("work")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAccount: %v", err)
|
||||||
|
}
|
||||||
|
if got.FromAddress != "Steve Cliff <me@stevecliff.com>" {
|
||||||
|
t.Fatalf("FromAddress not round-tripped: %q", got.FromAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
const schemaVersion = 1
|
const schemaVersion = 2
|
||||||
|
|
||||||
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS.
|
// schemaSQL is the full current schema. All statements are idempotent via IF NOT EXISTS.
|
||||||
const schemaSQL = `
|
const schemaSQL = `
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
smtp_security TEXT,
|
smtp_security TEXT,
|
||||||
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
|
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
|
from_address TEXT,
|
||||||
enc_password BLOB,
|
enc_password BLOB,
|
||||||
enc_oauth_client_id BLOB,
|
enc_oauth_client_id BLOB,
|
||||||
enc_oauth_client_secret BLOB,
|
enc_oauth_client_secret BLOB,
|
||||||
|
|||||||
+49
-8
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,9 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open opens (creating if needed) the DB at path and applies the schema.
|
// Open opens (creating if needed) the DB at path and applies the schema.
|
||||||
func Open(path string, key []byte) (*Store, error) {
|
// The store opens LOCKED: call InitKeys (first run) or Unlock before any
|
||||||
|
// secret read/write.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||||
return nil, fmt.Errorf("create db dir: %w", err)
|
return nil, fmt.Errorf("create db dir: %w", err)
|
||||||
}
|
}
|
||||||
@@ -39,22 +42,60 @@ func Open(path string, key []byte) (*Store, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, fmt.Errorf("apply schema: %w", err)
|
return nil, fmt.Errorf("apply schema: %w", err)
|
||||||
}
|
}
|
||||||
s := &Store{db: db, key: key}
|
s := &Store{db: db}
|
||||||
if _, err := s.GetSetting("schema_version"); err != nil {
|
if err := s.migrate(); err != nil {
|
||||||
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
|
db.Close()
|
||||||
db.Close()
|
return nil, err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrate brings an existing database up to the current schemaVersion. A brand-
|
||||||
|
// new database (no schema_version yet) already has every column from schemaSQL,
|
||||||
|
// so it is simply stamped at the current version. Each older version runs its
|
||||||
|
// forward step. The version gate makes every step idempotent across reopens.
|
||||||
|
func (s *Store) migrate() error {
|
||||||
|
v, err := s.GetSetting("schema_version")
|
||||||
|
if err != nil {
|
||||||
|
// Fresh database: schemaSQL created all columns already.
|
||||||
|
return s.SetSetting("schema_version", strconv.Itoa(schemaVersion))
|
||||||
|
}
|
||||||
|
var ver int
|
||||||
|
ver, err = strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid schema_version %q: %w", v, err)
|
||||||
|
}
|
||||||
|
if ver < 2 {
|
||||||
|
if _, err := s.db.Exec(`ALTER TABLE accounts ADD COLUMN from_address TEXT`); err != nil {
|
||||||
|
return fmt.Errorf("migrate to v2: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.SetSetting("schema_version", "2"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) Close() error { return s.db.Close() }
|
func (s *Store) Close() error { return s.db.Close() }
|
||||||
|
|
||||||
|
// expandUserHome replaces a leading "~" or "~/" in p with the user's home
|
||||||
|
// directory. Only a leading tilde is expanded (the usual shell convention) —
|
||||||
|
// "~user" and a tilde elsewhere in the path are left untouched. This guards
|
||||||
|
// against an EMCLI_DB set to a literal "~/..." (no shell to expand it), which
|
||||||
|
// would otherwise be opened relative to the cwd and create a stray "~" dir.
|
||||||
|
func expandUserHome(p string) string {
|
||||||
|
if p == "~" || strings.HasPrefix(p, "~/") {
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
|
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
|
||||||
func DefaultDBPath() (string, error) {
|
func DefaultDBPath() (string, error) {
|
||||||
if p := os.Getenv("EMCLI_DB"); p != "" {
|
if p := os.Getenv("EMCLI_DB"); p != "" {
|
||||||
return p, nil
|
return expandUserHome(p), nil
|
||||||
}
|
}
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
if dir := os.Getenv("AppData"); dir != "" {
|
if dir := os.Getenv("AppData"); dir != "" {
|
||||||
|
|||||||
+114
-11
@@ -1,49 +1,89 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testKey() []byte {
|
// A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
|
||||||
k := make([]byte, 32)
|
// tilde (no shell to expand it) can't be opened relative to the cwd and
|
||||||
for i := range k {
|
// silently create a stray "~" directory.
|
||||||
k[i] = byte(i)
|
func TestDefaultDBPathExpandsLeadingTilde(t *testing.T) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no home dir: %v", err)
|
||||||
|
}
|
||||||
|
cases := map[string]string{
|
||||||
|
"~/.config/emcli/emcli.db": filepath.Join(home, ".config", "emcli", "emcli.db"),
|
||||||
|
"~": home,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
t.Setenv("EMCLI_DB", in)
|
||||||
|
got, err := DefaultDBPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DefaultDBPath(%q): %v", in, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("EMCLI_DB=%q -> %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "~") {
|
||||||
|
t.Fatalf("EMCLI_DB=%q left a literal tilde: %q", in, got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return k
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// openTemp opens a fresh store in a temp dir.
|
// A non-leading tilde or "~user" is NOT a path we should rewrite — leave it be.
|
||||||
|
func TestDefaultDBPathLeavesOtherPathsUntouched(t *testing.T) {
|
||||||
|
for _, p := range []string{"/var/lib/emcli.db", "./rel/emcli.db", "~user/db"} {
|
||||||
|
t.Setenv("EMCLI_DB", p)
|
||||||
|
got, err := DefaultDBPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DefaultDBPath(%q): %v", p, err)
|
||||||
|
}
|
||||||
|
if got != p {
|
||||||
|
t.Fatalf("EMCLI_DB=%q was rewritten to %q", p, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// openTemp opens a fresh store in a temp dir and initialises keys so that
|
||||||
|
// account tests (which do crypto) work without needing their own setup.
|
||||||
func openTemp(t *testing.T) *Store {
|
func openTemp(t *testing.T) *Store {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
p := filepath.Join(t.TempDir(), "emcli.db")
|
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||||
s, err := Open(p, testKey())
|
s, err := Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Open: %v", err)
|
t.Fatalf("Open: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil {
|
||||||
|
t.Fatalf("InitKeys: %v", err)
|
||||||
|
}
|
||||||
t.Cleanup(func() { s.Close() })
|
t.Cleanup(func() { s.Close() })
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
||||||
p := filepath.Join(t.TempDir(), "emcli.db")
|
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||||
s, err := Open(p, testKey())
|
s, err := Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first Open: %v", err)
|
t.Fatalf("first Open: %v", err)
|
||||||
}
|
}
|
||||||
v, err := s.GetSetting("schema_version")
|
v, err := s.GetSetting("schema_version")
|
||||||
if err != nil || v != "1" {
|
if err != nil || v != "2" {
|
||||||
t.Fatalf("schema_version: %q err=%v", v, err)
|
t.Fatalf("schema_version: %q err=%v", v, err)
|
||||||
}
|
}
|
||||||
s.Close()
|
s.Close()
|
||||||
|
|
||||||
// Re-open: must not error or duplicate.
|
// Re-open: must not error or duplicate.
|
||||||
s2, err := Open(p, testKey())
|
s2, err := Open(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second Open: %v", err)
|
t.Fatalf("second Open: %v", err)
|
||||||
}
|
}
|
||||||
defer s2.Close()
|
defer s2.Close()
|
||||||
if v, _ := s2.GetSetting("schema_version"); v != "1" {
|
if v, _ := s2.GetSetting("schema_version"); v != "2" {
|
||||||
t.Fatalf("schema_version after reopen: %q", v)
|
t.Fatalf("schema_version after reopen: %q", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,3 +148,66 @@ func TestForeignKeyCascade(t *testing.T) {
|
|||||||
t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err)
|
t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenMigratesV1AddsFromAddress(t *testing.T) {
|
||||||
|
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||||
|
|
||||||
|
// Hand-build a v1 database: accounts table WITHOUT from_address, a settings
|
||||||
|
// table pinned at schema_version=1, and one pre-existing account row.
|
||||||
|
raw, err := sql.Open("sqlite", p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sql.Open: %v", err)
|
||||||
|
}
|
||||||
|
const v1Schema = `
|
||||||
|
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
|
CREATE TABLE accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
mode TEXT NOT NULL,
|
||||||
|
imap_host TEXT NOT NULL,
|
||||||
|
imap_port INTEGER NOT NULL,
|
||||||
|
imap_security TEXT NOT NULL,
|
||||||
|
smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT,
|
||||||
|
auth_type TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
enc_password BLOB,
|
||||||
|
enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB,
|
||||||
|
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subject_regex TEXT,
|
||||||
|
process_backlog INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO settings(key,value) VALUES ('schema_version','1');
|
||||||
|
INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username)
|
||||||
|
VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com');
|
||||||
|
`
|
||||||
|
if _, err := raw.Exec(v1Schema); err != nil {
|
||||||
|
t.Fatalf("seed v1 schema: %v", err)
|
||||||
|
}
|
||||||
|
raw.Close()
|
||||||
|
|
||||||
|
// Open via the store: the migration must add from_address and bump to v2.
|
||||||
|
s, err := Open(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open (migrate): %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
if v, _ := s.GetSetting("schema_version"); v != "2" {
|
||||||
|
t.Fatalf("schema_version after migrate: %q, want 2", v)
|
||||||
|
}
|
||||||
|
// ListAccounts SELECTs from_address; it would error if the column were missing.
|
||||||
|
accs, err := s.ListAccounts()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListAccounts after migrate: %v", err)
|
||||||
|
}
|
||||||
|
if len(accs) != 1 {
|
||||||
|
t.Fatalf("want 1 account after migrate, got %d", len(accs))
|
||||||
|
}
|
||||||
|
if accs[0].FromAddress != "" {
|
||||||
|
t.Fatalf("legacy account FromAddress should be empty, got %q", accs[0].FromAddress)
|
||||||
|
}
|
||||||
|
if got := accs[0].SendFrom(); got != "login@example.com" {
|
||||||
|
t.Fatalf("legacy account should send from username, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+26
-1
@@ -6,6 +6,7 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/mail"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -22,10 +23,24 @@ type Fields struct {
|
|||||||
IMAPHost, IMAPPort, IMAPSecurity string
|
IMAPHost, IMAPPort, IMAPSecurity string
|
||||||
SMTPHost, SMTPPort, SMTPSecurity string
|
SMTPHost, SMTPPort, SMTPSecurity string
|
||||||
Username, Password string
|
Username, Password string
|
||||||
|
FromAddress string
|
||||||
WhitelistIn, WhitelistOut, ProcessBacklog bool
|
WhitelistIn, WhitelistOut, ProcessBacklog bool
|
||||||
SubjectRegex string
|
SubjectRegex string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
|
||||||
|
// address (bare or "Display Name <addr>"). A blank value is valid: sending
|
||||||
|
// falls back to the login username.
|
||||||
|
func ValidFromAddress(s string) error {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := mail.ParseAddress(s); err != nil {
|
||||||
|
return errors.New("from address must be a valid email address or \"Name <email>\"")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
|
func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
|
||||||
|
|
||||||
// Validate checks required fields, enum fields, and numeric ports. RW accounts
|
// Validate checks required fields, enum fields, and numeric ports. RW accounts
|
||||||
@@ -60,6 +75,9 @@ func (f Fields) Validate() error {
|
|||||||
return errors.New("smtp port must be a number")
|
return errors.New("smtp port must be a number")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := ValidFromAddress(f.FromAddress); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
|
|||||||
Name: strings.TrimSpace(f.Name), Mode: f.Mode,
|
Name: strings.TrimSpace(f.Name), Mode: f.Mode,
|
||||||
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
|
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
|
||||||
AuthType: "password", Username: f.Username, Password: f.Password,
|
AuthType: "password", Username: f.Username, Password: f.Password,
|
||||||
|
FromAddress: f.FromAddress,
|
||||||
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
|
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
|
||||||
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
|
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
|
||||||
}
|
}
|
||||||
@@ -95,7 +114,8 @@ func FieldsFromAccount(a store.Account) Fields {
|
|||||||
Name: a.Name, Mode: a.Mode,
|
Name: a.Name, Mode: a.Mode,
|
||||||
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
|
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
|
||||||
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
|
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
|
||||||
Username: a.Username,
|
Username: a.Username,
|
||||||
|
FromAddress: a.FromAddress,
|
||||||
WhitelistIn: a.WhitelistInEnabled,
|
WhitelistIn: a.WhitelistInEnabled,
|
||||||
WhitelistOut: a.WhitelistOutEnabled,
|
WhitelistOut: a.WhitelistOutEnabled,
|
||||||
ProcessBacklog: a.ProcessBacklog,
|
ProcessBacklog: a.ProcessBacklog,
|
||||||
@@ -122,6 +142,7 @@ var fieldDefs = []fieldDef{
|
|||||||
{key: "smtp_port", label: "SMTP port (RW)"},
|
{key: "smtp_port", label: "SMTP port (RW)"},
|
||||||
{key: "smtp_security", label: "SMTP security (tls/starttls)"},
|
{key: "smtp_security", label: "SMTP security (tls/starttls)"},
|
||||||
{key: "username", label: "Username"},
|
{key: "username", label: "Username"},
|
||||||
|
{key: "from_address", label: "From address (optional)"},
|
||||||
{key: "password", label: "Password", password: true},
|
{key: "password", label: "Password", password: true},
|
||||||
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
|
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
|
||||||
{key: "whitelist_out", label: "Whitelist outbound (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
|
return f.SMTPSecurity
|
||||||
case "username":
|
case "username":
|
||||||
return f.Username
|
return f.Username
|
||||||
|
case "from_address":
|
||||||
|
return f.FromAddress
|
||||||
case "password":
|
case "password":
|
||||||
return f.Password
|
return f.Password
|
||||||
case "whitelist_in":
|
case "whitelist_in":
|
||||||
@@ -249,6 +272,8 @@ func (m AccountForm) collect() Fields {
|
|||||||
f.SMTPSecurity = strings.ToLower(v)
|
f.SMTPSecurity = strings.ToLower(v)
|
||||||
case "username":
|
case "username":
|
||||||
f.Username = v
|
f.Username = v
|
||||||
|
case "from_address":
|
||||||
|
f.FromAddress = v
|
||||||
case "password":
|
case "password":
|
||||||
f.Password = m.inputs[i].Value() // do not trim a password
|
f.Password = m.inputs[i].Value() // do not trim a password
|
||||||
case "whitelist_in":
|
case "whitelist_in":
|
||||||
|
|||||||
@@ -157,3 +157,36 @@ func TestAccountFormCancel(t *testing.T) {
|
|||||||
t.Fatal("esc should cancel the form")
|
t.Fatal("esc should cancel the form")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsBadFromAddress(t *testing.T) {
|
||||||
|
f := validFields()
|
||||||
|
f.FromAddress = "not an address"
|
||||||
|
if err := f.Validate(); err == nil {
|
||||||
|
t.Fatal("malformed from-address should fail validation")
|
||||||
|
}
|
||||||
|
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
|
||||||
|
if err := f.Validate(); err != nil {
|
||||||
|
t.Fatalf("display-name from-address should validate: %v", err)
|
||||||
|
}
|
||||||
|
f.FromAddress = "me@stevecliff.com"
|
||||||
|
if err := f.Validate(); err != nil {
|
||||||
|
t.Fatalf("bare from-address should validate: %v", err)
|
||||||
|
}
|
||||||
|
f.FromAddress = "" // blank ⇒ fall back, always valid
|
||||||
|
if err := f.Validate(); err != nil {
|
||||||
|
t.Fatalf("blank from-address should validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
|
||||||
|
f := validFields()
|
||||||
|
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
|
||||||
|
acc, _ := f.ToAccount()
|
||||||
|
if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
|
||||||
|
t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
|
||||||
|
}
|
||||||
|
back := FieldsFromAccount(acc)
|
||||||
|
if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
|
||||||
|
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
|
|||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `EMCLI_VERSION` | `v0.4.1` | Release tag to fetch |
|
| `EMCLI_VERSION` | `v0.5.2` | Release tag to fetch |
|
||||||
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
||||||
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
||||||
|
|
||||||
@@ -65,32 +65,47 @@ git clone https://gitea.dcglab.co.uk/steve/emcli && cd emcli
|
|||||||
CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
|
CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Confirm the encryption key is present
|
## 3. Confirm the agent key is present
|
||||||
|
|
||||||
emcli needs `EMCLI_KEY` (a base64-encoded 32-byte AES key) to touch its database. For agent use,
|
emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises
|
||||||
**the orchestrator that launched you provides it** in the environment.
|
`list`, `get`, `search`, `ack`, `send`, and `doctor`. Admin commands require `EMCLI_ADMIN_KEY`,
|
||||||
|
which the human holds — attempting admin commands with only `EMCLI_KEY` is refused by `emcli`.
|
||||||
|
|
||||||
|
For agent use, **the orchestrator that launched you provides `EMCLI_KEY`** in the environment.
|
||||||
|
|
||||||
- Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`.
|
- Confirm it's set, without printing it: `test -n "$EMCLI_KEY" && echo present`.
|
||||||
- **Never** read, print, log, pass as an argument, or generate this value.
|
- **Never** read, print, log, pass as an argument, or generate this value.
|
||||||
- If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by
|
- If it's empty, stop and tell the user: "emcli needs the `EMCLI_KEY` environment variable set by
|
||||||
your orchestrator; I can't read or create it for you."
|
your orchestrator; I can't read or create it for you."
|
||||||
|
|
||||||
(For a human setting emcli up the first time: generate one with `head -c 32 /dev/urandom | base64`
|
(For a human setting emcli up the first time: generate both keys with
|
||||||
and store it securely. Account creation and other admin is the human's job — see the project's
|
`head -c 32 /dev/urandom | base64` (once per key) and store them securely; then run `emcli init`
|
||||||
|
with both keys exported. Account creation and other admin is the human's job — see the project's
|
||||||
`USER-MANUAL.md`.)
|
`USER-MANUAL.md`.)
|
||||||
|
|
||||||
## 4. Find the account(s)
|
## 4. Find the account(s)
|
||||||
|
|
||||||
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use. If the
|
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
|
||||||
user permits running admin commands, `emcli doctor` lists the configured accounts and checks that
|
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
|
||||||
each one connects and authenticates:
|
with one entry per account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
emcli account list
|
||||||
|
# {"error":false,"error_detail":{},"data":{"accounts":[
|
||||||
|
# {"name":"gmail","from":"me@gmail.com","can_send":true},
|
||||||
|
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
|
||||||
|
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
|
||||||
|
(also an agent command) checks that accounts connect and authenticate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
emcli doctor # all accounts
|
emcli doctor # all accounts
|
||||||
emcli doctor --account gmail
|
emcli doctor --account gmail
|
||||||
```
|
```
|
||||||
|
|
||||||
Otherwise, just take the account name from the user and start with the workflow in `SKILL.md`.
|
Then start with the workflow in `SKILL.md`.
|
||||||
|
|
||||||
## You're set up
|
## You're set up
|
||||||
|
|
||||||
|
|||||||
+13
-7
@@ -17,10 +17,12 @@ sets its exit code to match.
|
|||||||
|
|
||||||
## Security model — read this first
|
## Security model — read this first
|
||||||
|
|
||||||
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`. Account setup,
|
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
|
||||||
passwords, whitelists, and config are the **user's** job (admin commands) — do not run or suggest
|
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
|
||||||
running `account`, `whitelist`, `config`, `init`, or `doctor` unless the user explicitly asks you
|
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
|
||||||
to help administer.
|
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
|
||||||
|
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
|
||||||
|
`init`. `emcli` will refuse those with a privilege error.
|
||||||
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
|
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
|
||||||
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
|
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
|
||||||
missing, stop and tell the user (see "Files & first run").
|
missing, stop and tell the user (see "Files & first run").
|
||||||
@@ -43,7 +45,8 @@ https://gitea.dcglab.co.uk/steve/emcli/raw/branch/main/skills/emcli/
|
|||||||
|
|
||||||
**Per-session preflight** (quick): run `emcli version`; if it's not found, set up via
|
**Per-session preflight** (quick): run `emcli version`; if it's not found, set up via
|
||||||
`AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if
|
`AGENTIC-MANUAL.md`. Confirm `EMCLI_KEY` is set *without printing it* (`test -n "$EMCLI_KEY"`); if
|
||||||
empty, tell the user their orchestrator must provide it. Then get the account name from the user.
|
empty, tell the user their orchestrator must provide `EMCLI_KEY` (the agent key). Then get the
|
||||||
|
account name from the user.
|
||||||
|
|
||||||
## How to read every result
|
## How to read every result
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ read-only — tell the user; do not attempt another account without their say-so
|
|||||||
| `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
|
| `emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
|
||||||
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
|
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
|
||||||
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
|
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
|
||||||
|
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
|
||||||
|
|
||||||
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
|
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
|
||||||
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
|
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
|
||||||
@@ -139,7 +143,9 @@ The user configures these; you cannot change them and shouldn't try.
|
|||||||
|
|
||||||
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
|
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
|
||||||
- ✅ `get` to read, then `ack` only after you've truly processed a message.
|
- ✅ `get` to read, then `ack` only after you've truly processed a message.
|
||||||
- ✅ Ask the user for the account name; keep bodies plain text.
|
- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
|
||||||
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
|
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
|
||||||
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`init`) unless asked to help set up.
|
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
|
||||||
|
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
|
||||||
|
(`account list` is allowed — use it to discover accounts.)
|
||||||
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
|
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
# bash install.sh
|
# bash install.sh
|
||||||
#
|
#
|
||||||
# Environment overrides:
|
# Environment overrides:
|
||||||
# EMCLI_VERSION release tag to fetch (default: v0.4.1)
|
# EMCLI_VERSION release tag to fetch (default: v0.5.2)
|
||||||
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
|
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
|
||||||
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
|
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
|
||||||
#
|
#
|
||||||
# Release assets follow this naming scheme:
|
# Release assets follow this naming scheme:
|
||||||
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.4.1_linux_amd64
|
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.2_linux_amd64
|
||||||
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
|
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${EMCLI_VERSION:-v0.4.1}"
|
VERSION="${EMCLI_VERSION:-v0.5.2}"
|
||||||
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
|
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
|
||||||
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
|
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
|
||||||
|
|
||||||
|
|||||||
+53
-17
@@ -62,23 +62,50 @@ The binary is organized into independently testable packages:
|
|||||||
human-readable/TUI).
|
human-readable/TUI).
|
||||||
|
|
||||||
### Trust boundary
|
### Trust boundary
|
||||||
- The agent invokes only the **agent commands** (Section 7.1).
|
|
||||||
- `EMCLI_KEY` is supplied by the environment/orchestrator that launches `emcli`, never as
|
Two keys enforce a hard privilege split — this is not convention; it is structurally enforced by
|
||||||
an argument the agent constructs. The agent has no command that reveals secret values.
|
the DEK-wrapping scheme:
|
||||||
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has
|
|
||||||
no other path to the mail servers.
|
- **`EMCLI_ADMIN_KEY`** — base64-encoded 32-byte key held by the human operator. Authorises ALL
|
||||||
|
commands. Admin commands unwrap the DEK from the `dek_wrap_admin` slot only; there is no fallback
|
||||||
|
to the agent slot. A process holding only `EMCLI_KEY` cannot run admin commands.
|
||||||
|
- **`EMCLI_KEY`** — base64-encoded 32-byte key supplied to the agent orchestrator. Authorises agent
|
||||||
|
commands (`list`, `get`, `search`, `ack`, `send`, `doctor`) only. `EMCLI_ADMIN_KEY` is a superset:
|
||||||
|
a process with only the admin key can also run agent commands.
|
||||||
|
- Agent commands use `EMCLI_KEY`; if only `EMCLI_ADMIN_KEY` is set, they fall back to it.
|
||||||
|
If a process holding only `EMCLI_KEY` attempts an admin command, `emcli` exits with:
|
||||||
|
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
|
||||||
|
(An agent command with no key set at all yields a different `config` error: `EMCLI_KEY is not set`.)
|
||||||
|
- `EMCLI_KEY` is supplied by the orchestrator that launches `emcli`, never as an argument the agent
|
||||||
|
constructs. The agent has no command that reveals secret values.
|
||||||
|
- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has no other
|
||||||
|
path to the mail servers.
|
||||||
|
|
||||||
## 5. Configuration & secrets
|
## 5. Configuration & secrets
|
||||||
|
|
||||||
- **Encryption key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). If
|
- **Admin key:** `EMCLI_ADMIN_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for
|
||||||
absent or malformed, every command that touches the DB fails closed with an error
|
admin commands and for `init`. If absent or malformed when an admin command is attempted, the
|
||||||
envelope; no plaintext fallback.
|
command fails with `emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`.
|
||||||
|
- **Agent key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). Required for agent
|
||||||
|
commands. If absent or malformed, every agent command fails closed with a `config` error envelope;
|
||||||
|
no plaintext fallback. `EMCLI_ADMIN_KEY` is accepted as a fallback for agent commands when
|
||||||
|
`EMCLI_KEY` is not set.
|
||||||
- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db`
|
- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db`
|
||||||
(`%AppData%\emcli\emcli.db` on Windows).
|
(`%AppData%\emcli\emcli.db` on Windows).
|
||||||
- **Field-level encryption:** secret columns are stored as AES-256-GCM ciphertext with a
|
- **Envelope encryption (DEK):** `emcli init` generates a random data-encryption key (DEK) that
|
||||||
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains
|
protects all account secrets. The DEK is stored in the `settings` table sealed under both keys:
|
||||||
plaintext for debuggability. Decryption with the wrong key fails (GCM auth tag) and is
|
- `dek_wrap_admin` — the DEK encrypted under `EMCLI_ADMIN_KEY` (AES-256-GCM).
|
||||||
surfaced as an error, never silently ignored.
|
- `dek_wrap_agent` — the DEK encrypted under `EMCLI_KEY` (AES-256-GCM).
|
||||||
|
The DEK is never written in cleartext. Admin commands unwrap from `dek_wrap_admin` only; agent
|
||||||
|
commands unwrap from `dek_wrap_agent` (or `dek_wrap_admin` if only the admin key is present).
|
||||||
|
There is no cross-slot fallback for admin commands — a holder of only `EMCLI_KEY` cannot unwrap
|
||||||
|
the admin DEK slot.
|
||||||
|
- **`init` idempotency:** re-running `emcli init` does not regenerate the DEK; the existing wrapped
|
||||||
|
DEK rows are preserved.
|
||||||
|
- **Field-level encryption:** secret columns are encrypted with the DEK using AES-256-GCM with a
|
||||||
|
random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains plaintext for
|
||||||
|
debuggability. Decryption with the wrong key fails (GCM auth tag) and is surfaced as an error,
|
||||||
|
never silently ignored.
|
||||||
|
|
||||||
Secret columns: account password, OAuth client secret, OAuth refresh token.
|
Secret columns: account password, OAuth client secret, OAuth refresh token.
|
||||||
|
|
||||||
@@ -142,7 +169,9 @@ audit_log
|
|||||||
settings
|
settings
|
||||||
key TEXT PK
|
key TEXT PK
|
||||||
value TEXT
|
value TEXT
|
||||||
-- includes: audit_retention_days, schema_version
|
-- includes: audit_retention_days, schema_version,
|
||||||
|
-- dek_wrap_admin (DEK sealed under EMCLI_ADMIN_KEY),
|
||||||
|
-- dek_wrap_agent (DEK sealed under EMCLI_KEY)
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -216,8 +245,11 @@ command that advances read state is `ack`.
|
|||||||
|
|
||||||
### 7.2 Admin commands (human-readable / TUI)
|
### 7.2 Admin commands (human-readable / TUI)
|
||||||
|
|
||||||
- **`emcli init`** — TUI flow: creates the DB (generating schema), adds the first account,
|
Require `EMCLI_ADMIN_KEY`.
|
||||||
and runs OAuth consent if the account is OAuth2.
|
|
||||||
|
- **`emcli init`** — TUI flow: creates the DB (generating schema + DEK), adds the first account,
|
||||||
|
and runs OAuth consent if the account is OAuth2. Requires both `EMCLI_ADMIN_KEY` and `EMCLI_KEY`
|
||||||
|
(writes both DEK wrap slots). Idempotent — re-running does not regenerate the DEK.
|
||||||
- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a
|
- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a
|
||||||
table (never secrets). `account add` accepts `--process-backlog` (default off) which sets
|
table (never secrets). `account add` accepts `--process-backlog` (default off) which sets
|
||||||
the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID
|
the account's baseline policy: off ⇒ newly-seen folders floor at their current max UID
|
||||||
@@ -225,8 +257,12 @@ command that advances read state is `ack`.
|
|||||||
- **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries.
|
- **`emcli whitelist in|out add|remove|list --account <name>`** — manage whitelist entries.
|
||||||
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
|
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
|
||||||
- **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries.
|
- **`emcli audit list [--account <name>] [--limit N]`** — view recent audit entries.
|
||||||
- **`emcli doctor`** — verifies `EMCLI_KEY` is present and valid, the DB opens, and each
|
|
||||||
account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics.
|
### 7.2a `doctor` — agent-role diagnostics
|
||||||
|
|
||||||
|
`doctor` is authorised by `EMCLI_KEY` (or `EMCLI_ADMIN_KEY`). It verifies the key is present and
|
||||||
|
valid, the DB opens, and each account's IMAP/SMTP connectivity and auth succeed. Prints
|
||||||
|
human-readable diagnostics. Can be run by the agent or by a human; does not require admin privilege.
|
||||||
|
|
||||||
### 7.3 Defaults & limits
|
### 7.3 Defaults & limits
|
||||||
- `list --limit` default: 50; maximum: 500.
|
- `list --limit` default: 50; maximum: 500.
|
||||||
|
|||||||
Reference in New Issue
Block a user