Compare commits
22 Commits
8ed10dd503
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 03bcdf6fc0 | |||
| 87555fdc4d | |||
| 5476c04443 | |||
| dbefb68611 | |||
| 1e00f68a3d | |||
| 1a03ce1c69 | |||
| ca49a42d40 | |||
| c826042625 | |||
| 44a9211a6f | |||
| 9a8765d4e4 | |||
| 1bf5bf3c47 | |||
| f407fc126d | |||
| 56ecdf246c | |||
| a5bfaa4fe3 | |||
| 85642d5b12 | |||
| 68c926f83b | |||
| 2c7b8d3610 | |||
| 7a4d2881ba | |||
| 3c5e0a26f3 | |||
| 456d25d4f3 | |||
| 3bea73f857 | |||
| c651b00d08 |
+61
-39
@@ -176,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` | |
|
||||
@@ -198,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.
|
||||
@@ -219,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'
|
||||
@@ -233,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'
|
||||
@@ -252,34 +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
|
||||
```
|
||||
|
||||
```bash
|
||||
emcli account edit --name work --from 'Work Team <you@yourdomain.com>' # set the send-as address
|
||||
emcli account edit --name work --from '' # clear it (revert to username)
|
||||
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)
|
||||
```
|
||||
|
||||
> Note: the flag form of `account edit` covers connection/auth fields, `--from`, and
|
||||
> `--subject-regex`. Passing `--from ''` clears the send-as address so mail falls back to the login
|
||||
> username. To toggle whitelists or `process-backlog`, use the interactive form (`emcli account edit
|
||||
> --name X` with no other flags), or the `whitelist` commands in section 6.
|
||||
> username. To enable or disable whitelists use `whitelist enable`/`whitelist disable` (section 6).
|
||||
|
||||
**Remove an account** (requires `--yes`):
|
||||
**Remove an account** (requires `--yes` when stdin is not a terminal):
|
||||
|
||||
```bash
|
||||
emcli account remove --name work --yes
|
||||
emcli account remove work --yes
|
||||
```
|
||||
|
||||
---
|
||||
@@ -292,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
|
||||
@@ -326,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -499,8 +514,9 @@ A blocked send (read-only account):
|
||||
sends and reads nothing.
|
||||
|
||||
```bash
|
||||
emcli doctor # check every account
|
||||
emcli doctor --account gmail # check just one
|
||||
emcli doctor # check every account
|
||||
emcli doctor gmail # check just one (positional)
|
||||
emcli doctor --account gmail # same — flag form also works
|
||||
```
|
||||
|
||||
Example output:
|
||||
@@ -531,7 +547,7 @@ Every agent action (`list`, `get`, `search`, `ack`, `send`) — allowed or block
|
||||
|
||||
```bash
|
||||
emcli audit list # most recent 50
|
||||
emcli audit list --account gmail # filter to one account
|
||||
emcli audit list gmail # filter to one account
|
||||
emcli audit list --limit 200
|
||||
```
|
||||
|
||||
@@ -541,6 +557,7 @@ the reason. Secrets never appear here.
|
||||
### Settings
|
||||
|
||||
```bash
|
||||
emcli config list # list all settings and their current values
|
||||
emcli config set audit_retention_days 90
|
||||
emcli config get audit_retention_days
|
||||
```
|
||||
@@ -548,6 +565,9 @@ emcli config get audit_retention_days
|
||||
- `audit_retention_days` — how long to keep audit rows. On each run, entries older than this are
|
||||
purged. Must be a whole number ≥ 0. `0` or unset means no automatic purging.
|
||||
|
||||
`config list` prints every known setting, its current value, and a short description. Unknown keys
|
||||
are rejected by `config get` and `config set`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting
|
||||
@@ -571,11 +591,11 @@ the DEK if one already exists) with both correct keys, then re-add any accounts
|
||||
`587`/`starttls` is a common alternative to `465`/`tls`.
|
||||
|
||||
**The agent can't see a message you know exists.** It's probably filtered: check the account's
|
||||
inbound whitelist (`emcli whitelist in list --account NAME`) and subject filter. Filtered mail is
|
||||
inbound whitelist (`emcli whitelist list NAME --in`) and subject filter. Filtered mail is
|
||||
invisible by design.
|
||||
|
||||
**`send` is blocked.**
|
||||
- `ro_mode` — the account is read-only. Change it: `emcli account edit --name NAME --mode RW`
|
||||
- `ro_mode` — the account is read-only. Change it: `emcli account edit NAME --mode RW`
|
||||
(and set SMTP details).
|
||||
- `whitelist_out` — a recipient isn't on the outbound whitelist. Add it, or review the rule.
|
||||
|
||||
@@ -597,18 +617,20 @@ emcli # or: emcli help / emcli --help — list all commands
|
||||
emcli <command> --help # usage and flags for one command
|
||||
|
||||
# Admin (requires EMCLI_ADMIN_KEY)
|
||||
emcli init # create DB + add first account (form)
|
||||
emcli account add [flags | none for form] # add an account
|
||||
emcli account list # full table (admin) / name+from+can_send JSON (agent)
|
||||
emcli account edit --name N [flags | none for form] # change an account
|
||||
emcli account remove --name N --yes # delete an account
|
||||
emcli whitelist in|out add|remove|list --account N [--address A]
|
||||
emcli config set|get <key> [value] # e.g. audit_retention_days
|
||||
emcli audit list [--account N] [--limit K]
|
||||
emcli init # create DB + add first account (form)
|
||||
emcli account add [<name>] [flags] # add an account; no args → interactive form
|
||||
emcli account list # full table (admin) / name+from+can_send JSON (agent)
|
||||
emcli account show N # display one account's settings
|
||||
emcli account edit N [flags | none for form] # change an account
|
||||
emcli account remove N [--yes] # delete an account (--yes or TTY confirm)
|
||||
emcli whitelist <add|remove|list|enable|disable> N [addr…] --in|--out
|
||||
emcli config list # list all settings and descriptions
|
||||
emcli config set|get <key> [value] # e.g. audit_retention_days
|
||||
emcli audit list [account] [--limit K]
|
||||
emcli version
|
||||
|
||||
# Agent (requires EMCLI_KEY or EMCLI_ADMIN_KEY; one line of JSON each)
|
||||
emcli doctor [--account N] # connectivity/auth check
|
||||
emcli doctor [N | --account N] # connectivity/auth check
|
||||
emcli list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
|
||||
emcli get --account N [--folder F] --uid U
|
||||
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
// 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",
|
||||
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", "--name", "alerts", "--mode", "RO",
|
||||
run(t, "account", "add", "alerts", "--mode", "RO",
|
||||
"--imap-host", "imap.example.com", "--username", "alerts@example.com")
|
||||
|
||||
// Drop the admin key → caller is an agent.
|
||||
@@ -66,7 +66,7 @@ func TestAccountListAgentJSONView(t *testing.T) {
|
||||
// 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",
|
||||
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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+252
-105
@@ -1,32 +1,45 @@
|
||||
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 {
|
||||
// account list is an agent command (a JSON consumer), so its
|
||||
// open/key failures are emitted as an envelope, like the other agent
|
||||
// commands; the admin subcommands stay human-readable.
|
||||
if sub == "list" {
|
||||
_ = Failure(CodeConfig, err.Error()).Write(out)
|
||||
} else {
|
||||
@@ -38,12 +51,16 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
|
||||
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")
|
||||
@@ -55,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 {
|
||||
@@ -70,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")
|
||||
@@ -101,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":
|
||||
@@ -147,40 +167,55 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
acc.SubjectRegex = *subj
|
||||
}
|
||||
})
|
||||
// acc.Password holds the existing (decrypted) password from GetAccount; the
|
||||
// Visit above overwrites it only when --password was passed. UpdateAccount
|
||||
// re-seals whatever non-empty value is present, so the password is preserved.
|
||||
// GetAccount loaded the existing decrypted password into acc; fs.Visit
|
||||
// overwrites acc.Password only when --password was passed; UpdateAccount
|
||||
// re-seals whatever non-empty password is present, so omitting --password
|
||||
// on edit preserves the existing password unchanged.
|
||||
if err := st.UpdateAccount(acc); err != nil {
|
||||
fmt.Fprintf(errOut, "edit: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "account %q updated\n", *name)
|
||||
fmt.Fprintf(out, "account %q updated\n", name)
|
||||
return 0
|
||||
case "remove":
|
||||
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
name := fs.String("name", "", "account name (required)")
|
||||
yes := fs.Bool("yes", false, "skip confirmation")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
if len(rest) == 0 || strings.HasPrefix(rest[0], "-") {
|
||||
fmt.Fprintln(errOut, "usage: emcli account remove <name> [--yes]")
|
||||
return 2
|
||||
}
|
||||
if *name == "" {
|
||||
fmt.Fprintln(errOut, "--name is required")
|
||||
name := rest[0]
|
||||
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
yes := fs.Bool("yes", false, "skip confirmation")
|
||||
if err := fs.Parse(rest[1:]); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0))
|
||||
return 2
|
||||
}
|
||||
if !*yes {
|
||||
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
|
||||
return 2
|
||||
if !isatty.IsTerminal(os.Stdin.Fd()) {
|
||||
fmt.Fprintf(errOut, "refusing to remove %q without --yes (no terminal for confirmation)\n", name)
|
||||
return 2
|
||||
}
|
||||
if !confirmRemoval(name, out) {
|
||||
fmt.Fprintln(out, "aborted")
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err := st.DeleteAccount(*name); err != nil {
|
||||
if err := st.DeleteAccount(name); err != nil {
|
||||
fmt.Fprintf(errOut, "remove: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "account %q removed\n", *name)
|
||||
fmt.Fprintf(out, "account %q removed\n", name)
|
||||
return 0
|
||||
case "show":
|
||||
return accountShow(st, rest, out, errOut)
|
||||
case "list":
|
||||
// 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.
|
||||
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()
|
||||
@@ -196,9 +231,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
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",
|
||||
"name": a.Name, "from": a.SendFrom(), "can_send": a.Mode == "RW",
|
||||
})
|
||||
}
|
||||
_ = Success(map[string]any{"accounts": items}).Write(out)
|
||||
@@ -211,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
|
||||
}
|
||||
}
|
||||
@@ -231,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")
|
||||
@@ -240,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)
|
||||
@@ -253,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
|
||||
}
|
||||
}
|
||||
@@ -272,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)
|
||||
@@ -309,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")
|
||||
@@ -325,23 +400,42 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(errOut, "usage: emcli whitelist <in|out> <add|remove|list> [flags]")
|
||||
sub := normalizeVerb(args[0])
|
||||
switch sub {
|
||||
case "add", "remove", "list", "enable", "disable": // valid
|
||||
default:
|
||||
fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list|enable|disable)\n", sub)
|
||||
return 2
|
||||
}
|
||||
dir := store.Direction(args[0])
|
||||
sub, rest := args[1], args[2:]
|
||||
fs := flag.NewFlagSet("whitelist", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
account := fs.String("account", "", "account name")
|
||||
address := fs.String("address", "", "email or @domain")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
|
||||
// Split the remaining tokens into the direction flag and positionals.
|
||||
var dir store.Direction
|
||||
var dirSet bool
|
||||
var pos []string
|
||||
for _, a := range args[1:] {
|
||||
switch a {
|
||||
case "--in", "-in":
|
||||
dir, dirSet = store.DirIn, true
|
||||
case "--out", "-out":
|
||||
dir, dirSet = store.DirOut, true
|
||||
default:
|
||||
if strings.HasPrefix(a, "-") {
|
||||
fmt.Fprintf(errOut, "unknown flag %q (use --in or --out)\n", a)
|
||||
return 2
|
||||
}
|
||||
pos = append(pos, a)
|
||||
}
|
||||
}
|
||||
if !dirSet {
|
||||
fmt.Fprintln(errOut, "direction is required: pass --in or --out")
|
||||
return 2
|
||||
}
|
||||
if *account == "" {
|
||||
fmt.Fprintln(errOut, "--account is required")
|
||||
if len(pos) == 0 {
|
||||
fmt.Fprintln(errOut, "account is required")
|
||||
return 2
|
||||
}
|
||||
account, addrs := pos[0], pos[1:]
|
||||
|
||||
st, err := openStore(role)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
@@ -350,30 +444,83 @@ func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int {
|
||||
defer st.Close()
|
||||
|
||||
switch sub {
|
||||
case "add":
|
||||
if err := st.AddWhitelist(*account, dir, *address); err != nil {
|
||||
fmt.Fprintf(errOut, "add: %v\n", err)
|
||||
return 1
|
||||
case "add", "remove":
|
||||
if len(addrs) == 0 {
|
||||
fmt.Fprintln(errOut, "at least one address is required")
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintf(out, "added %s to %s whitelist of %q\n", *address, dir, *account)
|
||||
case "remove":
|
||||
if err := st.RemoveWhitelist(*account, dir, *address); err != nil {
|
||||
fmt.Fprintf(errOut, "remove: %v\n", err)
|
||||
return 1
|
||||
for _, addr := range addrs {
|
||||
if err := policy.ValidWhitelistAddress(addr); err != nil {
|
||||
fmt.Fprintln(errOut, err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, "removed %s\n", *address)
|
||||
for _, addr := range addrs {
|
||||
if sub == "add" {
|
||||
err = st.AddWhitelist(account, dir, addr)
|
||||
} else {
|
||||
err = st.RemoveWhitelist(account, dir, addr)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
verb := "added"
|
||||
if sub == "remove" {
|
||||
verb = "removed"
|
||||
}
|
||||
fmt.Fprintf(out, "%s %d address(es) in the %s whitelist of %q\n", verb, len(addrs), dir, account)
|
||||
return 0
|
||||
case "list":
|
||||
addrs, err := st.ListWhitelist(*account, dir)
|
||||
if len(addrs) > 0 {
|
||||
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
|
||||
return 2
|
||||
}
|
||||
acc, err := st.GetAccount(account)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "list: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, a := range addrs {
|
||||
enabled := acc.WhitelistInEnabled
|
||||
if dir == store.DirOut {
|
||||
enabled = acc.WhitelistOutEnabled
|
||||
}
|
||||
state := "DISABLED"
|
||||
if enabled {
|
||||
state = "ENABLED"
|
||||
}
|
||||
entries, err := st.ListWhitelist(account, dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "list: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "%s whitelist of %q: %s\n", dir, account, state)
|
||||
for _, a := range entries {
|
||||
fmt.Fprintln(out, a)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub)
|
||||
return 2
|
||||
return 0
|
||||
case "enable", "disable":
|
||||
if len(addrs) > 0 {
|
||||
fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0])
|
||||
return 2
|
||||
}
|
||||
enable := sub == "enable"
|
||||
if enable {
|
||||
if entries, _ := st.ListWhitelist(account, dir); len(entries) == 0 {
|
||||
fmt.Fprintf(errOut, "warning: %s whitelist for %q is empty — this blocks ALL %s mail until you add addresses\n", dir, account, flowName(dir))
|
||||
}
|
||||
}
|
||||
if err := st.SetWhitelistEnabled(account, dir, enable); err != nil {
|
||||
fmt.Fprintf(errOut, "%s: %v\n", sub, err)
|
||||
return 1
|
||||
}
|
||||
state := "disabled"
|
||||
if enable {
|
||||
state = "enabled"
|
||||
}
|
||||
fmt.Fprintf(out, "%s whitelist of %q %s\n", dir, account, state)
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
+120
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,11 @@ var agentCmds = []cmdHelp{
|
||||
|
||||
var adminCmds = []cmdHelp{
|
||||
{"init", "init", "Create the database and add the first account (interactive)."},
|
||||
{"account", "account <add|edit|remove|list> [flags]", "Manage accounts (add/edit accept flags, or run with none for an interactive form)."},
|
||||
{"whitelist", "whitelist <in|out> <add|remove|list> --account <name> [--address A]", "Manage inbound/outbound whitelists."},
|
||||
{"config", "config <set|get> <key> [value]", "Get or set global settings (e.g. audit_retention_days)."},
|
||||
{"audit", "audit list [--account <name>] [--limit N]", "Show recent audit-log entries."},
|
||||
{"doctor", "doctor [--account <name>]", "Check each account's IMAP/SMTP connectivity and auth."},
|
||||
{"account", "account <add|edit|remove|show|list> [name] [flags]", "Manage accounts. `add`/`edit` take a positional name + field flags, or run with none for an interactive form."},
|
||||
{"whitelist", "whitelist <add|remove|list|enable|disable> <account> [address…] --in|--out", "Manage inbound/outbound whitelists. Direction (--in/--out) is required."},
|
||||
{"config", "config <list|get|set> [key] [value]", "List, get, or set global settings (e.g. audit_retention_days)."},
|
||||
{"audit", "audit list [account] [--limit N]", "Show recent audit-log entries."},
|
||||
{"doctor", "doctor [account]", "Check each account's IMAP/SMTP connectivity and auth."},
|
||||
{"version", "version", "Print the emcli version."},
|
||||
{"help", "help [command]", "Show this help, or detailed usage for one command."},
|
||||
}
|
||||
@@ -58,6 +58,8 @@ func printMainHelp(w io.Writer) {
|
||||
fmt.Fprintf(w, " %-10s %s\n", c.name, c.summary)
|
||||
}
|
||||
fmt.Fprint(w, "\nRun \"emcli <command> --help\" for a command's flags.\n")
|
||||
fmt.Fprint(w, "\nAliases: rm/del = remove, ls = list. Admin commands take positional\n")
|
||||
fmt.Fprint(w, "operands (account/address/key); agent commands use flags (--account …).\n")
|
||||
fmt.Fprint(w, "\nEnvironment:\n")
|
||||
fmt.Fprint(w, " EMCLI_KEY base64-encoded 32-byte AES key; required for any command that uses the database\n")
|
||||
fmt.Fprint(w, " EMCLI_DB database path (default ~/.config/emcli/emcli.db; %AppData%\\emcli\\emcli.db on Windows)\n")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -32,7 +32,8 @@ func commandRole(args []string) store.Role {
|
||||
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" {
|
||||
// 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
|
||||
@@ -105,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)
|
||||
@@ -124,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
|
||||
@@ -143,9 +162,9 @@ func Run(args []string, out, errOut io.Writer) int {
|
||||
}
|
||||
cmd, rest := args[0], args[1:]
|
||||
role := commandRole(args)
|
||||
switch cmd {
|
||||
switch normalizeVerb(cmd) {
|
||||
case "list", "get", "search", "ack":
|
||||
return runAgent(cmd, rest, role, out, errOut)
|
||||
return runAgent(normalizeVerb(cmd), rest, role, out, errOut)
|
||||
case "send":
|
||||
return runSend(rest, role, out, errOut)
|
||||
case "account":
|
||||
|
||||
@@ -58,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="
|
||||
@@ -67,3 +81,43 @@ func b64AgentKey() string {
|
||||
// 32 bytes of 0x01, base64 — distinct from b64Key so slot mix-ups surface.
|
||||
return "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
|
||||
}
|
||||
|
||||
func TestTopLevelLsAlias(t *testing.T) {
|
||||
adminEnv(t)
|
||||
// `ls` with no --account must hit the same usage path as `list` (CodeUsage
|
||||
// envelope on stdout, exit 2) — proving it routed to the agent list command.
|
||||
code, out, _ := run(t, "ls")
|
||||
if code != 2 || !strings.Contains(out, "account") {
|
||||
t.Fatalf("ls should alias list (usage about --account): code=%d out=%q", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountLsAliasAgentRole verifies that `account ls` is treated as an agent
|
||||
// command (not admin) so a caller with only EMCLI_KEY can use it and gets the
|
||||
// same reduced-JSON envelope as `account list`.
|
||||
func TestAccountLsAliasAgentRole(t *testing.T) {
|
||||
adminEnv(t) // sets up both keys + initialized temp DB
|
||||
run(t, "account", "add", "work", "--mode", "RW",
|
||||
"--imap-host", "imap.example.com", "--smtp-host", "smtp.example.com",
|
||||
"--username", "login@example.com", "--from", "me@example.com")
|
||||
|
||||
// Drop the admin key — caller is agent-only.
|
||||
t.Setenv("EMCLI_ADMIN_KEY", "")
|
||||
|
||||
// `account ls` must succeed with the reduced JSON view, same as `account list`.
|
||||
code, out, errOut := run(t, "account", "ls")
|
||||
if code != 0 {
|
||||
t.Fatalf("agent account ls should succeed: code=%d out=%q err=%q", code, out, errOut)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &env); err != nil {
|
||||
t.Fatalf("account ls output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if env["error"] == true {
|
||||
t.Fatalf("account ls returned error envelope: %s", out)
|
||||
}
|
||||
// The agent view must not leak IMAP host or login username.
|
||||
if strings.Contains(out, "imap.example.com") || strings.Contains(out, "login@example.com") {
|
||||
t.Fatalf("account ls leaked host/username:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `EMCLI_VERSION` | `v0.5.0` | Release tag to fetch |
|
||||
| `EMCLI_VERSION` | `v0.6.0` | Release tag to fetch |
|
||||
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
|
||||
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
|
||||
|
||||
|
||||
@@ -7,17 +7,17 @@
|
||||
# bash install.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# EMCLI_VERSION release tag to fetch (default: v0.5.0)
|
||||
# EMCLI_VERSION release tag to fetch (default: v0.6.0)
|
||||
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
|
||||
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
|
||||
#
|
||||
# Release assets follow this naming scheme:
|
||||
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.5.0_linux_amd64
|
||||
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.6.0_linux_amd64
|
||||
# checksums.txt (sha256, one "<sum> <asset>" line per asset)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${EMCLI_VERSION:-v0.5.0}"
|
||||
VERSION="${EMCLI_VERSION:-v0.6.0}"
|
||||
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
|
||||
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user