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>
This commit is contained in:
2026-07-07 14:28:38 +01:00
parent 4c0c6b94db
commit d023df1b4a
14 changed files with 645 additions and 34 deletions
+1 -1
View File
@@ -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.
+17 -3
View File
@@ -17,8 +17,8 @@ sets its exit code to match.
## Security model — read this first
- **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
- **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
@@ -117,7 +117,8 @@ 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 |
@@ -125,6 +126,19 @@ read-only — tell the user; do not attempt another account without their say-so
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.
+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.