31 Commits

Author SHA1 Message Date
steve 8babdcfa88 chore(release): default installer to v0.7.0
release / release (push) Successful in 2m25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:29:09 +01:00
steve dfb434aec3 ci(release): use the tag annotation as the release description
The release was created with no body; now the annotated tag's message
is published as the release notes (lightweight tags keep an empty body).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:29:01 +01:00
steve d023df1b4a feat(cli): folders command, search --all-folders, empty-search hint
Fixes from testing email search (docs/enhancements-2026-07-07.md):

- New `folders` agent command lists the account's mailboxes (name,
  delimiter, selectable), INBOX first, so agents can discover archived
  mail outside INBOX.
- `search --all-folders` sweeps every selectable mailbox; each hit
  carries a `folder` field, `skipped_folders` reports mailboxes the
  server refused, and --limit caps visible results across the sweep.
  The sweep deliberately skips EnsureFolderBaseline so a read-only
  search never mutates list --new state.
- Empty search results include a generic `data.hint` with next steps.
  The hint is a fixed constant per mode, so the invisibility invariant
  holds: absent and policy-filtered mail produce byte-identical
  envelopes (codified in TestSearchEmptyHintIndistinguishableFromFiltered).
- Skill and user docs: document `--text` full-text search as
  best-effort (server-dependent); recommend --subject-contains/--from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:28:38 +01:00
steve 4c0c6b94db chore(skill): bump emcli skill version to 0.6.0 (mirror binary)
Mirror the skill metadata version to the binary release and document the
three skill-package touch points in RELEASING.md so they bump together.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 21:29:07 +01:00
steve e1b4ec38e5 docs(manual): document --from on account edit and the send-as address
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:49:46 +01:00
40 changed files with 4241 additions and 258 deletions
+6 -2
View File
@@ -34,12 +34,16 @@ jobs:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Create the release for the pushed tag.
# Create the release for the pushed tag. The annotated tag's message
# becomes the release description (empty for lightweight tags).
body=$(git tag -l --format='%(contents)' "${TAG}")
payload=$(jq -n --arg tag "${TAG}" --arg body "${body}" \
'{tag_name: $tag, name: $tag, body: $body}')
id=$(curl -fsSL -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${SERVER}/api/v1/repos/${REPO}/releases" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" | jq -r '.id')
-d "${payload}" | jq -r '.id')
echo "release id: ${id}"
# Upload every built asset (binaries + checksums.txt).
for f in dist/*; do
+9 -4
View File
@@ -46,11 +46,16 @@ published. If it ever doesn't fit your runner setup, fall back to Option A.
> public for `skills/emcli/scripts/install.sh` to fetch binaries without a token. A private repo
> returns 404 to unauthenticated downloads.
## After a release
## Skill-package version bump (do this in the `chore(release):` commit, before tagging)
The skill installer defaults to `EMCLI_VERSION=v0.4.1`. When you cut a different version, either
publish under that tag or update the default in `skills/emcli/scripts/install.sh` (and the options
table in `skills/emcli/AGENTIC-MANUAL.md`).
The agent skill is versioned in lockstep with the binary. When cutting `vX.Y.Z`, update all three
of these so the tagged commit is self-consistent, then commit as
`chore(release): default installer to vX.Y.Z`:
- `skills/emcli/scripts/install.sh``VERSION="${EMCLI_VERSION:-vX.Y.Z}"` (and the example asset
name in the header comment).
- `skills/emcli/AGENTIC-MANUAL.md` — the `EMCLI_VERSION` default in the options table.
- `skills/emcli/SKILL.md``metadata.version: "X.Y.Z"` (no `v` prefix; mirrors the binary tag).
## Versioning
+112 -48
View File
@@ -33,9 +33,10 @@ This manual is for **using and administering** `emcli`. It assumes you have the
## 1. Key concepts
**Two kinds of commands.**
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
and are for *you*, the human. They print human-readable text or open an interactive form.
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
- **Agent commands** (`list`, `get`, `search`, `folders`, `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
@@ -102,7 +103,7 @@ reconfigure accounts, whitelists, or audit settings.
| 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_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `folders`, `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
@@ -124,8 +125,12 @@ DEK for an admin command, even if it somehow knows the agent key.
| Command | Role required |
|---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
| `list`, `get`, `search`, `folders`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
`can_send` — no host or login username.
| `init` | Both keys required (writes both wrap slots) |
### Agent launcher guidance
@@ -171,17 +176,18 @@ emcli account add # opens the form
Or with flags (good for scripting):
```bash
emcli account add --name work --mode RW \
emcli account add work --mode RW \
--imap-host imap.example.com --imap-port 993 --imap-security tls \
--smtp-host smtp.example.com --smtp-port 465 --smtp-security tls \
--username you@example.com --password 'your-password'
```
The account name is the first positional argument. Omit it to open the interactive form.
**`account add` flags:**
| Flag | Default | Notes |
|---|---|---|
| `--name` | — | Account name the agent will use (required) |
| `--mode` | `RO` | `RO` (read-only) or `RW` (read + send) |
| `--imap-host` | — | IMAP server (required) |
| `--imap-port` | `993` | |
@@ -193,10 +199,10 @@ emcli account add --name work --mode RW \
| `--password` | — | Login password or app password |
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
| `--subject-regex` | — | Inbound subject filter (optional) |
| `--whitelist-in` | off | Enable inbound whitelist |
| `--whitelist-out` | off | Enable outbound whitelist |
| `--process-backlog` | off | Treat existing mail as "new" (see below) |
To enable whitelisting after adding an account, use `whitelist enable` (section 6).
**`--process-backlog`.** When `emcli` first sees a folder:
- **off (default):** existing mail is treated as already handled — `list --new` starts empty and
only mail that arrives *after* this point counts as new.
@@ -214,7 +220,7 @@ Gmail needs an **app password**, not your normal Google password.
4. Add the account (paste the app password; the spaces Google shows are optional):
```bash
emcli account add --name gmail --mode RW \
emcli account add gmail --mode RW \
--imap-host imap.gmail.com --imap-port 993 --imap-security tls \
--smtp-host smtp.gmail.com --smtp-port 465 --smtp-security tls \
--username you@gmail.com --password 'xxxxxxxxxxxxxxxx'
@@ -228,7 +234,7 @@ An app password keeps working until you revoke it, change your main Google passw
Most IMAP/SMTP providers work the same way. A typical cPanel-style host:
```bash
emcli account add --name work --mode RW \
emcli account add work --mode RW \
--imap-host mail.yourdomain.com --imap-port 993 --imap-security tls \
--smtp-host mail.yourdomain.com --smtp-port 465 --smtp-security tls \
--username you@yourdomain.com --password 'your-password'
@@ -247,28 +253,42 @@ confirm.
emcli account list
```
**Show an account's current settings:**
```bash
emcli account show gmail
```
Prints the account name, mode, IMAP/SMTP host, username, send-from address, subject filter, and
whitelist state. The password is never shown.
**Edit an account** — interactive (opens the form pre-filled):
```bash
emcli account edit --name gmail
emcli account edit gmail
```
**Edit with flags** — only the flags you pass are changed; everything else is preserved. Leaving
`--password` out keeps the existing password.
```bash
emcli account edit --name work --mode RW --smtp-host smtp.example.com --smtp-port 587 --smtp-security starttls
emcli account edit --name gmail --password 'new-app-password' # rotate the app password
emcli account edit work --mode RW --smtp-host smtp.example.com --smtp-port 587 --smtp-security starttls
emcli account edit gmail --password 'new-app-password' # rotate the app password
```
> Note: the flag form of `account edit` covers connection/auth fields and `--subject-regex`. To
> toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit --name X`
> with no other flags), or the `whitelist` commands in section 6.
```bash
emcli account edit work --from 'Work Team <you@yourdomain.com>' # set the send-as address
emcli account edit work --from '' # clear it (revert to username)
```
**Remove an account** (requires `--yes`):
> Note: the flag form of `account edit` covers connection/auth fields, `--from`, and
> `--subject-regex`. Passing `--from ''` clears the send-as address so mail falls back to the login
> username. To enable or disable whitelists use `whitelist enable`/`whitelist disable` (section 6).
**Remove an account** (requires `--yes` when stdin is not a terminal):
```bash
emcli account remove --name work --yes
emcli account remove work --yes
```
---
@@ -281,24 +301,30 @@ Set with `--mode RO|RW` on `account add`/`edit`. `RO` accounts reject every `sen
### Inbound whitelist
Enable it on the account (`--whitelist-in`, or the interactive form), then manage entries:
Manage entries, then enable filtering when ready:
```bash
emcli whitelist in add --account gmail --address boss@example.com
emcli whitelist in add --account gmail --address @partner.com
emcli whitelist in list --account gmail
emcli whitelist in remove --account gmail --address boss@example.com
emcli whitelist add gmail boss@example.com --in
emcli whitelist add gmail @partner.com --in
emcli whitelist list gmail --in
emcli whitelist remove gmail boss@example.com --in
emcli whitelist enable gmail --in # activate filtering
emcli whitelist disable gmail --in # suspend filtering without removing entries
```
`whitelist list` shows the current entries and whether filtering is ENABLED or DISABLED.
Enabling an empty whitelist warns you that all inbound mail will be blocked until you add addresses.
When enabled, the agent only sees mail from listed senders. Everything else is invisible.
### Outbound whitelist
Enable it (`--whitelist-out`), then manage entries the same way with `whitelist out`:
Same pattern with `--out`:
```bash
emcli whitelist out add --account gmail --address @example.com
emcli whitelist out list --account gmail
emcli whitelist add gmail @example.com --out
emcli whitelist list gmail --out
emcli whitelist enable gmail --out
```
When enabled, **every** recipient of a `send` (to + cc + bcc) must match an entry or the whole
@@ -315,10 +341,10 @@ send is blocked.
A regular expression on the account. If set, the agent only sees mail whose subject matches:
```bash
emcli account edit --name gmail --subject-regex '^\[ticket\]'
emcli account edit gmail --subject-regex '^\[ticket\]'
```
Clear it by setting it empty in the interactive form.
Clear it by passing an empty string (`--subject-regex ''`) or using the interactive form.
---
@@ -327,7 +353,7 @@ Clear it by setting it empty in the interactive form.
These are what the agent runs. Each prints exactly one JSON object (see section 8). They all take
`--account` and most take `--folder` (default `INBOX`).
> **Reading never changes state.** `list`, `get`, and `search` are read-only. Only `ack` advances
> **Reading never changes state.** `list`, `get`, `search`, and `folders` are read-only. Only `ack` advances
> "what's been processed."
### `list` — message headers
@@ -366,19 +392,44 @@ emcli search --account gmail --from boss@example.com
emcli search --account gmail --subject-contains invoice
emcli search --account gmail --text "quarterly report"
emcli search --account gmail --since-date 2026-01-01T00:00:00Z --before-date 2026-02-01T00:00:00Z
emcli search --account gmail --subject-contains invoice --all-folders # sweep every mailbox
```
| Flag | Meaning |
|---|---|
| `--from` | Sender contains |
| `--subject-contains` | Subject contains |
| `--text` | Full-text search |
| `--text` | Full-text search (best-effort — see below) |
| `--all-folders` | Search every selectable mailbox (mutually exclusive with `--folder`) |
| `--since-date` / `--before-date` | Date bounds, RFC 3339 (e.g. `2026-06-01T00:00:00Z`) |
| `--limit` | Max results (default `50`) |
Returns the same headers-only shape as `list`. Searches the whole folder, regardless of
new/acked state. Filtered (whitelisted-out) mail never appears.
With `--all-folders` each returned message also carries a `folder` field (UIDs are only unique
per folder — pass it back via `--folder` when using `get`/`ack` on a hit), and
`data.skipped_folders` lists any mailboxes the server refused to search. `--limit` caps total
results across the sweep.
`--text` depends on the server having a full-text index; some IMAP servers return zero results
for mail that `--subject-contains` finds. Prefer `--subject-contains`/`--from`; treat `--text` as
best-effort.
When a search returns no messages, `data.hint` suggests next steps (list folders, try
`--all-folders`, adjust criteria). The hint is deliberately generic: it is identical whether
matching mail is absent, in another folder, or hidden by the inbound whitelist.
### `folders` — list mailboxes
```bash
emcli folders --account gmail
```
Returns every mailbox the account can see: `name`, `delimiter`, and `selectable` (folders with
`selectable: false` are hierarchy placeholders and can't be used with `--folder`). INBOX sorts
first. Useful before searching mail that may be archived outside INBOX.
### `ack` — mark messages processed
```bash
@@ -421,6 +472,11 @@ emcli send --account gmail --to a@x.com --subject "Re: Hi" --body "thanks" \
client. The source is subject to the inbound whitelist — you can't reply to mail you aren't allowed
to see.
The message's `From:` is the account's send-as address (`--from`, set on `account add`/`edit`); if
none is configured it falls back to the login username. A display-name address like
`Steve Cliff <me@example.com>` shows the name in the recipient's client while the bare address is
used for the SMTP envelope.
---
## 8. The JSON envelope
@@ -484,7 +540,8 @@ sends and reads nothing.
```bash
emcli doctor # check every account
emcli doctor --account gmail # check just one
emcli doctor gmail # check just one (positional)
emcli doctor --account gmail # same — flag form also works
```
Example output:
@@ -511,11 +568,11 @@ server settings are right.
### Audit log
Every agent action (`list`, `get`, `search`, `ack`, `send`) — allowed or blocked — is recorded.
Every agent action (`list`, `get`, `search`, `folders`, `ack`, `send`) — allowed or blocked — is recorded.
```bash
emcli audit list # most recent 50
emcli audit list --account gmail # filter to one account
emcli audit list gmail # filter to one account
emcli audit list --limit 200
```
@@ -525,6 +582,7 @@ the reason. Secrets never appear here.
### Settings
```bash
emcli config list # list all settings and their current values
emcli config set audit_retention_days 90
emcli config get audit_retention_days
```
@@ -532,17 +590,20 @@ emcli config get audit_retention_days
- `audit_retention_days` — how long to keep audit rows. On each run, entries older than this are
purged. Must be a whole number ≥ 0. `0` or unset means no automatic purging.
`config list` prints every known setting, its current value, and a short description. Unknown keys
are rejected by `config get` and `config set`.
---
## 11. Troubleshooting
**"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). Agent commands (`list`, `get`, `search`, `ack`, `send`,
base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `folders`, `ack`, `send`,
`doctor`) need this key.
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
Admin commands (`account`, `whitelist`, `config`, `audit`, `init`) need this key; `EMCLI_KEY`
alone is not enough for them.
Admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) need this key;
`EMCLI_KEY` alone is not enough for them. (`account list` is the exception — an agent can run it.)
**A command fails to decrypt / wrong key.** The key doesn't match the one used when the database
was initialised. Restore the original key, or re-run `emcli init` (idempotent — it won't regenerate
@@ -555,11 +616,11 @@ the DEK if one already exists) with both correct keys, then re-add any accounts
`587`/`starttls` is a common alternative to `465`/`tls`.
**The agent can't see a message you know exists.** It's probably filtered: check the account's
inbound whitelist (`emcli whitelist in list --account NAME`) and subject filter. Filtered mail is
inbound whitelist (`emcli whitelist list NAME --in`) and subject filter. Filtered mail is
invisible by design.
**`send` is blocked.**
- `ro_mode` — the account is read-only. Change it: `emcli account edit --name NAME --mode RW`
- `ro_mode` — the account is read-only. Change it: `emcli account edit NAME --mode RW`
(and set SMTP details).
- `whitelist_out` — a recipient isn't on the outbound whitelist. Add it, or review the rule.
@@ -582,20 +643,23 @@ emcli <command> --help # usage and flags for one command
# Admin (requires EMCLI_ADMIN_KEY)
emcli init # create DB + add first account (form)
emcli account add [flags | none for form] # add an account
emcli account list # list accounts (no secrets)
emcli account edit --name N [flags | none for form] # change an account
emcli account remove --name N --yes # delete an account
emcli whitelist in|out add|remove|list --account N [--address A]
emcli account add [<name>] [flags] # add an account; no args → interactive form
emcli account list # full table (admin) / name+from+can_send JSON (agent)
emcli account show N # display one account's settings
emcli account edit N [flags | none for form] # change an account
emcli account remove N [--yes] # delete an account (--yes or TTY confirm)
emcli whitelist <add|remove|list|enable|disable> N [addr…] --in|--out
emcli config list # list all settings and descriptions
emcli config set|get <key> [value] # e.g. audit_retention_days
emcli audit list [--account N] [--limit K]
emcli audit list [account] [--limit K]
emcli version
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
emcli doctor [--account N] # connectivity/auth check
emcli doctor [N | --account N] # connectivity/auth check
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
emcli get --account N [--folder F] --uid U
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
emcli search --account N [--folder F | --all-folders] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
emcli folders --account N
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]]
```
+25
View File
@@ -0,0 +1,25 @@
Fixes identified from testing emcli email search (Morphy Richards purchase investigation).
1. No folder discovery command
emcli has no folders command, so the agent cannot enumerate available mailboxes. Searches default to INBOX only and miss archived mail (e.g. INBOX.Archive).
Fix: Add a folders agent command to list available mailboxes.
2. INBOX-only default too narrow
When searching for a specific email, the agent should ideally search across all folders, but cannot without knowing folder names.
Fix: Add an --all-folders flag to search to sweep all mailboxes at once.
3. --text full-text search unreliable on some IMAP servers
On myfamilyemail.co.uk, --text returned zero results for emails that --subject-contains found fine. The IMAP server either does not support full-text search or has an incomplete index.
Fix: Document this limitation in the skill; recommend --subject-contains as the primary search method and treat --text as best-effort.
4. No way to distinguish filtered vs missing vs wrong-folder
All three cases return empty results with no error, making it impossible for the agent to know whether mail is absent, hidden by a whitelist, or simply in another folder.
Fix: emcli (or the skill) should surface a diagnostic hint when results are empty — e.g. suggest running folders or retrying with --all-folders.
@@ -0,0 +1,497 @@
# Agent-readable `account list` Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an agent holding only `EMCLI_KEY` run `emcli account list` and get a reduced JSON view (name, from, can_send), while admin keeps the full text table and `account add/edit/remove` stay admin-only.
**Architecture:** Make `commandRole` subcommand-aware so `account list` routes to the agent role; branch the `list` renderer on whether the admin key is present (admin → existing text table; agent → standard `Success` JSON envelope). No schema change; `ListAccounts` already avoids decrypting secrets.
**Tech Stack:** Go, standard library (`flag`, `encoding/json`), existing `internal/cli` envelope helpers and `internal/crypto` key loaders.
## Global Constraints
- Agent output is the existing JSON envelope shape: `{"error":bool,"error_detail":{...},"data":{...}}` via `Success(...)` / `Failure(...)` in `internal/cli/envelope.go`.
- Admin `account list` output stays byte-for-byte the current human-readable table (`NAME MODE IMAP USER`).
- The agent (reduced) view exposes only `name`, `from`, `can_send` — never the IMAP host/port or login username.
- `from = Account.SendFrom()` (explicit `FromAddress`, else `Username`). `can_send = (Mode == "RW")`.
- `account add/edit/remove` remain admin-only (hard-require `EMCLI_ADMIN_KEY`, no fallback).
- Privilege detection: a caller is "admin" iff `crypto.AdminKeyFromEnv()` returns no error.
- Spec: `docs/superpowers/specs/2026-06-23-agent-account-list-design.md`.
---
### Task 1: Route `account list` to the agent role and render by privilege
**Files:**
- Modify: `internal/cli/run.go` (`commandRole`, its call site in `Run`)
- Modify: `internal/cli/admin.go` (the `list` branch of `runAccount`; add `crypto` import)
- Modify: `internal/cli/role_test.go` (`TestCommandRole`)
- Modify: `internal/cli/security_invariant_test.go` (refused-commands set)
- Create/Test: `internal/cli/account_list_test.go`
**Interfaces:**
- Consumes: `store.Account.SendFrom() string`, `store.Account.Mode string`, `store.Store.ListAccounts() ([]store.Account, error)`, `crypto.AdminKeyFromEnv() ([]byte, error)`, `Success(map[string]any) Envelope`, `Failure(code, msg string) Envelope`, `Envelope.Write(io.Writer) error`, test helpers `adminEnv(t)`, `run(t, args...)`.
- Produces: `commandRole(args []string) store.Role` (signature changes from `commandRole(cmd string)`). Agent `account list` emits `{"data":{"accounts":[{"name":string,"from":string,"can_send":bool}]}}`.
- [ ] **Step 1: Rewrite `TestCommandRole` for the new signature and subcommand routing**
Replace the body of `TestCommandRole` in `internal/cli/role_test.go` with:
```go
func TestCommandRole(t *testing.T) {
adminCmds := [][]string{
{"whitelist"}, {"config"}, {"audit"},
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"},
{"account", "list"},
}
for _, c := range adminCmds {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%v should be admin", c)
}
}
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%v should be agent", c)
}
}
}
```
Note: `init` is intentionally absent from this table. `commandRole({"init"})` falls through to the agent arm, but `Run` dispatches `init` via its own bootstrap path (which requires both keys), so its `commandRole` result is never used — asserting a role for it here would be both wrong and meaningless.
- [ ] **Step 2: Run the routing test to verify it fails to compile**
Run: `go test ./internal/cli/ -run TestCommandRole`
Expected: FAIL — compile error, `commandRole` takes `string`, called with `[]string`.
- [ ] **Step 3: Make `commandRole` subcommand-aware, update its call site, and fix the security invariant**
In `internal/cli/run.go`, replace:
```go
func commandRole(cmd string) store.Role {
switch cmd {
case "account", "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
```
with:
```go
func commandRole(args []string) store.Role {
switch args[0] {
case "account":
// account list is a read-only discovery view available to agents;
// add/edit/remove mutate config and require admin.
if len(args) >= 2 && args[1] == "list" {
return store.RoleAgent
}
return store.RoleAdmin
case "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
}
}
```
In `Run`, change the call site from `role := commandRole(cmd)` to `role := commandRole(args)`.
In `internal/cli/security_invariant_test.go`, in `TestAgentKeyCannotRunAdminCommands`, replace the `adminAttempts` entry `{"account", "list"}` so the set covers a *mutating* account command instead (account list is now allowed for agents):
```go
adminAttempts := [][]string{
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
```
- [ ] **Step 4: Run the cli package tests to verify routing + invariant pass**
Run: `go test ./internal/cli/ -run 'TestCommandRole|TestAgentKeyCannotRunAdminCommands'`
Expected: PASS (both). The agent can no longer be proven to refuse `account list` — that is intended; the invariant now proves `account add` is refused and the DB is unchanged.
- [ ] **Step 5: Add the rendering tests (agent JSON view + admin text view)**
Create `internal/cli/account_list_test.go`:
```go
package cli
import (
"encoding/json"
"strings"
"testing"
)
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
// name/from/can_send, and never the IMAP host or login username.
func TestAccountListAgentJSONView(t *testing.T) {
adminEnv(t) // both keys + initialized temp DB
run(t, "account", "add", "--name", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
run(t, "account", "add", "--name", "alerts", "--mode", "RO",
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
// Drop the admin key → caller is an agent.
t.Setenv("EMCLI_ADMIN_KEY", "")
code, out, errOut := run(t, "account", "list")
if code != 0 {
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
}
var env struct {
Error bool `json:"error"`
Data struct {
Accounts []struct {
Name string `json:"name"`
From string `json:"from"`
CanSend bool `json:"can_send"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
}
if env.Error || len(env.Data.Accounts) != 2 {
t.Fatalf("want 2 accounts and no error, got %+v", env)
}
// The reduced view must not leak the IMAP host or the login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("agent view leaked host/username:\n%s", out)
}
got := map[string]struct {
from string
canSend bool
}{}
for _, a := range env.Data.Accounts {
got[a.Name] = struct {
from string
canSend bool
}{a.From, a.CanSend}
}
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
}
// alerts has no --from → SendFrom() falls back to the username.
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
}
}
// With the admin key present, `account list` stays the full human-readable table.
func TestAccountListAdminTextView(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
code, out, _ := run(t, "account", "list")
if code != 0 {
t.Fatalf("admin account list failed: code=%d", code)
}
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
if !strings.Contains(out, want) {
t.Fatalf("admin view missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"accounts"`) {
t.Fatalf("admin view should be text, not JSON:\n%s", out)
}
}
```
- [ ] **Step 6: Run the rendering tests to verify the agent view fails**
Run: `go test ./internal/cli/ -run 'TestAccountListAgentJSONView|TestAccountListAdminTextView'`
Expected: `TestAccountListAdminTextView` PASS (already text); `TestAccountListAgentJSONView` FAIL — output is still the text table, so `json.Unmarshal` errors.
- [ ] **Step 7: Split the `list` branch by privilege in `runAccount`**
In `internal/cli/admin.go`, add the crypto import. Change the import block:
```go
import (
"flag"
"fmt"
"io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
```
Replace the `case "list":` block (currently):
```go
case "list":
accs, err := st.ListAccounts()
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
```
with:
```go
case "list":
// Holding the admin key means the caller is the human admin (full
// detail). An agent holds only EMCLI_KEY and gets a reduced JSON view.
_, adminErr := crypto.AdminKeyFromEnv()
isAdmin := adminErr == nil
accs, err := st.ListAccounts()
if err != nil {
if isAdmin {
fmt.Fprintf(errOut, "list: %v\n", err)
} else {
_ = Failure(CodeDB, err.Error()).Write(out)
}
return 1
}
if !isAdmin {
items := make([]map[string]any, 0, len(accs))
for _, a := range accs {
items = append(items, map[string]any{
"name": a.Name,
"from": a.SendFrom(),
"can_send": a.Mode == "RW",
})
}
_ = Success(map[string]any{"accounts": items}).Write(out)
return 0
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username)
}
return 0
```
- [ ] **Step 8: Run the full cli package test suite**
Run: `go test ./internal/cli/`
Expected: PASS (all tests, including the two new rendering tests, the routing test, and the security invariant).
- [ ] **Step 9: Run the whole module to confirm nothing else regressed**
Run: `go build ./... && go test ./...`
Expected: build clean; all packages PASS.
- [ ] **Step 10: Commit**
```bash
git add internal/cli/run.go internal/cli/admin.go internal/cli/role_test.go \
internal/cli/security_invariant_test.go internal/cli/account_list_test.go
git commit -m "feat(cli): agent-readable account list (reduced JSON view)
account list now routes to the agent role; an agent (EMCLI_KEY only) gets a
JSON envelope of name/from/can_send, while the admin keeps the full text
table. account add/edit/remove stay admin-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: Update user and agent documentation
**Files:**
- Modify: `USER-MANUAL.md` (command-kinds note, role table, cheatsheet)
- Modify: `skills/emcli/SKILL.md` (allowed-commands note, command table, do/don't)
- Modify: `skills/emcli/AGENTIC-MANUAL.md` (§4 account discovery)
**Interfaces:**
- Consumes: behavior shipped in Task 1 (agent `account list``{"data":{"accounts":[{name,from,can_send}]}}`).
- Produces: docs only; no code interface.
- [ ] **Step 1: USER-MANUAL — note that `account list` is the one agent-readable admin view**
In `USER-MANUAL.md`, in the "Two kinds of commands" block, change the Admin bullet (line ~36) from:
```
- **Admin commands** (`init`, `account`, `whitelist`, `config`, `audit`) require `EMCLI_ADMIN_KEY`
and are for *you*, the human. They print human-readable text or open an interactive form.
```
to:
```
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
interactive form. (`account list` is the one exception — it is also an agent command; see below.)
```
- [ ] **Step 2: USER-MANUAL — update the role table**
Replace the role table rows (lines ~127-128):
```
| `list`, `get`, `search`, `ack`, `send`, `doctor` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
```
with:
```
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
with only `EMCLI_KEY` (an agent) it prints a JSON envelope exposing just `name`, `from`, and
`can_send` — no host or login username.
```
- [ ] **Step 3: USER-MANUAL — annotate the cheatsheet**
In the cheatsheet (line ~597), change:
```
emcli account list # list accounts (no secrets)
```
to:
```
emcli account list # full table (admin) / name+from+can_send JSON (agent)
```
- [ ] **Step 4: SKILL.md — carve `account list` out of the forbidden-commands rule**
In `skills/emcli/SKILL.md`, change the first bullet (lines ~20-24):
```
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
with a privilege error.
```
to:
```
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
`init`. `emcli` will refuse those with a privilege error.
```
- [ ] **Step 5: SKILL.md — add `account list` to the command table**
In the command table (after the `send` row, line ~122), add:
```
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
```
- [ ] **Step 6: SKILL.md — fix the "don't" bullet**
Change (lines ~147-148):
```
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
```
to:
```
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
```
Also change the ✅ bullet (line ~145) from `Ask the user for the account name; keep bodies plain text.` to:
```
- ✅ Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
```
- [ ] **Step 7: AGENTIC-MANUAL — document discovery via `account list`**
In `skills/emcli/AGENTIC-MANUAL.md`, replace the body of `## 4. Find the account(s)` (lines ~88-97):
```
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
configured accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Just take the account name from the user and start with the workflow in `SKILL.md`.
```
with:
```
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
with one entry per account:
```bash
emcli account list
# {"error":false,"error_detail":{},"data":{"accounts":[
# {"name":"gmail","from":"me@gmail.com","can_send":true},
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
```
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
(also an agent command) checks that accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Then start with the workflow in `SKILL.md`.
```
- [ ] **Step 8: Sanity-check the docs render and reference reality**
Run: `grep -n "account list" USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md`
Expected: each file shows the updated `account list` references; no remaining text claims the agent cannot run `account list`.
- [ ] **Step 9: Commit**
```bash
git add USER-MANUAL.md skills/emcli/SKILL.md skills/emcli/AGENTIC-MANUAL.md
git commit -m "docs: agent can discover accounts via account list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
```
---
## Notes for the implementer
- Run all Go commands from the repo root (`/home/steve/src/emcli`).
- The two intentional red states are Step 2 (compile error) and Step 6 (agent JSON test) in Task 1. Every other test run must be green.
- Do not change the admin text table or add columns to it — admin output must stay identical.
- `adminEnv(t)` and `run(t, ...)` live in `internal/cli/admin_test.go`; `b64Key`/`b64AgentKey` in `internal/cli/run_test.go`. No new helpers are needed.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
# Agent-readable `account list` — design
**Date:** 2026-06-23
**Status:** Approved (brainstorm), ready for implementation plan
**Author:** Steve + Claude
## Problem
An agent process is launched with only `EMCLI_KEY` (the two-key model — see
`2026-06-22-two-key-privilege-design.md`). Every agent command takes
`--account NAME`, but the agent has no way to *discover* which accounts exist:
`account list` is classified admin-only and is refused under the agent key. So
account names must be supplied to the agent out of band, which is brittle and
defeats the point of a self-directed agent.
The two-key spec deliberately gated the whole `account` command to admin because
`account add/edit/remove` mutate configuration. But `account list` is read-only
and exposes no secrets — `store.ListAccounts` never decrypts the password
(`enc_password` is scanned and discarded). Gating *discovery* behind admin is
stricter than the threat model requires.
## Goal
Let an agent holding only `EMCLI_KEY` run `account list` to discover the
accounts it may operate on, while:
- keeping `account add/edit/remove` admin-only (mutation stays gated);
- exposing to the agent only what it needs — **account name, the send-as From
address, and whether the account can send** — and *not* the IMAP host/port or
login username;
- preserving the admin's existing full-detail view unchanged.
## Constraints / decisions
Settled during brainstorming:
1. **Scope is exactly `account list`.** `whitelist list`, `config get`, and
`audit list` stay admin-only. `audit` in particular is oversight data and
must remain invisible to the agent.
2. **Privilege-dependent rendering.** The admin keeps the current full table
(`NAME MODE IMAP USER`, human-readable). The agent gets a *reduced* view
containing only name, From, and send-capability.
3. **Agent output is JSON.** The agent is a machine consumer, so its
`account list` emits the standard agent envelope (like `list`/`get`/`search`),
not a text table. The admin path stays human-readable text.
4. **No secret exposure, no schema change.** `ListAccounts` already avoids
decrypting passwords; nothing about the data model changes.
## Approach
Reclassify `account list` to the agent role, and branch rendering on the
caller's actual privilege (presence of the admin key).
### Routing (`internal/cli/run.go`)
`commandRole` becomes subcommand-aware for `account`:
- `account list``RoleAgent`
- `account add | edit | remove` (and bare `account`) → `RoleAdmin`
- all other commands unchanged.
`commandRole` currently takes `cmd string`; it changes to take the full
`args []string` so it can peek at the `account` subcommand. `Run` passes
`args` through. This keeps `commandRole` the single source of truth for the
classification table.
Authorization mechanics are unchanged: `openStore(RoleAgent)` requires
`EMCLI_KEY` (falling back to the admin key for a human who holds only that and
runs `account list`). `account add/edit/remove` still hard-require
`EMCLI_ADMIN_KEY` with no fallback.
### Rendering (`internal/cli/admin.go`, the `list` branch)
Determine privilege from the environment: `_, err := crypto.AdminKeyFromEnv()`;
`isAdmin := err == nil`. (Holding the admin key *is* being the admin in this
trust model. A human with only the admin key still gets the admin view; an agent
with only `EMCLI_KEY` gets the reduced view.)
- **Admin** → existing full table, unchanged:
```
NAME MODE IMAP USER
work RW imap.example.com:993 me@example.com
```
- **Agent** → JSON envelope to stdout:
```json
{"error":false,"error_detail":{},"data":{"accounts":[
{"name":"work","from":"me@example.com","can_send":true},
{"name":"alerts","from":"alerts@example.com","can_send":false}
]}}
```
where `from = Account.SendFrom()` (the configured From address, falling back
to the username) and `can_send = (Mode == "RW")` (RW accounts have SMTP
configured; RO cannot send). The IMAP host/port and the raw login username are
**not** emitted (when From falls back to the username it may coincide with it,
which is acceptable — the user asked for From specifically).
The reduced view reuses the existing `Success(...)` envelope and `Envelope.Write`
helper; no new output machinery.
## Error handling
- Agent key on `account add/edit/remove` → unchanged:
`emcli: this command requires EMCLI_ADMIN_KEY (admin privilege)`, non-zero.
- Agent `account list` with neither key set → the agent-command config error
(`EMCLI_KEY is not set`), surfaced the same way other agent commands surface a
missing key. Because the agent path now emits JSON, a missing-key failure on
`account list` is a JSON `Failure(CodeConfig, …)` envelope, consistent with the
other agent commands.
- DB/list errors on the agent path → `Failure(CodeDB, …)` envelope; on the admin
path → existing `list: <err>` text to stderr.
## Testing
- **Routing:** extend the `commandRole` table test — `account list` → agent;
`account add` / `account edit` / `account remove` → admin.
- **Agent view:** with only `EMCLI_KEY`, `account list` exits 0, emits a valid
envelope, and the `data.accounts` entries carry `name`/`from`/`can_send` — and
the output does **not** contain the IMAP host or the login username.
- **Admin view:** with `EMCLI_ADMIN_KEY`, `account list` still prints the full
`NAME MODE IMAP USER` table (regression guard).
- **`can_send`:** an RW account yields `can_send:true`, an RO account
`can_send:false`; `from` reflects `SendFrom()` (explicit From, else username).
- **Security invariant (`security_invariant_test.go`):** remove
`{"account","list"}` from the refused-commands set (it is now allowed) and
replace it with a *mutating* `account add …` attempt, so the "forced agent
cannot run admin commands and the DB is byte-for-byte unchanged" invariant
still covers the `account` family.
## Documentation updates
- **USER-MANUAL:** role/command table — `account list` is agent-readable
(reduced JSON view); `account add/edit/remove` remain admin.
- **`skills/emcli` (SKILL.md / AGENTIC-MANUAL.md):** document that the agent
discovers accounts via `account list`, including the JSON shape
(`name`, `from`, `can_send`).
## Out of scope
- Agent access to `whitelist list`, `config get`, or `audit list`.
- Any change to the admin `account list` columns or to the data model.
- JSON output for the admin `account list` path (stays human-readable text).
@@ -0,0 +1,201 @@
# emcli — Human-facing CLI grammar redesign
Date: 2026-06-27
Status: Approved (design)
## Problem
`emcli` was designed flag-first for AI agents — explicit, stable, machine-parseable. The
human-facing **admin** commands inherited that flag-heavy grammar, and the result is inconsistent
and error-prone for a person at a terminal. The "main noun" of each command is sometimes
positional, sometimes a flag:
| Command (today) | Names its target via |
|---|---|
| `config set audit_retention_days 90` | positional key + value |
| `account remove --name bobby --yes` | flag |
| `whitelist in add --account bobby --address x@y` | flags (×2) |
| `audit list --account bobby` | flag |
A human has no rule to remember. The concrete failure that triggered this work:
```
emcli whitelist in add --account bobby "Tk555@protonmail.com"
```
Go's `flag` parser read `--account bobby`, treated `"Tk555@protonmail.com"` as an ignored leftover
positional, and `--address` defaulted to `""`. `AddWhitelist` then ran
`INSERT OR IGNORE ... VALUES(id, "")` — silently inserting a blank whitelist row. No error, no
validation.
## Goals
- One consistent, predictable grammar for all human-facing (admin) commands.
- Eliminate the silently-ignored-input and empty-operand bug classes.
- Improve discoverability (`config list`, `account show`) and onboarding (whitelists fully
decoupled from account creation).
- Keep the agent JSON command surface (`list`/`get`/`search`/`ack`/`send`) **frozen**.
## Non-goals
- No change to agent JSON commands' flags, output, or behavior.
- No change to the encryption/key model, store schema semantics, or policy engine logic.
- No unrelated refactoring.
## Unifying grammar
> **`emcli <group> <verb> <operands…> [--flags]`**
>
> Primary targets — account name, address(es), config key/value — are **positional**.
> Flags carry only options/modifiers (`--in`/`--out`, `--yes`, `--limit`, account field values
> like `--imap-host`).
Two cross-cutting safety rules enforced everywhere:
1. **No silently-ignored input.** Any leftover positional argument beyond what a command expects
is an error (`unexpected argument "x"`). This alone prevents the triggering bug.
2. **No empty primary operands.** Empty account / address / key is rejected with a clear message.
### Verb aliases
Applied at the verb position for every group (and the top-level command position, additively):
- `remove` = `rm` = `del`
- `list` = `ls`
Implemented as a single `normalizeVerb(string) string` map applied before the verb `switch`.
## Command surface (after)
### account
| Command | Notes |
|---|---|
| `account add [name] [--imap-host … --username … …]` | `name` positional. No flags → interactive TUI form (unchanged). **`--whitelist-in`/`--whitelist-out` removed** (see Whitelist decoupling). `--process-backlog` kept. |
| `account edit <name> [--flags]` | `name` positional; only `name` → interactive form prefilled. **`--whitelist-in`/`--whitelist-out` removed.** |
| `account remove <name> [--yes]` | `name` positional. On a TTY, prompt `Remove account "bobby"? [y/N]`; non-TTY (piped) requires `--yes`. Aliases: `rm`, `del`. |
| `account show <name>` | **New.** Human-readable detail: mode (RO/RW), IMAP host/port/security, SMTP host/port/security, send-from address, subject regex, inbound/outbound whitelist enabled state. Never prints the password. |
| `account list` | Unchanged table. Alias: `ls`. |
### whitelist
Direction is a **required** `--in` / `--out` flag (error if neither given — no wrong-list default).
Whitelist management is fully self-contained in this group (enable/disable moved here from
`account edit`).
| Command | Notes |
|---|---|
| `whitelist add <account> <addr…> --in\|--out` | One or more addresses, each validated (see Address validation). Aliases for verb: n/a. |
| `whitelist remove <account> <addr…> --in\|--out` | One or more addresses. Aliases: `rm`, `del`. |
| `whitelist list <account> --in\|--out` | Header line shows `ENABLED`/`DISABLED`. Alias: `ls`. |
| `whitelist enable <account> --in\|--out` | **New.** If the list is empty, print a warning: `warning: inbound whitelist for "bobby" is empty — this blocks ALL inbound mail until you add addresses`. Still enables (explicit user action). |
| `whitelist disable <account> --in\|--out` | **New.** |
### config
A known-key **settings registry** backs validation and discovery. Each entry: `key`,
`description`, and a `validate(value) error`.
| Command | Notes |
|---|---|
| `config list` | **New.** Table: `KEY VALUE DESCRIPTION`. Lists every registered key, its current value (or `(unset)`), and description. Alias: `ls`. |
| `config get <key>` | Rejects unknown key. |
| `config set <key> <value>` | Rejects unknown key (`unknown setting "foo" (see: emcli config list)`); runs the key's validator. |
Initial registry: `audit_retention_days` (description "Days to keep audit-log entries"; validator:
integer ≥ 0 — same rule as today).
### audit
| Command | Notes |
|---|---|
| `audit list [account] [--limit N]` | `account` positional (optional; omitted = all accounts). `--limit` stays a flag (it's a modifier). Alias: `ls`. |
### doctor (exception — dual-use)
`doctor` is agent-runnable (`RoleAgent`) and the agentic manual documents `emcli doctor --account
gmail`. To avoid breaking documented agent usage while still giving humans the positional form, it
accepts **both**:
| Command | Notes |
|---|---|
| `doctor [account]` | Positional account (human shorthand). |
| `doctor --account <name>` | Retained for agent/script compatibility. If both a positional and `--account` are given and they differ, that's an error. |
### Frozen (untouched)
`list`, `get`, `search`, `ack`, `send``--account`/`--folder`/`--uid…` flags, JSON envelope
output. **Intentional split**, documented in help: agent commands are flag-driven for parse
stability; human admin commands are positional. `init`, `version`, `help` unchanged (except `init`
no longer references whitelists).
## Whitelist decoupling from onboarding
Verified against the code: `WhitelistInEnabled`/`WhitelistOutEnabled` are stored per-account
booleans, default `false`, read fresh on every command via `Deps.setup` (which rebuilds
`policy.InboundRule` each call) and `SendCmd`. `policy.InboundRule.Allows` / `OutboundRule.Check`
only consult them at evaluation time. Nothing in account creation, `init`, or the policy engine
needs them set at creation time, and toggling them later takes effect on the next command.
Therefore: remove `--whitelist-in` / `--whitelist-out` from `account add`, from `init`'s flow, and
from the interactive TUI account form (the `Whitelist inbound`/`Whitelist outbound` toggles in
`internal/tui/account.go`) entirely — leaving them in the form would contradict the decoupling and
re-expose the empty-enabled-whitelist footgun. Accounts are created with whitelists off; the user
sets them up afterward:
```
emcli whitelist add bobby alice@example.com bob@example.com --in
emcli whitelist enable bobby --in
```
Default (whitelist off) is the existing permissive default; RO/RW mode and subject regex still
apply regardless.
## Address validation
`whitelist add` / `whitelist remove` validate each address argument before touching the store.
Accepted shapes:
- A full address: `local@domain` (non-empty local part, a domain with at least one dot).
- A domain wildcard: `@domain` (leading `@`, domain with at least one dot).
Anything else (empty, no `@`, no domain) is rejected: `invalid address "x": expected
local@domain or @domain`. Validation lives in a small reusable helper so `add` and `remove` share
it. (Addresses are still lowercased on store as today.)
## Error & output conventions (unchanged where already good)
- Usage/errors → stderr; successful human output → stdout.
- Exit codes: `0` success, `1` runtime error, `2` usage error (preserved).
- Admin commands remain human-readable (never JSON). `account list` for an agent (agent key only)
still emits the reduced JSON view — unchanged.
## Implementation surface
- `internal/cli/admin.go` — rewrite arg parsing for `account`, `whitelist`, `config`, `audit` to
the positional grammar; add `account show`, `whitelist enable/disable`, `config list`; add the
leftover-positional and empty-operand guards; wire `normalizeVerb`.
- `internal/cli/run.go``doctor` accepts positional + `--account`; routing unchanged otherwise.
- `internal/cli/interactive.go` (`runInit`) — drop whitelist references.
- `internal/tui/account.go` — remove the `WhitelistIn`/`WhitelistOut` form fields, their rows,
and their render/parse/`ToAccount`/`FieldsFromAccount` handling (new accounts are created with
whitelists off; managed via the `whitelist` group thereafter).
- `internal/cli/help.go` — rewrite admin synopses to the new grammar; document the
agent-vs-human positional/flag split; list aliases.
- New: settings registry (small map + validators) — location `internal/cli` (e.g.
`config_registry.go`) unless it needs store access, in which case `internal/store`.
- New: address validator helper (e.g. `internal/cli` or `internal/policy`, near existing address
matching).
- `internal/store` — add `SetWhitelistEnabled(account, dir, bool)` (or equivalent) if not already
expressible via existing account update; `account show` reads existing getters.
- TTY detection helper for `account remove` confirmation (e.g. `term.IsTerminal` on stdin fd).
- Docs: `USER-MANUAL.md`, `skills/emcli/AGENTIC-MANUAL.md` (the `doctor --account` example stays
valid; verify wording), `README.md` getting-started (no whitelist at init).
- Tests: `admin_test.go`, `run_test.go`, plus new tests for aliases, leftover-arg rejection,
empty-operand rejection, address validation, `config list`/unknown-key, `whitelist
enable/disable` (incl. empty-list warning), `account show`, and `account remove` confirm path.
## What breaks (accepted)
Every current admin invocation using `--account` / `--name` / `--address` on `account`,
`whitelist`, `config` (positional already), `audit`, plus `account edit --whitelist-in/out` and
`account add --whitelist-in/out`. The agent path (skill + installer drive only the frozen JSON
commands; `doctor --account` retained) is unaffected.
## Open questions
None outstanding — all design forks resolved with the user.
+1 -1
View File
@@ -10,6 +10,7 @@ require (
github.com/emersion/go-message v0.18.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/mattn/go-isatty v0.0.20
modernc.org/sqlite v1.53.0
)
@@ -27,7 +28,6 @@ require (
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+85
View File
@@ -0,0 +1,85 @@
package cli
import (
"encoding/json"
"strings"
"testing"
)
// With only EMCLI_KEY set, `account list` emits the reduced JSON envelope:
// name/from/can_send, and never the IMAP host or login username.
func TestAccountListAgentJSONView(t *testing.T) {
adminEnv(t) // both keys + initialized temp DB
run(t, "account", "add", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
run(t, "account", "add", "alerts", "--mode", "RO",
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
// Drop the admin key → caller is an agent.
t.Setenv("EMCLI_ADMIN_KEY", "")
code, out, errOut := run(t, "account", "list")
if code != 0 {
t.Fatalf("agent account list should succeed: code=%d err=%q", code, errOut)
}
var env struct {
Error bool `json:"error"`
Data struct {
Accounts []struct {
Name string `json:"name"`
From string `json:"from"`
CanSend bool `json:"can_send"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("output is not the agent envelope: %v\n%s", err, out)
}
if env.Error || len(env.Data.Accounts) != 2 {
t.Fatalf("want 2 accounts and no error, got %+v", env)
}
// The reduced view must not leak the IMAP host or the login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("agent view leaked host/username:\n%s", out)
}
got := map[string]struct {
from string
canSend bool
}{}
for _, a := range env.Data.Accounts {
got[a.Name] = struct {
from string
canSend bool
}{a.From, a.CanSend}
}
if g := got["work"]; g.from != "me@example.com" || !g.canSend {
t.Errorf("work: want from=me@example.com can_send=true, got %+v", g)
}
// alerts has no --from → SendFrom() falls back to the username.
if g := got["alerts"]; g.from != "alerts@example.com" || g.canSend {
t.Errorf("alerts: want from=alerts@example.com can_send=false, got %+v", g)
}
}
// With the admin key present, `account list` stays the full human-readable table.
func TestAccountListAdminTextView(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
code, out, _ := run(t, "account", "list")
if code != 0 {
t.Fatalf("admin account list failed: code=%d", code)
}
for _, want := range []string{"NAME", "MODE", "IMAP", "USER", "imap.example.com:993", "login@example.com"} {
if !strings.Contains(out, want) {
t.Fatalf("admin view missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"accounts"`) {
t.Fatalf("admin view should be text, not JSON:\n%s", out)
}
}
+52
View File
@@ -0,0 +1,52 @@
package cli
import (
"fmt"
"io"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// accountShow renders one account's configuration. The password is never shown.
func accountShow(st *store.Store, rest []string, out, errOut io.Writer) int {
if len(rest) == 0 {
fmt.Fprintln(errOut, "usage: emcli account show <name>")
return 2
}
if len(rest) > 1 {
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[1])
return 2
}
a, err := st.GetAccount(rest[0])
if err != nil {
fmt.Fprintf(errOut, "show: %v\n", err)
return 1
}
onOff := func(b bool) string {
if b {
return "enabled"
}
return "disabled"
}
smtp := "-"
if a.SMTPHost != "" {
smtp = fmt.Sprintf("%s:%d (%s)", a.SMTPHost, a.SMTPPort, a.SMTPSecurity)
}
subj := a.SubjectRegex
if subj == "" {
subj = "(none)"
}
// Labels padded to the width of the longest ("outbound whitelist:") so
// every value aligns in one column.
const lbl = "%-19s %s\n"
fmt.Fprintf(out, lbl, "name:", a.Name)
fmt.Fprintf(out, lbl, "mode:", a.Mode)
fmt.Fprintf(out, lbl, "imap:", fmt.Sprintf("%s:%d (%s)", a.IMAPHost, a.IMAPPort, a.IMAPSecurity))
fmt.Fprintf(out, lbl, "smtp:", smtp)
fmt.Fprintf(out, lbl, "username:", a.Username)
fmt.Fprintf(out, lbl, "send-from:", a.SendFrom())
fmt.Fprintf(out, lbl, "subject filter:", subj)
fmt.Fprintf(out, lbl, "inbound whitelist:", onOff(a.WhitelistInEnabled))
fmt.Fprintf(out, lbl, "outbound whitelist:", onOff(a.WhitelistOutEnabled))
return 0
}
+36
View File
@@ -0,0 +1,36 @@
package cli
import (
"strings"
"testing"
)
func TestAccountShow(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "shown", "--mode", "RW",
"--imap-host", "imap.x.com", "--imap-port", "993",
"--smtp-host", "smtp.x.com", "--smtp-port", "465",
"--username", "u@x.com", "--password", "secret", "--from", "me@x.com")
code, out, _ := run(t, "account", "show", "shown")
if code != 0 {
t.Fatalf("show exit=%d", code)
}
for _, want := range []string{"shown", "RW", "imap.x.com:993", "smtp.x.com:465", "u@x.com", "me@x.com"} {
if !strings.Contains(out, want) {
t.Fatalf("show missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "secret") {
t.Fatalf("show must never print the password:\n%s", out)
}
}
func TestAccountShowMissingName(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "account", "show"); code != 2 {
t.Fatal("show without a name must be a usage error")
}
if code, _, _ := run(t, "account", "show", "nope"); code == 0 {
t.Fatal("show of a missing account must be non-zero")
}
}
+269 -94
View File
@@ -1,41 +1,66 @@
package cli
import (
"bufio"
"flag"
"fmt"
"io"
"strconv"
"os"
"strings"
"github.com/mattn/go-isatty"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
"git.dcglab.co.uk/steve/emcli/internal/policy"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
// confirmRemoval prompts on a TTY for a y/N answer. Non-TTY callers never reach
// here (the caller requires --yes when stdin is not a terminal).
func confirmRemoval(name string, out io.Writer) bool {
fmt.Fprintf(out, "Remove account %q? [y/N]: ", name)
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
// runAccount handles `account <add|edit|remove|show|list>`. Human-readable
// output (except the agent-only reduced-JSON branch of `list`).
func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "account")
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, list")
fmt.Fprintln(out, "\nSubcommands: add, edit, remove, show, list")
if len(args) > 0 {
return 0 // explicit --help
return 0
}
return 2
}
sub, rest := args[0], args[1:]
sub := normalizeVerb(args[0])
rest := args[1:]
st, err := openStore(role)
if err != nil {
if sub == "list" {
_ = Failure(CodeConfig, err.Error()).Write(out)
} else {
fmt.Fprintf(errOut, "emcli: %v\n", err)
}
return 1
}
defer st.Close()
switch sub {
case "add":
if len(rest) == 0 { // no flags → interactive TUI form
if len(rest) == 0 { // no args → interactive TUI form
return addInteractive(st, tui.Fields{}, out, errOut)
}
// Peel a leading positional name (if present) before flag parsing.
var name string
if !strings.HasPrefix(rest[0], "-") {
name, rest = rest[0], rest[1:]
}
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name")
mode := fs.String("mode", "RO", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 993, "IMAP port")
@@ -47,14 +72,16 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
pass := fs.String("password", "", "login password")
from := fs.String("from", "", "send-as address (blank = use username)")
subj := fs.String("subject-regex", "", "inbound subject filter")
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
backlog := fs.Bool("process-backlog", false, "treat existing mail as new")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" || *host == "" || *user == "" {
fmt.Fprintln(errOut, "name, imap-host, and username are required")
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
if name == "" || *host == "" || *user == "" {
fmt.Fprintln(errOut, "name, --imap-host, and --username are required")
return 2
}
if err := tui.ValidFromAddress(*from); err != nil {
@@ -62,26 +89,31 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
return 2
}
acc := store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
Name: name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass,
FromAddress: *from,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog,
FromAddress: *from, SubjectRegex: *subj, ProcessBacklog: *backlog,
}
if *mode == "RW" {
acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec
}
_, err := st.AddAccount(acc)
if err != nil {
if _, err := st.AddAccount(acc); err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
fmt.Fprintf(out, "account %q added (%s)\n", name, *mode)
return 0
case "edit":
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
fmt.Fprintln(errOut, "usage: emcli account edit <name> [flags]")
return 2
}
name := rest[0]
flagArgs := rest[1:]
if len(flagArgs) == 0 { // only name → interactive prefilled form
return editInteractive(st, name, out, errOut)
}
fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
mode := fs.String("mode", "", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 0, "IMAP port")
@@ -93,26 +125,22 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
pass := fs.String("password", "", "login password (blank keeps existing)")
from := fs.String("from", "", "send-as address (empty reverts to username)")
subj := fs.String("subject-regex", "", "inbound subject filter")
if err := fs.Parse(rest); err != nil {
if err := fs.Parse(flagArgs); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut)
}
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc, err := st.GetAccount(*name)
acc, err := st.GetAccount(name)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
// Overlay only the flags the user actually set.
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "mode":
@@ -139,43 +167,76 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
acc.SubjectRegex = *subj
}
})
// acc.Password holds the existing (decrypted) password from GetAccount; the
// Visit above overwrites it only when --password was passed. UpdateAccount
// re-seals whatever non-empty value is present, so the password is preserved.
// GetAccount loaded the existing decrypted password into acc; fs.Visit
// overwrites acc.Password only when --password was passed; UpdateAccount
// re-seals whatever non-empty password is present, so omitting --password
// on edit preserves the existing password unchanged.
if err := st.UpdateAccount(acc); err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q updated\n", *name)
fmt.Fprintf(out, "account %q updated\n", name)
return 0
case "remove":
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest); err != nil {
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
fmt.Fprintln(errOut, "usage: emcli account remove <name> [--yes]")
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
name := rest[0]
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest[1:]); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
if !*yes {
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
if !isatty.IsTerminal(os.Stdin.Fd()) {
fmt.Fprintf(errOut, "refusing to remove %q without --yes (no terminal for confirmation)\n", name)
return 2
}
if err := st.DeleteAccount(*name); err != nil {
if !confirmRemoval(name, out) {
fmt.Fprintln(out, "aborted")
return 1
}
}
if err := st.DeleteAccount(name); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q removed\n", *name)
fmt.Fprintf(out, "account %q removed\n", name)
return 0
case "show":
return accountShow(st, rest, out, errOut)
case "list":
if len(rest) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0])
return 2
}
_, adminErr := crypto.AdminKeyFromEnv()
isAdmin := adminErr == nil
accs, err := st.ListAccounts()
if err != nil {
if isAdmin {
fmt.Fprintf(errOut, "list: %v\n", err)
} else {
_ = Failure(CodeDB, err.Error()).Write(out)
}
return 1
}
if !isAdmin {
items := make([]map[string]any, 0, len(accs))
for _, a := range accs {
items = append(items, map[string]any{
"name": a.Name, "from": a.SendFrom(), "can_send": a.Mode == "RW",
})
}
_ = Success(map[string]any{"accounts": items}).Write(out)
return 0
}
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER")
for _, a := range accs {
fmt.Fprintf(out, "%-16s %-4s %-28s %s\n",
@@ -183,7 +244,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
}
return 0
default:
fmt.Fprintf(errOut, "unknown account subcommand %q\n", sub)
fmt.Fprintf(errOut, "unknown account subcommand %q (want add|edit|remove|show|list)\n", sub)
return 2
}
}
@@ -203,7 +264,7 @@ func auditList(st *store.Store, account string, limit int, out io.Writer) error
return nil
}
// runConfig handles `config set <key> <value>` and `config get <key>`.
// runConfig handles `config <list|get|set>` against the settings registry.
func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "config")
@@ -212,11 +273,8 @@ func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
}
return 2
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
return 2
}
sub, key := args[0], args[1]
sub := normalizeVerb(args[0])
rest := args[1:]
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
@@ -225,16 +283,51 @@ func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
defer st.Close()
switch sub {
case "list":
if len(rest) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0])
return 2
}
fmt.Fprintf(out, "%-22s %-8s %s\n", "KEY", "VALUE", "DESCRIPTION")
for _, k := range settingKeys() {
v, err := st.GetSetting(k)
if err != nil {
v = "(unset)"
}
fmt.Fprintf(out, "%-22s %-8s %s\n", k, v, settingsRegistry[k].desc)
}
return 0
case "get":
if len(rest) != 1 {
fmt.Fprintln(errOut, "usage: emcli config get <key>")
return 2
}
key := rest[0]
if _, ok := settingsRegistry[key]; !ok {
fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
return 2
}
v, err := st.GetSetting(key)
if err != nil {
fmt.Fprintf(errOut, "config get: %s not set\n", key)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, v)
return 0
case "set":
if len(args) < 3 {
if len(rest) != 2 {
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
return 2
}
value := args[2]
if key == "audit_retention_days" {
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value)
key, value := rest[0], rest[1]
def, ok := settingsRegistry[key]
if !ok {
fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key)
return 2
}
if def.validate != nil {
if err := def.validate(value); err != nil {
fmt.Fprintf(errOut, "%s %v\n", key, err)
return 2
}
}
@@ -244,35 +337,36 @@ func runConfig(args []string, role store.Role, out, errOut io.Writer) int {
}
fmt.Fprintf(out, "%s = %s\n", key, value)
return 0
case "get":
v, err := st.GetSetting(key)
if err != nil {
fmt.Fprintf(errOut, "config get: %s not set\n", key)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, v)
return 0
default:
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub)
fmt.Fprintf(errOut, "unknown config subcommand %q (want list|get|set)\n", sub)
return 2
}
}
// runAudit handles `audit list [--account <name>] [--limit N]`.
// runAudit handles `audit list [account] [--limit N]`.
func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) > 0 && helpRequested(args[0]) {
printCmdUsage(out, "audit")
return 0
}
if len(args) == 0 || args[0] != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
if len(args) == 0 || normalizeVerb(args[0]) != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [account] [--limit N]")
return 2
}
// Peel an optional positional account before flag parsing.
rest := args[1:]
var account string
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
account, rest = rest[0], rest[1:]
}
fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "filter by account")
limit := fs.Int("limit", 50, "max rows")
if err := fs.Parse(args[1:]); err != nil {
if err := fs.Parse(rest); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
st, err := openStore(role)
@@ -281,14 +375,23 @@ func runAudit(args []string, role store.Role, out, errOut io.Writer) int {
return 1
}
defer st.Close()
if err := auditList(st, *account, *limit, out); err != nil {
if err := auditList(st, account, *limit, out); err != nil {
fmt.Fprintf(errOut, "audit list: %v\n", err)
return 1
}
return 0
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
// flowName renders a direction for human-facing prose.
func flowName(dir store.Direction) string {
if dir == store.DirOut {
return "outbound"
}
return "inbound"
}
// runWhitelist handles `whitelist <add|remove|list|enable|disable> <account>
// [addr…] --in|--out`.
func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
if len(args) == 0 || helpRequested(args[0]) {
printCmdUsage(out, "whitelist")
@@ -297,23 +400,42 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
}
return 2
}
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
sub := normalizeVerb(args[0])
switch sub {
case "add", "remove", "list", "enable", "disable": // valid
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list|enable|disable)\n", sub)
return 2
}
dir := store.Direction(args[0])
sub, rest := args[1], args[2:]
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "account name")
address := fs.String("address", "", "email or @domain")
if err := fs.Parse(rest); err != nil {
// Split the remaining tokens into the direction flag and positionals.
var dir store.Direction
var dirSet bool
var pos []string
for _, a := range args[1:] {
switch a {
case "--in", "-in":
dir, dirSet = store.DirIn, true
case "--out", "-out":
dir, dirSet = store.DirOut, true
default:
if strings.HasPrefix(a, "-") {
fmt.Fprintf(errOut, "unknown flag %q (use --in or --out)\n", a)
return 2
}
if *account == "" {
fmt.Fprintln(errOut, "--account is required")
pos = append(pos, a)
}
}
if !dirSet {
fmt.Fprintln(errOut, "direction is required: pass --in or --out")
return 2
}
if len(pos) == 0 {
fmt.Fprintln(errOut, "account is required")
return 2
}
account, addrs := pos[0], pos[1:]
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
@@ -322,30 +444,83 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
defer st.Close()
switch sub {
case "add":
if err := st.AddWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "add: %v\n", err)
case "add", "remove":
if len(addrs) == 0 {
fmt.Fprintln(errOut, "at least one address is required")
return 2
}
for _, addr := range addrs {
if err := policy.ValidWhitelistAddress(addr); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
}
for _, addr := range addrs {
if sub == "add" {
err = st.AddWhitelist(account, dir, addr)
} else {
err = st.RemoveWhitelist(account, dir, addr)
}
if err != nil {
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
return 1
}
fmt.Fprintf(out, "added %s to %s whitelist of %q\n", *address, dir, *account)
case "remove":
if err := st.RemoveWhitelist(*account, dir, *address); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "removed %s\n", *address)
verb := "added"
if sub == "remove" {
verb = "removed"
}
fmt.Fprintf(out, "%s %d address(es) in the %s whitelist of %q\n", verb, len(addrs), dir, account)
return 0
case "list":
addrs, err := st.ListWhitelist(*account, dir)
if len(addrs) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
return 2
}
acc, err := st.GetAccount(account)
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
for _, a := range addrs {
enabled := acc.WhitelistInEnabled
if dir == store.DirOut {
enabled = acc.WhitelistOutEnabled
}
state := "DISABLED"
if enabled {
state = "ENABLED"
}
entries, err := st.ListWhitelist(account, dir)
if err != nil {
fmt.Fprintf(errOut, "list: %v\n", err)
return 1
}
fmt.Fprintf(out, "%s whitelist of %q: %s\n", dir, account, state)
for _, a := range entries {
fmt.Fprintln(out, a)
}
default:
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
return 0
case "enable", "disable":
if len(addrs) > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
return 2
}
enable := sub == "enable"
if enable {
if entries, _ := st.ListWhitelist(account, dir); len(entries) == 0 {
fmt.Fprintf(errOut, "warning: %s whitelist for %q is empty — this blocks ALL %s mail until you add addresses\n", dir, account, flowName(dir))
}
}
if err := st.SetWhitelistEnabled(account, dir, enable); err != nil {
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
return 1
}
state := "disabled"
if enable {
state = "enabled"
}
fmt.Fprintf(out, "%s whitelist of %q %s\n", dir, account, state)
return 0
}
return 0
}
+120 -14
View File
@@ -52,18 +52,40 @@ func TestConfigSetGet(t *testing.T) {
func TestConfigSetRejectsBadRetention(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code == 0 {
t.Fatal("negative retention must be rejected")
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code != 2 {
t.Fatal("negative retention must be a usage error")
}
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code == 0 {
t.Fatal("non-integer retention must be rejected")
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code != 2 {
t.Fatal("non-integer retention must be a usage error")
}
}
func TestConfigRejectsUnknownKey(t *testing.T) {
adminEnv(t)
if code, _, e := run(t, "config", "set", "bogus", "1"); code != 2 || !strings.Contains(e, "unknown setting") {
t.Fatalf("set unknown key: code=%d err=%q", code, e)
}
if code, _, e := run(t, "config", "get", "bogus"); code != 2 || !strings.Contains(e, "unknown setting") {
t.Fatalf("get unknown key: code=%d err=%q", code, e)
}
}
func TestConfigList(t *testing.T) {
adminEnv(t)
run(t, "config", "set", "audit_retention_days", "42")
code, out, _ := run(t, "config", "ls") // alias
if code != 0 {
t.Fatalf("config ls exit=%d", code)
}
if !strings.Contains(out, "audit_retention_days") || !strings.Contains(out, "42") || !strings.Contains(out, "KEY") {
t.Fatalf("config list output wrong:\n%s", out)
}
}
func TestAccountRemove(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "--name", "gone", "--yes"); code != 0 {
run(t, "account", "add", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "gone", "--yes"); code != 0 {
t.Fatalf("remove failed: %s", e)
}
_, out, _ := run(t, "account", "list")
@@ -74,17 +96,26 @@ func TestAccountRemove(t *testing.T) {
func TestAccountRemoveMissing(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "account", "remove", "--name", "nope", "--yes"); code == 0 {
if code, _, _ := run(t, "account", "remove", "nope", "--yes"); code == 0 {
t.Fatal("removing a missing account must be non-zero")
}
}
func TestAccountRemoveNoTTYNeedsYes(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "keep", "--imap-host", "h", "--username", "u@x.com")
// Under `go test`, stdin is not a TTY, so without --yes this must refuse.
code, _, errOut := run(t, "account", "remove", "keep")
if code != 2 || !strings.Contains(errOut, "--yes") {
t.Fatalf("non-TTY remove without --yes must refuse: code=%d err=%q", code, errOut)
}
}
func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
db := adminEnv(t)
run(t, "account", "add", "--name", "ed", "--mode", "RO",
run(t, "account", "add", "ed", "--mode", "RO",
"--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig")
// Edit only mode + add SMTP; imap-host, username, password must be preserved.
if code, _, e := run(t, "account", "edit", "--name", "ed", "--mode", "RW",
if code, _, e := run(t, "account", "edit", "ed", "--mode", "RW",
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e)
}
@@ -110,6 +141,67 @@ func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
}
}
func TestWhitelistRequiresDirection(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
code, _, errOut := run(t, "whitelist", "add", "bobby", "a@x.com")
if code != 2 || !strings.Contains(errOut, "--in") || !strings.Contains(errOut, "--out") {
t.Fatalf("missing direction must name --in/--out: code=%d err=%q", code, errOut)
}
}
func TestWhitelistAddListRemove(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "whitelist", "add", "bobby", "a@x.com", "@y.com", "--out"); code != 0 {
t.Fatalf("add failed: %s", e)
}
code, out, _ := run(t, "whitelist", "list", "bobby", "--out")
if code != 0 || !strings.Contains(out, "a@x.com") || !strings.Contains(out, "@y.com") || !strings.Contains(out, "DISABLED") {
t.Fatalf("list wrong: code=%d out=%q", code, out)
}
if code, _, e := run(t, "whitelist", "rm", "bobby", "a@x.com", "--out"); code != 0 { // alias
t.Fatalf("rm failed: %s", e)
}
_, out, _ = run(t, "whitelist", "ls", "bobby", "--out") // alias
if strings.Contains(out, "a@x.com") {
t.Fatalf("address not removed:\n%s", out)
}
}
func TestWhitelistRejectsBadAddress(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "whitelist", "add", "bobby", "notanaddress", "--in"); code != 2 || !strings.Contains(e, "invalid address") {
t.Fatalf("bad address must be rejected: code=%d err=%q", code, e)
}
// The original bug: a missing address must not silently insert a blank row.
if code, _, _ := run(t, "whitelist", "add", "bobby", "--in"); code != 2 {
t.Fatal("add with no address must be a usage error")
}
}
func TestWhitelistEnableDisable(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com")
// Enabling an empty whitelist warns but succeeds.
code, _, errOut := run(t, "whitelist", "enable", "bobby", "--in")
if code != 0 || !strings.Contains(errOut, "empty") {
t.Fatalf("enable empty: code=%d err=%q", code, errOut)
}
_, out, _ := run(t, "whitelist", "list", "bobby", "--in")
if !strings.Contains(out, "ENABLED") {
t.Fatalf("expected ENABLED:\n%s", out)
}
if code, _, e := run(t, "whitelist", "disable", "bobby", "--in"); code != 0 {
t.Fatalf("disable failed: %s", e)
}
_, out, _ = run(t, "whitelist", "list", "bobby", "--in")
if !strings.Contains(out, "DISABLED") {
t.Fatalf("expected DISABLED:\n%s", out)
}
}
func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
@@ -132,12 +224,26 @@ func TestAuditListCoreRenders(t *testing.T) {
}
}
func TestAuditListPositionalAccount(t *testing.T) {
adminEnv(t)
// Positional account + `ls` alias must be accepted (empty log → exit 0).
if code, _, e := run(t, "audit", "ls", "someacct"); code != 0 {
t.Fatalf("audit ls <account> should succeed: code=%d err=%q", code, e)
}
// Extra positional is a usage error.
if code, _, _ := run(t, "audit", "list", "a", "b"); code != 2 {
t.Fatal("extra positional must be a usage error")
}
// The removed --account flag is now a usage error.
if code, _, _ := run(t, "audit", "list", "--account", "x"); code != 2 {
t.Fatal("removed --account flag should now be a usage error")
}
}
func TestAccountEditFromValidationRejectsMalformed(t *testing.T) {
adminEnv(t)
// Seed an account so the failure is from --from validation, not a missing account.
run(t, "account", "add", "--name", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com")
// A malformed --from value must be rejected with exit code 2 before touching the account.
code, _, errStr := run(t, "account", "edit", "--name", "valacc", "--from", "not an address")
run(t, "account", "add", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com")
code, _, errStr := run(t, "account", "edit", "valacc", "--from", "not an address")
if code != 2 {
t.Fatalf("expected exit code 2 for malformed --from, got %d (stderr: %q)", code, errStr)
}
+110 -12
View File
@@ -21,6 +21,7 @@ type Mailer interface {
FetchHeadersRange(folder string, since, before uint32, limit int) ([]mail.Header, error)
FetchFull(folder string, uid uint32) (mail.Message, error)
Search(folder string, sc mail.SearchCriteria, limit int) ([]mail.Header, error)
ListFolders() ([]mail.FolderInfo, error)
Logout() error
}
@@ -50,18 +51,18 @@ func (d Deps) audit(account, action, target, result, reason string) {
})
}
// setup loads the account, builds the inbound rule, dials IMAP, and selects the
// folder (establishing the baseline). Returns a cleanup func.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, uint32, func(), *Envelope) {
// connect loads the account, builds the inbound rule, and dials IMAP — no
// folder selection and no baseline side effects.
func (d Deps) connect(account string) (store.Account, policy.InboundRule, Mailer, *Envelope) {
acc, err := d.Store.GetAccount(account)
if err != nil {
e := Failure(CodeNotFound, "account not found: "+account)
return acc, policy.InboundRule{}, nil, 0, nil, &e
return acc, policy.InboundRule{}, nil, &e
}
re, err := policy.CompileSubject(acc.SubjectRegex)
if err != nil {
e := Failure(CodeConfig, "invalid subject_regex: "+err.Error())
return acc, policy.InboundRule{}, nil, 0, nil, &e
return acc, policy.InboundRule{}, nil, &e
}
wlIn, _ := d.Store.ListWhitelist(account, store.DirIn)
rule := policy.InboundRule{
@@ -72,7 +73,17 @@ func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule,
m, err := d.Dial(acc)
if err != nil {
e := Failure(CodeNetwork, "imap connect failed: "+err.Error())
return acc, rule, nil, 0, nil, &e
return acc, rule, nil, &e
}
return acc, rule, m, nil
}
// setup loads the account, builds the inbound rule, dials IMAP, and selects the
// folder (establishing the baseline). Returns a cleanup func.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, uint32, func(), *Envelope) {
acc, rule, m, fail := d.connect(account)
if fail != nil {
return acc, rule, nil, 0, nil, fail
}
uidv, maxUID, err := m.SelectFolder(folder)
if err != nil {
@@ -184,6 +195,41 @@ func GetCmd(d Deps, account, folder string, uid uint32) error {
}))
}
// Empty-search hints. These MUST stay constant per command variant: the same
// text is emitted whether matching mail is absent, in another folder, or
// hidden by inbound policy, so an empty result reveals nothing about
// filtering (see the invisibility invariant in the skill docs).
const (
searchEmptyHint = "no matches in this folder; mail may be in another mailbox — " +
"run 'folders' to list mailboxes or retry with --all-folders; " +
"note the account's inbound policy may hide some messages " +
"(hidden and non-existent messages are indistinguishable)"
searchAllEmptyHint = "no matches in any folder; try adjusting criteria — " +
"--subject-contains is more reliable than --text on some servers; " +
"note the account's inbound policy may hide some messages " +
"(hidden and non-existent messages are indistinguishable)"
)
// appendVisible appends policy-visible headers to out, up to limit total
// visible results (0 = uncapped). A non-empty folder tags each message with
// its mailbox (needed when sweeping: UIDs are only unique per folder).
func appendVisible(out []map[string]any, rule policy.InboundRule, headers []mail.Header, limit int, folder string) []map[string]any {
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
continue
}
m := headerMap(h)
if folder != "" {
m["folder"] = folder
}
out = append(out, m)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int) error {
_, rule, m, _, done, fail := d.setup(account, folder)
if fail != nil {
@@ -194,18 +240,70 @@ func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(headers))
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
out := appendVisible(make([]map[string]any, 0, len(headers)), rule, headers, limit, "")
d.audit(account, "search", folder, "allowed", "")
data := map[string]any{"messages": out}
if len(out) == 0 {
data["hint"] = searchEmptyHint
}
return d.emit(Success(data))
}
// SearchAllCmd sweeps every selectable folder. It deliberately skips
// EnsureFolderBaseline: baselining folders the agent never listed would mark
// their existing mail "not new" as a side effect of a read command.
func SearchAllCmd(d Deps, account string, sc mail.SearchCriteria, limit int) error {
_, rule, m, fail := d.connect(account)
if fail != nil {
return d.emit(*fail)
}
defer m.Logout()
folders, err := m.ListFolders()
if err != nil {
return d.emit(Failure(CodeNetwork, "list folders failed: "+err.Error()))
}
out := make([]map[string]any, 0)
skipped := make([]string, 0)
for _, f := range folders {
if !f.Selectable {
continue
}
out = append(out, headerMap(h))
if limit > 0 && len(out) >= limit {
break
}
headers, err := m.Search(f.Name, sc, 0)
if err != nil {
skipped = append(skipped, f.Name)
continue
}
d.audit(account, "search", folder, "allowed", "")
return d.emit(Success(map[string]any{"messages": out}))
out = appendVisible(out, rule, headers, limit, f.Name)
}
d.audit(account, "search", "*", "allowed", "")
data := map[string]any{"messages": out, "skipped_folders": skipped}
if len(out) == 0 {
data["hint"] = searchAllEmptyHint
}
return d.emit(Success(data))
}
func FoldersCmd(d Deps, account string) error {
_, _, m, fail := d.connect(account)
if fail != nil {
return d.emit(*fail)
}
defer m.Logout()
folders, err := m.ListFolders()
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(folders))
for _, f := range folders {
out = append(out, map[string]any{
"name": f.Name, "delimiter": f.Delimiter, "selectable": f.Selectable,
})
}
d.audit(account, "folders", "", "allowed", "")
return d.emit(Success(map[string]any{"folders": out}))
}
func AckCmd(d Deps, account, folder string, uids []uint32) error {
+252 -1
View File
@@ -16,6 +16,13 @@ type fakeMailer struct {
maxUID uint32
headers []mail.Header
full map[uint32]mail.Message
// Multi-folder fields, used by folders/--all-folders tests. When
// headersByFolder is non-nil Search consults it instead of headers.
folders []mail.FolderInfo
headersByFolder map[string][]mail.Header
searchErr map[string]error
searched []string
}
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) {
@@ -43,9 +50,19 @@ func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Head
func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) {
return f.full[uid], nil
}
func (f *fakeMailer) Search(string, mail.SearchCriteria, int) ([]mail.Header, error) {
func (f *fakeMailer) Search(folder string, _ mail.SearchCriteria, _ int) ([]mail.Header, error) {
f.searched = append(f.searched, folder)
if err := f.searchErr[folder]; err != nil {
return nil, err
}
if f.headersByFolder != nil {
return f.headersByFolder[folder], nil
}
return f.headers, nil
}
func (f *fakeMailer) ListFolders() ([]mail.FolderInfo, error) {
return f.folders, nil
}
func (f *fakeMailer) Logout() error { return nil }
func testKey() []byte {
@@ -219,3 +236,237 @@ func TestAckAdvancesStateAndFiltered(t *testing.T) {
t.Fatalf("want 0 new messages, got %d", len(msgs))
}
}
func TestFoldersListsMailboxes(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
{Name: "Public", Delimiter: ".", Selectable: false},
},
}
d, buf := newDeps(t, fm)
if err := FoldersCmd(d, "work"); err != nil {
t.Fatalf("FoldersCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
data := res["data"].(map[string]any)
folders := data["folders"].([]any)
if len(folders) != 3 {
t.Fatalf("want 3 folders, got %d: %v", len(folders), folders)
}
first := folders[0].(map[string]any)
if first["name"] != "INBOX" || first["delimiter"] != "." || first["selectable"] != true {
t.Fatalf("unexpected first folder: %v", first)
}
last := folders[2].(map[string]any)
if last["name"] != "Public" || last["selectable"] != false {
t.Fatalf("unexpected last folder: %v", last)
}
}
func TestSearchAllFoldersTagsFolderAndFilters(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
},
headersByFolder: map[string][]mail.Header{
"INBOX": {
{UID: 1, From: "a@trusted.com", Subject: "one"},
{UID: 2, From: "x@evil.com", Subject: "spam"}, // filtered
},
"INBOX.Archive": {
{UID: 7, From: "b@trusted.com", Subject: "two"},
},
},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("want 2 visible messages, got %d: %v", len(msgs), msgs)
}
m0 := msgs[0].(map[string]any)
m1 := msgs[1].(map[string]any)
if m0["folder"] != "INBOX" || m1["folder"] != "INBOX.Archive" {
t.Fatalf("wrong folder tags: %v / %v", m0["folder"], m1["folder"])
}
skipped := data["skipped_folders"].([]any)
if len(skipped) != 0 {
t.Fatalf("want no skipped folders, got %v", skipped)
}
}
func TestSearchAllFoldersSkipsNoselectAndErrors(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "Broken", Delimiter: ".", Selectable: true},
{Name: "Public", Delimiter: ".", Selectable: false},
},
headersByFolder: map[string][]mail.Header{
"INBOX": {{UID: 1, From: "a@trusted.com", Subject: "one"}},
},
searchErr: map[string]error{"Broken": errCommandFailed},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
for _, f := range fm.searched {
if f == "Public" {
t.Fatal("searched a \\Noselect folder")
}
}
data := res["data"].(map[string]any)
if got := len(data["messages"].([]any)); got != 1 {
t.Fatalf("want 1 message, got %d", got)
}
skipped := data["skipped_folders"].([]any)
if len(skipped) != 1 || skipped[0] != "Broken" {
t.Fatalf("want skipped_folders [Broken], got %v", skipped)
}
}
func TestSearchAllFoldersLimitCountsVisibleAcrossFolders(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
{Name: "INBOX.Sent", Delimiter: ".", Selectable: true},
},
headersByFolder: map[string][]mail.Header{
"INBOX": {
{UID: 1, From: "a@trusted.com", Subject: "one"},
{UID: 2, From: "x@evil.com", Subject: "spam"}, // filtered
},
"INBOX.Archive": {
{UID: 3, From: "y@evil.com", Subject: "spam"}, // filtered
{UID: 4, From: "b@trusted.com", Subject: "two"},
},
"INBOX.Sent": {
{UID: 5, From: "c@trusted.com", Subject: "three"},
},
},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 2); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
res := decode(t, buf.Bytes())
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("want exactly 2 visible messages, got %d: %v", len(msgs), msgs)
}
for _, f := range fm.searched {
if f == "INBOX.Sent" {
t.Fatal("searched a folder after the visible limit was reached")
}
}
}
func TestSearchAllFoldersDoesNotBaseline(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
},
headersByFolder: map[string][]mail.Header{
"INBOX.Archive": {{UID: 7, From: "b@trusted.com", Subject: "two"}},
},
}
d, _ := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
// IsNew errors (no folder_state row) iff the sweep did not baseline.
if _, err := d.Store.IsNew("work", "INBOX.Archive", 7); err == nil {
t.Fatal("sweep must not create a folder_state baseline for swept folders")
}
}
func TestSearchEmptyEmitsHint(t *testing.T) {
fm := &fakeMailer{uidValidity: 1, maxUID: 5}
d, buf := newDeps(t, fm)
if err := SearchCmd(d, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd: %v", err)
}
data := decode(t, buf.Bytes())["data"].(map[string]any)
if hint, ok := data["hint"].(string); !ok || hint == "" {
t.Fatalf("want non-empty hint on empty result, got %v", data["hint"])
}
// Non-empty results must omit the hint.
fm2 := &fakeMailer{
uidValidity: 1, maxUID: 5,
headers: []mail.Header{{UID: 1, From: "a@trusted.com", Subject: "one"}},
}
d2, buf2 := newDeps(t, fm2)
if err := SearchCmd(d2, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd: %v", err)
}
data2 := decode(t, buf2.Bytes())["data"].(map[string]any)
if _, ok := data2["hint"]; ok {
t.Fatalf("hint must be absent when messages exist, got %v", data2["hint"])
}
}
func TestSearchAllFoldersEmptyEmitsHint(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{{Name: "INBOX", Delimiter: ".", Selectable: true}},
headersByFolder: map[string][]mail.Header{},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
data := decode(t, buf.Bytes())["data"].(map[string]any)
if hint, ok := data["hint"].(string); !ok || hint == "" {
t.Fatalf("want non-empty hint on empty sweep, got %v", data["hint"])
}
}
// TestSearchEmptyHintIndistinguishableFromFiltered codifies the invisibility
// invariant for the empty-search hint: the envelope for "no mail at all" must
// be byte-identical to the envelope for "mail exists but is all filtered", so
// an empty result reveals nothing about inbound policy. Do not weaken this to
// a structural comparison.
func TestSearchEmptyHintIndistinguishableFromFiltered(t *testing.T) {
absent := &fakeMailer{uidValidity: 1, maxUID: 5}
dAbsent, bufAbsent := newDeps(t, absent)
if err := SearchCmd(dAbsent, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd (absent): %v", err)
}
filtered := &fakeMailer{
uidValidity: 1, maxUID: 5,
headers: []mail.Header{
{UID: 1, From: "x@evil.com", Subject: "spam1"},
{UID: 2, From: "y@evil.com", Subject: "spam2"},
},
}
dFiltered, bufFiltered := newDeps(t, filtered)
if err := SearchCmd(dFiltered, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd (filtered): %v", err)
}
if !bytes.Equal(bufAbsent.Bytes(), bufFiltered.Bytes()) {
t.Fatalf("empty-vs-filtered envelopes differ:\nabsent: %s\nfiltered: %s",
bufAbsent.Bytes(), bufFiltered.Bytes())
}
}
+38
View File
@@ -0,0 +1,38 @@
package cli
import (
"fmt"
"sort"
"strconv"
)
// settingDef describes a configurable global setting for `config list`/`set`.
type settingDef struct {
desc string
validate func(string) error
}
// settingsRegistry is the authoritative set of valid config keys. `config set`
// rejects keys absent here; `config list` enumerates them.
var settingsRegistry = map[string]settingDef{
"audit_retention_days": {
desc: "Days to keep audit-log entries (integer >= 0)",
validate: func(v string) error {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return fmt.Errorf("must be an integer >= 0, got %q", v)
}
return nil
},
},
}
// settingKeys returns the registry keys in stable sorted order.
func settingKeys() []string {
ks := make([]string, 0, len(settingsRegistry))
for k := range settingsRegistry {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
+28
View File
@@ -0,0 +1,28 @@
package cli
import "testing"
func TestSettingsRegistry(t *testing.T) {
def, ok := settingsRegistry["audit_retention_days"]
if !ok {
t.Fatal("audit_retention_days must be registered")
}
if def.desc == "" {
t.Error("registered setting needs a description")
}
if err := def.validate("30"); err != nil {
t.Errorf("validate(30) = %v, want nil", err)
}
for _, bad := range []string{"-1", "abc", ""} {
if def.validate(bad) == nil {
t.Errorf("validate(%q) = nil, want error", bad)
}
}
if _, ok := settingsRegistry["nope"]; ok {
t.Error("unknown key must not be present")
}
keys := settingKeys()
if len(keys) != len(settingsRegistry) {
t.Fatalf("settingKeys len=%d, want %d", len(keys), len(settingsRegistry))
}
}
+12
View File
@@ -16,3 +16,15 @@ func uintSlice(us []uint32) []uint64 {
}
return out
}
// normalizeVerb maps verb aliases to their canonical form. Applied at the
// subcommand-verb position of every admin group and at the top-level command.
func normalizeVerb(v string) string {
switch v {
case "rm", "del":
return "remove"
case "ls":
return "list"
}
return v
}
+16
View File
@@ -0,0 +1,16 @@
package cli
import "testing"
func TestNormalizeVerb(t *testing.T) {
cases := map[string]string{
"rm": "remove", "del": "remove", "remove": "remove",
"ls": "list", "list": "list",
"add": "add", "enable": "enable", "": "",
}
for in, want := range cases {
if got := normalizeVerb(in); got != want {
t.Errorf("normalizeVerb(%q) = %q, want %q", in, got, want)
}
}
}
+9 -6
View File
@@ -16,18 +16,19 @@ type cmdHelp struct {
var agentCmds = []cmdHelp{
{"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."},
{"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."},
{"search", "search --account <name> [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
{"search", "search --account <name> [--folder F | --all-folders] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
{"folders", "folders --account <name>", "List the account's mailboxes/folders."},
{"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."},
{"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."},
}
var adminCmds = []cmdHelp{
{"init", "init", "Create the database and add the first account (interactive)."},
{"account", "account <add|edit|remove|list> [flags]", "Manage accounts (add/edit accept flags, or run with none for an interactive form)."},
{"whitelist", "whitelist <in|out> <add|remove|list> --account <name> [--address A]", "Manage inbound/outbound whitelists."},
{"config", "config <set|get> <key> [value]", "Get or set global settings (e.g. audit_retention_days)."},
{"audit", "audit list [--account <name>] [--limit N]", "Show recent audit-log entries."},
{"doctor", "doctor [--account <name>]", "Check each account's IMAP/SMTP connectivity and auth."},
{"account", "account <add|edit|remove|show|list> [name] [flags]", "Manage accounts. `add`/`edit` take a positional name + field flags, or run with none for an interactive form."},
{"whitelist", "whitelist <add|remove|list|enable|disable> <account> [address…] --in|--out", "Manage inbound/outbound whitelists. Direction (--in/--out) is required."},
{"config", "config <list|get|set> [key] [value]", "List, get, or set global settings (e.g. audit_retention_days)."},
{"audit", "audit list [account] [--limit N]", "Show recent audit-log entries."},
{"doctor", "doctor [account]", "Check each account's IMAP/SMTP connectivity and auth."},
{"version", "version", "Print the emcli version."},
{"help", "help [command]", "Show this help, or detailed usage for one command."},
}
@@ -58,6 +59,8 @@ func printMainHelp(w io.Writer) {
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
}
fmt.Fprint(w, "\nRun \"emcli <command> --help\" for a command's flags.\n")
fmt.Fprint(w, "\nAliases: rm/del = remove, ls = list. Admin commands take positional\n")
fmt.Fprint(w, "operands (account/address/key); agent commands use flags (--account …).\n")
fmt.Fprint(w, "\nEnvironment:\n")
fmt.Fprint(w, " EMCLI_KEY base64-encoded 32-byte AES key; required for any command that uses the database\n")
fmt.Fprint(w, " EMCLI_DB database path (default ~/.config/emcli/emcli.db; %AppData%\\emcli\\emcli.db on Windows)\n")
+16 -1
View File
@@ -15,7 +15,7 @@ func TestMainHelpListsAllCommands(t *testing.T) {
t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text)
}
for _, want := range []string{
"Usage", "list", "get", "search", "ack", "send",
"Usage", "list", "get", "search", "ack", "send", "folders",
"account", "whitelist", "config", "audit", "doctor", "version",
"EMCLI_KEY", "EMCLI_DB",
} {
@@ -71,3 +71,18 @@ func TestAdminCommandHelpExitsZero(t *testing.T) {
}
}
}
func TestHelpReflectsNewGrammar(t *testing.T) {
_, out, _ := run(t, "help", "whitelist")
if !strings.Contains(out, "<account>") || !strings.Contains(out, "--in") {
t.Fatalf("whitelist help should show positional account + --in/--out:\n%s", out)
}
_, out, _ = run(t, "help", "account")
if !strings.Contains(out, "show") {
t.Fatalf("account help should list the new show subcommand:\n%s", out)
}
_, mainOut, mainErr := run(t, "help")
if !strings.Contains(mainOut+mainErr, "rm") && !strings.Contains(mainOut+mainErr, "alias") {
t.Fatalf("main help should mention aliases:\n%s", mainOut+mainErr)
}
}
+12 -6
View File
@@ -9,16 +9,22 @@ import (
)
func TestCommandRole(t *testing.T) {
admin := []string{"account", "whitelist", "config", "audit"}
agent := []string{"list", "get", "search", "ack", "send", "doctor"}
for _, c := range admin {
adminCmds := [][]string{
{"whitelist"}, {"config"}, {"audit"},
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
}
agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"}, {"folders"},
{"account", "list"},
}
for _, c := range adminCmds {
if commandRole(c) != store.RoleAdmin {
t.Errorf("%s should be admin", c)
t.Errorf("%v should be admin", c)
}
}
for _, c := range agent {
for _, c := range agentCmds {
if commandRole(c) != store.RoleAgent {
t.Errorf("%s should be agent", c)
t.Errorf("%v should be agent", c)
}
}
}
+90 -11
View File
@@ -27,9 +27,17 @@ func realMailer(acc store.Account) (Mailer, error) {
// commandRole maps a command to the privilege it requires. Admin commands
// mutate configuration or expose oversight data; everything else is agent.
func commandRole(cmd string) store.Role {
switch cmd {
case "account", "whitelist", "config", "audit":
func commandRole(args []string) store.Role {
switch args[0] {
case "account":
// account list is a read-only discovery view available to agents;
// add/edit/remove mutate config and require admin.
// Normalize the verb so `account ls` routes the same as `account list`.
if len(args) >= 2 && normalizeVerb(args[1]) == "list" {
return store.RoleAgent
}
return store.RoleAdmin
case "whitelist", "config", "audit":
return store.RoleAdmin
default: // list, get, search, ack, send, doctor
return store.RoleAgent
@@ -98,18 +106,36 @@ func newDepsLive(st *store.Store, out io.Writer) Deps {
}
}
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
// runDoctor handles `doctor [account]` or `doctor [--account <name>]`.
func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
// Accept a positional account (human form) or --account (agent form).
var positional string
rest := args
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
positional, rest = args[0], args[1:]
}
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "doctor", errOut)
account := fs.String("account", "", "check only this account")
if err := fs.Parse(args); err != nil {
accountFlag := fs.String("account", "", "check only this account")
if err := fs.Parse(rest); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 0 {
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
return 2
}
account := *accountFlag
if positional != "" {
if account != "" && account != positional {
fmt.Fprintln(errOut, "give the account once, as a positional or --account, not both")
return 2
}
account = positional
}
st, err := openStore(role)
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
@@ -117,7 +143,7 @@ func runDoctor(args []string, role store.Role, out, errOut io.Writer) int {
}
defer st.Close()
d := newDepsLive(st, out)
if err := DoctorCmd(d, *account); err != nil {
if err := DoctorCmd(d, account); err != nil {
return 1
}
return 0
@@ -135,10 +161,12 @@ func Run(args []string, out, errOut io.Writer) int {
return 0
}
cmd, rest := args[0], args[1:]
role := commandRole(cmd)
switch cmd {
role := commandRole(args)
switch normalizeVerb(cmd) {
case "list", "get", "search", "ack":
return runAgent(cmd, rest, role, out, errOut)
return runAgent(normalizeVerb(cmd), rest, role, out, errOut)
case "folders":
return runFolders(rest, role, out, errOut)
case "send":
return runSend(rest, role, out, errOut)
case "account":
@@ -159,6 +187,37 @@ func Run(args []string, out, errOut io.Writer) int {
}
}
// runFolders handles `folders --account <name>` — list the account's mailboxes.
func runFolders(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("folders", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "folders", errOut)
account := fs.String("account", "", "account name")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
if *account == "" {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
}
defer st.Close()
_, _ = st.PurgeAudit(time.Now())
d := newDepsLive(st, out)
if err := FoldersCmd(d, *account); err != nil {
return 1
}
return 0
}
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
@@ -177,6 +236,10 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
sinceDate := fs.String("since-date", "", "search: RFC3339 date lower bound")
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
allFolders := new(bool)
if cmd == "search" {
allFolders = fs.Bool("all-folders", false, "search: sweep all mailboxes")
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0 // usage already printed to stderr; help isn't an error
@@ -191,6 +254,18 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
if *allFolders {
folderSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "folder" {
folderSet = true
}
})
if folderSet {
_ = Failure(CodeUsage, "--folder and --all-folders are mutually exclusive").Write(out)
return 2
}
}
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
@@ -225,7 +300,11 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
sc.Before = tm
}
}
if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil {
if *allFolders {
if err := SearchAllCmd(d, *account, sc, *limit); err != nil {
return 1
}
} else if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil {
return 1
}
case "ack":
+110 -5
View File
@@ -18,9 +18,9 @@ func TestRunUnknownCommand(t *testing.T) {
}
}
func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
// `account list` with no DB key should fail closed with a usage/config error,
// proving the key check happens before any DB work.
func TestAccountListMissingKeyFailsClosedAsJSON(t *testing.T) {
// `account list` is an agent command: with no DB key it fails closed before
// any DB work, emitting a JSON config-error envelope that names EMCLI_KEY.
var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", "")
t.Setenv("EMCLI_ADMIN_KEY", "")
@@ -28,8 +28,15 @@ func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) {
if code == 0 {
t.Fatal("missing EMCLI_KEY must fail")
}
if !strings.Contains(out.String()+errOut.String(), "EMCLI_ADMIN_KEY") {
t.Fatalf("should mention EMCLI_ADMIN_KEY, got out=%q err=%q", out.String(), errOut.String())
var env map[string]any
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("agent account list error must be JSON, got out=%q err=%q", out.String(), errOut.String())
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
if !strings.Contains(out.String(), "EMCLI_KEY") {
t.Fatalf("should name the missing EMCLI_KEY, got %q", out.String())
}
}
@@ -51,6 +58,20 @@ func TestListUsageErrorIsJSON(t *testing.T) {
}
}
func TestDoctorPositionalAccount(t *testing.T) {
adminEnv(t)
// No such account: doctor should reach account lookup and fail there (exit 1),
// proving the positional was accepted (not rejected as an unexpected arg).
code, _, errOut := run(t, "doctor", "ghost")
if code == 2 {
t.Fatalf("positional account must be accepted, got usage error: %q", errOut)
}
// Giving both positional and --account with different values is a usage error.
if code, _, _ := run(t, "doctor", "ghost", "--account", "other"); code != 2 {
t.Fatal("conflicting positional + --account must be a usage error")
}
}
func b64Key() string {
// 32 zero bytes, base64.
return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
@@ -60,3 +81,87 @@ func b64AgentKey() string {
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
}
func TestTopLevelLsAlias(t *testing.T) {
adminEnv(t)
// `ls` with no --account must hit the same usage path as `list` (CodeUsage
// envelope on stdout, exit 2) — proving it routed to the agent list command.
code, out, _ := run(t, "ls")
if code != 2 || !strings.Contains(out, "account") {
t.Fatalf("ls should alias list (usage about --account): code=%d out=%q", code, out)
}
}
// TestAccountLsAliasAgentRole verifies that `account ls` is treated as an agent
// command (not admin) so a caller with only EMCLI_KEY can use it and gets the
// same reduced-JSON envelope as `account list`.
func TestAccountLsAliasAgentRole(t *testing.T) {
adminEnv(t) // sets up both keys + initialized temp DB
run(t, "account", "add", "work", "--mode", "RW",
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
"--username", "login@example.com", "--from", "me@example.com")
// Drop the admin key — caller is agent-only.
t.Setenv("EMCLI_ADMIN_KEY", "")
// `account ls` must succeed with the reduced JSON view, same as `account list`.
code, out, errOut := run(t, "account", "ls")
if code != 0 {
t.Fatalf("agent account ls should succeed: code=%d out=%q err=%q", code, out, errOut)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("account ls output is not JSON: %v\n%s", err, out)
}
if env["error"] == true {
t.Fatalf("account ls returned error envelope: %s", out)
}
// The agent view must not leak IMAP host or login username.
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
t.Fatalf("account ls leaked host/username:\n%s", out)
}
}
func TestSearchAllFoldersConflictsWithFolder(t *testing.T) {
adminEnv(t)
code, out, _ := run(t, "search", "--account", "x", "--folder", "F", "--all-folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func TestAllFoldersRejectedForList(t *testing.T) {
adminEnv(t)
// --all-folders is only registered for search; list must reject it at
// flag-parse time with a JSON usage envelope.
code, out, _ := run(t, "list", "--account", "x", "--all-folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func TestFoldersRequiresAccount(t *testing.T) {
adminEnv(t)
code, out, _ := run(t, "folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ func TestAgentKeyCannotRunAdminCommands(t *testing.T) {
before := dbBytes(t, db)
adminAttempts := [][]string{
{"account", "list"},
{"account", "add", "--name", "x", "--imap-host", "h", "--username", "u@x.com"},
{"config", "set", "audit_retention_days", "30"},
{"audit"},
}
+39
View File
@@ -52,6 +52,45 @@ func Dial(cfg IMAPConfig) (*Client, error) {
func (c *Client) Logout() error { return c.c.Logout() }
// FolderInfo describes one mailbox as reported by the server. Selectable is
// false for hierarchy-only entries (\Noselect) that cannot be passed to SELECT.
type FolderInfo struct {
Name string
Delimiter string
Selectable bool
}
// ListFolders enumerates all mailboxes, INBOX first, then case-insensitive
// name order.
func (c *Client) ListFolders() ([]FolderInfo, error) {
infoCh := make(chan *imap.MailboxInfo, 16)
done := make(chan error, 1)
go func() { done <- c.c.List("", "*", infoCh) }()
var out []FolderInfo
for info := range infoCh {
selectable := true
for _, attr := range info.Attributes {
if strings.EqualFold(attr, imap.NoSelectAttr) {
selectable = false
break
}
}
out = append(out, FolderInfo{Name: info.Name, Delimiter: info.Delimiter, Selectable: selectable})
}
if err := <-done; err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
ii, ji := out[i].Name == "INBOX", out[j].Name == "INBOX"
if ii != ji {
return ii
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
func (c *Client) SelectFolder(folder string) (uint32, uint32, error) {
mbox, err := c.c.Select(folder, true) // read-only select
if err != nil {
+19
View File
@@ -49,6 +49,25 @@ func TestSelectAndFetch(t *testing.T) {
t.Logf("inbox has %d messages, maxUID=%d", len(headers), maxUID)
}
func TestListFolders(t *testing.T) {
c, err := Dial(testCfg(t))
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer c.Logout()
folders, err := c.ListFolders()
if err != nil {
t.Fatalf("ListFolders: %v", err)
}
if len(folders) == 0 {
t.Fatal("expected at least one folder")
}
if folders[0].Name != "INBOX" || !folders[0].Selectable {
t.Fatalf("expected selectable INBOX first, got %+v", folders[0])
}
}
func atoiEnv(t *testing.T, k string) int {
t.Helper()
n, err := strconv.Atoi(os.Getenv(k))
+30
View File
@@ -3,6 +3,8 @@
package policy
import (
"errors"
"fmt"
"net/mail"
"strings"
)
@@ -42,3 +44,31 @@ func MatchAddress(entries []string, addr string) bool {
}
return false
}
// ValidWhitelistAddress reports an error if s is not a usable whitelist entry.
// Accepted forms: a bare address "local@domain", or a domain wildcard "@domain".
// The domain must contain at least one dot. Display-name forms ("Bob <b@x>")
// are rejected because the store keeps the raw string, which would never match.
func ValidWhitelistAddress(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return errors.New("address must not be empty")
}
bad := fmt.Errorf("invalid address %q: expected local@domain or @domain", s)
if strings.HasPrefix(s, "@") {
domain := s[1:]
if domain == "" || strings.Contains(domain, "@") || !strings.Contains(domain, ".") {
return bad
}
return nil
}
addr, err := mail.ParseAddress(s)
if err != nil || !strings.EqualFold(addr.Address, s) {
return bad // parse failure or a display-name/extra-token form
}
at := strings.LastIndex(addr.Address, "@")
if at < 1 || !strings.Contains(addr.Address[at+1:], ".") {
return bad
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
package policy
import "testing"
func TestValidWhitelistAddress(t *testing.T) {
good := []string{"tk555@protonmail.com", "a.b@sub.example.co.uk", "@example.com", "@sub.example.com"}
for _, s := range good {
if err := ValidWhitelistAddress(s); err != nil {
t.Errorf("ValidWhitelistAddress(%q) = %v, want nil", s, err)
}
}
bad := []string{"", " ", "notanaddress", "@", "@nodot", "a@nodot", "Bob <b@x.com>", "a@b@c.com"}
for _, s := range bad {
if err := ValidWhitelistAddress(s); err == nil {
t.Errorf("ValidWhitelistAddress(%q) = nil, want error", s)
}
}
}
+16 -1
View File
@@ -8,6 +8,7 @@ import (
"path/filepath"
"runtime"
"strconv"
"strings"
_ "modernc.org/sqlite"
)
@@ -77,10 +78,24 @@ func (s *Store) migrate() error {
func (s *Store) Close() error { return s.db.Close() }
// expandUserHome replaces a leading "~" or "~/" in p with the user's home
// directory. Only a leading tilde is expanded (the usual shell convention) —
// "~user" and a tilde elsewhere in the path are left untouched. This guards
// against an EMCLI_DB set to a literal "~/..." (no shell to expand it), which
// would otherwise be opened relative to the cwd and create a stray "~" dir.
func expandUserHome(p string) string {
if p == "~" || strings.HasPrefix(p, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, strings.TrimPrefix(p[1:], "/"))
}
}
return p
}
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil
return expandUserHome(p), nil
}
if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" {
+43
View File
@@ -2,10 +2,53 @@ package store
import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
)
// A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
// tilde (no shell to expand it) can't be opened relative to the cwd and
// silently create a stray "~" directory.
func TestDefaultDBPathExpandsLeadingTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("no home dir: %v", err)
}
cases := map[string]string{
"~/.config/emcli/emcli.db": filepath.Join(home, ".config", "emcli", "emcli.db"),
"~": home,
}
for in, want := range cases {
t.Setenv("EMCLI_DB", in)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", in, err)
}
if got != want {
t.Fatalf("EMCLI_DB=%q -> %q, want %q", in, got, want)
}
if strings.Contains(got, "~") {
t.Fatalf("EMCLI_DB=%q left a literal tilde: %q", in, got)
}
}
}
// A non-leading tilde or "~user" is NOT a path we should rewrite — leave it be.
func TestDefaultDBPathLeavesOtherPathsUntouched(t *testing.T) {
for _, p := range []string{"/var/lib/emcli.db", "./rel/emcli.db", "~user/db"} {
t.Setenv("EMCLI_DB", p)
got, err := DefaultDBPath()
if err != nil {
t.Fatalf("DefaultDBPath(%q): %v", p, err)
}
if got != p {
t.Fatalf("EMCLI_DB=%q was rewritten to %q", p, got)
}
}
}
// openTemp opens a fresh store in a temp dir and initialises keys so that
// account tests (which do crypto) work without needing their own setup.
func openTemp(t *testing.T) *Store {
+15
View File
@@ -86,3 +86,18 @@ func (s *Store) ListWhitelist(account string, dir Direction) ([]string, error) {
}
return out, rows.Err()
}
// SetWhitelistEnabled toggles one account's per-direction whitelist-enabled
// flag, leaving the address list and all other fields untouched.
func (s *Store) SetWhitelistEnabled(account string, dir Direction, enabled bool) error {
col := "whitelist_in_enabled"
if dir == DirOut {
col = "whitelist_out_enabled"
}
id, err := s.accountID(account)
if err != nil {
return err
}
_, err = s.db.Exec(fmt.Sprintf("UPDATE accounts SET %s=? WHERE id=?", col), b2i(enabled), id)
return err
}
+34
View File
@@ -0,0 +1,34 @@
package store
import (
"path/filepath"
"testing"
)
func TestSetWhitelistEnabled(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "e.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
k := make([]byte, 32)
if err := st.InitKeys(k, k); err != nil {
t.Fatalf("InitKeys: %v", err)
}
if _, err := st.AddAccount(Account{Name: "a", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls", AuthType: "password", Username: "u@x.com"}); err != nil {
t.Fatalf("AddAccount: %v", err)
}
if err := st.SetWhitelistEnabled("a", DirIn, true); err != nil {
t.Fatalf("SetWhitelistEnabled: %v", err)
}
got, err := st.GetAccount("a")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if !got.WhitelistInEnabled || got.WhitelistOutEnabled {
t.Fatalf("flags wrong: in=%v out=%v", got.WhitelistInEnabled, got.WhitelistOutEnabled)
}
if err := st.SetWhitelistEnabled("missing", DirIn, true); err == nil {
t.Fatal("enabling on a missing account must error")
}
}
+1 -14
View File
@@ -24,7 +24,7 @@ type Fields struct {
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool
ProcessBacklog bool
SubjectRegex string
}
@@ -90,7 +90,6 @@ func (f Fields) ToAccount() (store.Account, bool) {
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
}
if f.Mode == "RW" {
@@ -116,8 +115,6 @@ func FieldsFromAccount(a store.Account) Fields {
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username,
FromAddress: a.FromAddress,
WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog,
SubjectRegex: a.SubjectRegex,
}
@@ -144,8 +141,6 @@ var fieldDefs = []fieldDef{
{key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true},
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
{key: "whitelist_out", label: "Whitelist outbound (y/n)", isBool: true},
{key: "process_backlog", label: "Process backlog (y/n)", isBool: true},
{key: "subject_regex", label: "Subject regex (optional)"},
}
@@ -189,10 +184,6 @@ func fieldValue(f Fields, key string) string {
return f.FromAddress
case "password":
return f.Password
case "whitelist_in":
return boolStr(f.WhitelistIn)
case "whitelist_out":
return boolStr(f.WhitelistOut)
case "process_backlog":
return boolStr(f.ProcessBacklog)
case "subject_regex":
@@ -276,10 +267,6 @@ func (m AccountForm) collect() Fields {
f.FromAddress = v
case "password":
f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in":
f.WhitelistIn = parseBool(v)
case "whitelist_out":
f.WhitelistOut = parseBool(v)
case "process_backlog":
f.ProcessBacklog = parseBool(v)
case "subject_regex":
+15 -4
View File
@@ -1,6 +1,7 @@
package tui
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
@@ -69,7 +70,6 @@ func TestFieldsValidateRWNeedsSMTP(t *testing.T) {
func TestFieldsToAccount(t *testing.T) {
f := validFields()
f.WhitelistIn = true
f.SubjectRegex = "^urgent"
acc, pwSet := f.ToAccount()
if !pwSet {
@@ -78,9 +78,12 @@ func TestFieldsToAccount(t *testing.T) {
if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 {
t.Fatalf("account not assembled: %+v", acc)
}
if acc.AuthType != "password" || !acc.WhitelistInEnabled || acc.SubjectRegex != "^urgent" {
if acc.AuthType != "password" || acc.SubjectRegex != "^urgent" {
t.Fatalf("account flags wrong: %+v", acc)
}
if acc.WhitelistInEnabled || acc.WhitelistOutEnabled {
t.Fatal("new accounts must have whitelist flags false (managed via whitelist group)")
}
if acc.Password != "pw" {
t.Fatalf("password not carried: %q", acc.Password)
}
@@ -99,10 +102,10 @@ func TestFieldsFromAccountRoundTrip(t *testing.T) {
a := store.Account{
Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls",
SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls",
Username: "u@x.com", WhitelistOutEnabled: true, SubjectRegex: "re:",
Username: "u@x.com", SubjectRegex: "re:",
}
f := FieldsFromAccount(a)
if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || !f.WhitelistOut || f.SubjectRegex != "re:" {
if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || f.SubjectRegex != "re:" {
t.Fatalf("FieldsFromAccount wrong: %+v", f)
}
// Password is never read back from an account.
@@ -190,3 +193,11 @@ func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
}
}
func TestFormHasNoWhitelistFields(t *testing.T) {
f := NewAccountForm(Fields{}, false)
out := f.View()
if strings.Contains(strings.ToLower(out), "whitelist") {
t.Fatalf("account form must not mention whitelists:\n%s", out)
}
}
+17 -6
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose |
|---|---|---|
| `EMCLI_VERSION` | `v0.5.0` | Release tag to fetch |
| `EMCLI_VERSION` | `v0.7.0` | Release tag to fetch |
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
@@ -68,7 +68,7 @@ CGO_ENABLED=0 go build -o emcli ./cmd/emcli # then move ./emcli onto your PATH
## 3. Confirm the agent key is present
emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises
`list`, `get`, `search`, `ack`, `send`, and `doctor`. Admin commands require `EMCLI_ADMIN_KEY`,
`list`, `get`, `search`, `folders`, `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.
@@ -85,16 +85,27 @@ with both keys exported. Account creation and other admin is the human's job —
## 4. Find the account(s)
You refer to an account by name (e.g. `gmail`, `work`). Ask the user which account to use.
`emcli doctor` is an agent command (authorised by `EMCLI_KEY`), so you can run it to check that
configured accounts connect and authenticate:
You refer to an account by name (e.g. `gmail`, `work`). Discover the configured accounts yourself
with `emcli account list` (an agent command authorised by `EMCLI_KEY`); it prints a JSON envelope
with one entry per account:
```bash
emcli account list
# {"error":false,"error_detail":{},"data":{"accounts":[
# {"name":"gmail","from":"me@gmail.com","can_send":true},
# {"name":"alerts","from":"alerts@x.com","can_send":false}]}}
```
`name` is what you pass to `--account`; `from` is the send-as identity; `can_send` is false for
read-only accounts (they reject `send`). If unsure which to use, ask the user. `emcli doctor`
(also an agent command) checks that accounts connect and authenticate:
```bash
emcli doctor # all accounts
emcli doctor --account gmail
```
Just take the account name from the user and start with the workflow in `SKILL.md`.
Then start with the workflow in `SKILL.md`.
## You're set up
+27 -11
View File
@@ -4,7 +4,7 @@ description: Read and send email through emcli, a guard-railed CLI gateway that
compatibility: Requires the emcli binary (run scripts/install.sh to fetch it; needs curl or wget and a Linux/macOS/Windows shell) and the EMCLI_KEY environment variable, which the orchestrator provides. Needs network access to the configured mail server.
metadata:
author: steve
version: "0.4.0"
version: "0.7.0"
homepage: "https://gitea.dcglab.co.uk/steve/emcli"
---
@@ -17,12 +17,12 @@ sets its exit code to match.
## Security model — read this first
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`. You are
provided only `EMCLI_KEY` (the agent key), which authorises these commands and nothing else.
Account setup, passwords, whitelists, and config are the **user's** job (admin commands that
require `EMCLI_ADMIN_KEY`) — do not run or suggest running `account`, `whitelist`, `config`,
`audit`, or `init`. You have only `EMCLI_KEY` (agent key); `emcli` will refuse admin commands
with a privilege error.
- **You only run agent commands:** `list`, `get`, `search`, `folders`, `ack`, `send`, `doctor`,
and `account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which
authorises these and nothing else. Account *setup* (`account add/edit/remove`), passwords,
whitelists, and config are the **user's** job (admin commands that require `EMCLI_ADMIN_KEY`) —
do not run or suggest running `account add/edit/remove`, `whitelist`, `config`, `audit`, or
`init`. `emcli` will refuse those with a privilege error.
- **Never touch the secret key.** `EMCLI_KEY` is supplied in the environment by whoever launched
you. Do not read it, print it, log it, pass it as an argument, or try to generate one. If it is
missing, stop and tell the user (see "Files & first run").
@@ -117,13 +117,28 @@ read-only — tell the user; do not attempt another account without their say-so
|---|---|
| `emcli list --account A [--folder F] [--new] [--limit N] [--before U] [--since U]` | Message headers, newest first |
| `emcli get --account A [--folder F] --uid U` | One full message (body + attachments) |
| `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 \| --all-folders] [--from X] [--subject-contains X] [--text X] [--since-date D] [--before-date D]` | Server-side search |
| `emcli folders --account A` | List the account's mailboxes/folders |
| `emcli ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account |
Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
### Searching effectively
- `list`/`search` cover **one folder** (INBOX by default). To find mail that may be archived or
sorted elsewhere, run `emcli folders --account A` to see the mailboxes, or search with
`--all-folders` to sweep them all (each hit then carries a `folder` field — pass it back via
`--folder` when you `get`/`ack` that message).
- Prefer `--subject-contains` and `--from`. `--text` (full-text) is **best-effort**: some IMAP
servers have no body index and return zero results for mail that `--subject-contains` finds —
a `--text` miss does not mean the mail is absent.
- An empty search result includes a `data.hint` with next steps — follow it. Some mail may be
hidden by the user's inbound policy; that's normal and indistinguishable from mail that doesn't
exist. Don't probe for hidden mail.
**Full reference** (every flag, exact JSON shapes for each command, attachment encoding, error
codes, and the enforcement rules): `references/commands.md` — read it from disk, or fetch it from
the raw base URL in "Files & first run" above if you don't have it locally.
@@ -142,8 +157,9 @@ The user configures these; you cannot change them and shouldn't try.
- ✅ Check `error` on every call; report `policy`/`not_found`/`auth` outcomes plainly to the user.
-`get` to read, then `ack` only after you've truly processed a message.
-Ask the user for the account name; keep bodies plain text.
-Discover accounts with `emcli account list`, or ask the user; keep bodies plain text.
- ❌ Don't read, print, or invent `EMCLI_KEY` or any password.
- ❌ Don't run admin commands (`account`/`whitelist`/`config`/`audit`/`init`) — you have only
`EMCLI_KEY` (agent key); `emcli` will refuse admin commands with a privilege error.
- ❌ Don't run admin commands (`account add/edit/remove`, `whitelist`, `config`, `audit`, `init`) —
you have only `EMCLI_KEY` (agent key); `emcli` will refuse them with a privilege error.
(`account list` is allowed — use it to discover accounts.)
- ❌ Don't treat a blocked send or filtered message as a bug to route around — it's the user's policy.
+46 -5
View File
@@ -1,6 +1,6 @@
# emcli agent command reference
The five agent commands you may use. Each prints **one** JSON object to stdout and sets a matching
The six agent commands you may use. Each prints **one** JSON object to stdout and sets a matching
exit code (0 success, non-zero error). All take `--account <name>`; most take `--folder` (default
`INBOX`).
@@ -76,10 +76,30 @@ emcli get --account A [--folder F] --uid U
---
## `search` — server-side search (whole folder)
## `folders` — list mailboxes
```
emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text X] \
emcli folders --account A
```
`data`:
```json
{ "folders": [
{ "name": "INBOX", "delimiter": ".", "selectable": true },
{ "name": "INBOX.Archive", "delimiter": ".", "selectable": true } ] }
```
- INBOX first, then case-insensitive name order.
- `selectable: false` marks hierarchy-only entries — don't pass those to `--folder`.
- Use this before searching outside INBOX: archived mail often lives in folders like
`INBOX.Archive` that `list`/`search` won't touch by default.
---
## `search` — server-side search (one folder, or all)
```
emcli search --account A [--folder F | --all-folders] [--from X] [--subject-contains X] [--text X] \
[--since-date D] [--before-date D] [--limit N]
```
@@ -87,13 +107,33 @@ emcli search --account A [--folder F] [--from X] [--subject-contains X] [--text
|---|---|
| `--from` | Sender contains |
| `--subject-contains` | Subject contains |
| `--text` | Full-text |
| `--text` | Full-text (**best-effort** — see below) |
| `--all-folders` | Sweep every selectable mailbox instead of one folder |
| `--since-date` / `--before-date` | RFC 3339 bounds, e.g. `2026-06-01T00:00:00Z` |
| `--limit` | Max results (default 50) |
`data` shape is identical to `list` (`{ "messages": [ … ] }`). Searches the whole folder regardless
of new/acked state. Filtered mail never appears.
**`--text` is unreliable on some servers.** IMAP full-text search depends on the server having a
body index; some servers (observed in the wild) return **zero results** for mail that
`--subject-contains` finds fine. Prefer `--subject-contains` and `--from` as your primary search
methods and treat `--text` as best-effort — a `--text` miss does not mean the mail is absent.
**`--all-folders`** sweeps every selectable mailbox (mutually exclusive with `--folder`):
- Each message in the result gains a `"folder"` field. UIDs are only unique per folder, so pass
that folder back via `--folder` when you `get`/`ack` a hit.
- `data.skipped_folders` lists mailboxes the server refused to search (always present, `[]` when
none) — transport errors, not policy.
- `--limit` caps total visible results across the sweep.
- On Gmail-style servers the same message can appear under several folders with different UIDs;
dedupe by `message_id` if that matters.
**Empty results include a `hint`.** When `messages` is `[]`, `data.hint` suggests next steps
(run `folders`, retry with `--all-folders`, or adjust criteria). The hint text is fixed per mode
and identical whether matching mail is absent, in another folder, or hidden by policy — it tells
you what to try, not why the result is empty.
---
## `ack` — mark message(s) processed
@@ -150,7 +190,8 @@ is subject to the inbound filter: a filtered/missing source returns `not_found`.
- **Mode:** `RO` accounts reject `send`. `RW` can read and send.
- **Inbound whitelist / subject filter:** disallowed mail is invisible everywhere (`list`/`search`
omit it; `get`/`ack` return `not_found`). You can't tell a filtered message from a non-existent
one — by design.
one — by design. The empty-search `hint` is deliberately generic for the same reason: it never
reveals whether anything was filtered.
- **Outbound whitelist:** every recipient (to+cc+bcc) must match, or the send is blocked whole.
- **Address matching:** case-insensitive; an entry `@domain.com` matches any address at that
domain; otherwise an exact-address match.
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh
#
# Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.5.0)
# EMCLI_VERSION release tag to fetch (default: v0.7.0)
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
#
# Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.7.0_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.5.0}"
VERSION="${EMCLI_VERSION:-v0.7.0}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"