4 Commits

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:25:34 +01:00
17 changed files with 665 additions and 45 deletions
+6 -2
View File
@@ -34,12 +34,16 @@ jobs:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
# Create the release for the pushed tag. # Create the release for the pushed tag. The annotated tag's message
# becomes the release description (empty for lightweight tags).
body=$(git tag -l --format='%(contents)' "${TAG}")
payload=$(jq -n --arg tag "${TAG}" --arg body "${body}" \
'{tag_name: $tag, name: $tag, body: $body}')
id=$(curl -fsSL -X POST \ id=$(curl -fsSL -X POST \
-H "Authorization: token ${TOKEN}" \ -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
"${SERVER}/api/v1/repos/${REPO}/releases" \ "${SERVER}/api/v1/repos/${REPO}/releases" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\"}" | jq -r '.id') -d "${payload}" | jq -r '.id')
echo "release id: ${id}" echo "release id: ${id}"
# Upload every built asset (binaries + checksums.txt). # Upload every built asset (binaries + checksums.txt).
for f in dist/*; do for f in dist/*; do
+9 -4
View File
@@ -46,11 +46,16 @@ published. If it ever doesn't fit your runner setup, fall back to Option A.
> public for `skills/emcli/scripts/install.sh` to fetch binaries without a token. A private repo > public for `skills/emcli/scripts/install.sh` to fetch binaries without a token. A private repo
> returns 404 to unauthenticated downloads. > returns 404 to unauthenticated downloads.
## After a release ## Skill-package version bump (do this in the `chore(release):` commit, before tagging)
The skill installer defaults to `EMCLI_VERSION=v0.4.1`. When you cut a different version, either The agent skill is versioned in lockstep with the binary. When cutting `vX.Y.Z`, update all three
publish under that tag or update the default in `skills/emcli/scripts/install.sh` (and the options of these so the tagged commit is self-consistent, then commit as
table in `skills/emcli/AGENTIC-MANUAL.md`). `chore(release): default installer to vX.Y.Z`:
- `skills/emcli/scripts/install.sh``VERSION="${EMCLI_VERSION:-vX.Y.Z}"` (and the example asset
name in the header comment).
- `skills/emcli/AGENTIC-MANUAL.md` — the `EMCLI_VERSION` default in the options table.
- `skills/emcli/SKILL.md``metadata.version: "X.Y.Z"` (no `v` prefix; mirrors the binary tag).
## Versioning ## Versioning
+34 -8
View File
@@ -36,7 +36,7 @@ This manual is for **using and administering** `emcli`. It assumes you have the
- **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require - **Admin commands** (`init`, `account add/edit/remove`, `whitelist`, `config`, `audit`) require
`EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an `EMCLI_ADMIN_KEY` and are for *you*, the human. They print human-readable text or open an
interactive form. (`account list` is the one exception — it is also an agent command; see below.) interactive form. (`account list` is the one exception — it is also an agent command; see below.)
- **Agent commands** (`list`, `get`, `search`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or - **Agent commands** (`list`, `get`, `search`, `folders`, `ack`, `send`, `doctor`) require `EMCLI_KEY` (or
`EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and `EMCLI_ADMIN_KEY` as a superset) and are for the *agent*. They print one line of JSON and
nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but nothing else, so a program can consume them reliably. (`doctor` prints human-readable text but
is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works is authorised by the agent key — `EMCLI_KEY` alone is sufficient; `EMCLI_ADMIN_KEY` also works
@@ -103,7 +103,7 @@ reconfigure accounts, whitelists, or audit settings.
| Key | Holder | Authorises | | Key | Holder | Authorises |
|---|---|---| |---|---|---|
| `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) | | `EMCLI_ADMIN_KEY` | Human / secrets manager | ALL commands (`account`, `whitelist`, `config`, `audit`, `init`, plus all agent commands) |
| `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `ack`, `send`, `doctor`) | | `EMCLI_KEY` | Agent orchestrator | Agent commands only (`list`, `get`, `search`, `folders`, `ack`, `send`, `doctor`) |
`EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands `EMCLI_ADMIN_KEY` is a strict superset: a process with only the admin key can run agent commands
too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY too. A process with only `EMCLI_KEY` is refused with `emcli: this command requires EMCLI_ADMIN_KEY
@@ -125,7 +125,7 @@ DEK for an admin command, even if it somehow knows the agent key.
| Command | Role required | | Command | Role required |
|---|---| |---|---|
| `list`, `get`, `search`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) | | `list`, `get`, `search`, `folders`, `ack`, `send`, `doctor`, `account list` | Agent (`EMCLI_KEY` or `EMCLI_ADMIN_KEY`) |
| `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) | | `account add/edit/remove`, `whitelist`, `config`, `audit` | Admin (`EMCLI_ADMIN_KEY` required) |
`account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table; `account list` is dual-role: with the admin key it prints the full `NAME MODE IMAP USER` table;
@@ -353,7 +353,7 @@ Clear it by passing an empty string (`--subject-regex ''`) or using the interact
These are what the agent runs. Each prints exactly one JSON object (see section 8). They all take These are what the agent runs. Each prints exactly one JSON object (see section 8). They all take
`--account` and most take `--folder` (default `INBOX`). `--account` and most take `--folder` (default `INBOX`).
> **Reading never changes state.** `list`, `get`, and `search` are read-only. Only `ack` advances > **Reading never changes state.** `list`, `get`, `search`, and `folders` are read-only. Only `ack` advances
> "what's been processed." > "what's been processed."
### `list` — message headers ### `list` — message headers
@@ -392,19 +392,44 @@ emcli search --account gmail --from boss@example.com
emcli search --account gmail --subject-contains invoice emcli search --account gmail --subject-contains invoice
emcli search --account gmail --text "quarterly report" emcli search --account gmail --text "quarterly report"
emcli search --account gmail --since-date 2026-01-01T00:00:00Z --before-date 2026-02-01T00:00:00Z emcli search --account gmail --since-date 2026-01-01T00:00:00Z --before-date 2026-02-01T00:00:00Z
emcli search --account gmail --subject-contains invoice --all-folders # sweep every mailbox
``` ```
| Flag | Meaning | | Flag | Meaning |
|---|---| |---|---|
| `--from` | Sender contains | | `--from` | Sender contains |
| `--subject-contains` | Subject contains | | `--subject-contains` | Subject contains |
| `--text` | Full-text search | | `--text` | Full-text search (best-effort — see below) |
| `--all-folders` | Search every selectable mailbox (mutually exclusive with `--folder`) |
| `--since-date` / `--before-date` | Date bounds, RFC 3339 (e.g. `2026-06-01T00:00:00Z`) | | `--since-date` / `--before-date` | Date bounds, RFC 3339 (e.g. `2026-06-01T00:00:00Z`) |
| `--limit` | Max results (default `50`) | | `--limit` | Max results (default `50`) |
Returns the same headers-only shape as `list`. Searches the whole folder, regardless of Returns the same headers-only shape as `list`. Searches the whole folder, regardless of
new/acked state. Filtered (whitelisted-out) mail never appears. new/acked state. Filtered (whitelisted-out) mail never appears.
With `--all-folders` each returned message also carries a `folder` field (UIDs are only unique
per folder — pass it back via `--folder` when using `get`/`ack` on a hit), and
`data.skipped_folders` lists any mailboxes the server refused to search. `--limit` caps total
results across the sweep.
`--text` depends on the server having a full-text index; some IMAP servers return zero results
for mail that `--subject-contains` finds. Prefer `--subject-contains`/`--from`; treat `--text` as
best-effort.
When a search returns no messages, `data.hint` suggests next steps (list folders, try
`--all-folders`, adjust criteria). The hint is deliberately generic: it is identical whether
matching mail is absent, in another folder, or hidden by the inbound whitelist.
### `folders` — list mailboxes
```bash
emcli folders --account gmail
```
Returns every mailbox the account can see: `name`, `delimiter`, and `selectable` (folders with
`selectable: false` are hierarchy placeholders and can't be used with `--folder`). INBOX sorts
first. Useful before searching mail that may be archived outside INBOX.
### `ack` — mark messages processed ### `ack` — mark messages processed
```bash ```bash
@@ -543,7 +568,7 @@ server settings are right.
### Audit log ### Audit log
Every agent action (`list`, `get`, `search`, `ack`, `send`) — allowed or blocked — is recorded. Every agent action (`list`, `get`, `search`, `folders`, `ack`, `send`) — allowed or blocked — is recorded.
```bash ```bash
emcli audit list # most recent 50 emcli audit list # most recent 50
@@ -573,7 +598,7 @@ are rejected by `config get` and `config set`.
## 11. Troubleshooting ## 11. Troubleshooting
**"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid **"EMCLI_KEY is not set" / "must be base64 of exactly 32 bytes".** Set `EMCLI_KEY` to a valid
base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `ack`, `send`, base64-encoded 32-byte key (section 2). Agent commands (`list`, `get`, `search`, `folders`, `ack`, `send`,
`doctor`) need this key. `doctor`) need this key.
**"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2). **"this command requires EMCLI_ADMIN_KEY (admin privilege)".** Set `EMCLI_ADMIN_KEY` (section 2).
@@ -633,7 +658,8 @@ emcli version
emcli doctor [N | --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 list --account N [--folder F] [--new] [--limit K] [--before U] [--since U]
emcli get --account N [--folder F] --uid U emcli get --account N [--folder F] --uid U
emcli search --account N [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K] emcli search --account N [--folder F | --all-folders] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit K]
emcli folders --account N
emcli ack --account N [--folder F] --uid-list U1,U2,U3 emcli ack --account N [--folder F] --uid-list U1,U2,U3
emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]] emcli send --account N --to A [--cc A] [--bcc A] --subject S --body B [--attach P]… [--reply-to U [--folder F]]
``` ```
+25
View File
@@ -0,0 +1,25 @@
Fixes identified from testing emcli email search (Morphy Richards purchase investigation).
1. No folder discovery command
emcli has no folders command, so the agent cannot enumerate available mailboxes. Searches default to INBOX only and miss archived mail (e.g. INBOX.Archive).
Fix: Add a folders agent command to list available mailboxes.
2. INBOX-only default too narrow
When searching for a specific email, the agent should ideally search across all folders, but cannot without knowing folder names.
Fix: Add an --all-folders flag to search to sweep all mailboxes at once.
3. --text full-text search unreliable on some IMAP servers
On myfamilyemail.co.uk, --text returned zero results for emails that --subject-contains found fine. The IMAP server either does not support full-text search or has an incomplete index.
Fix: Document this limitation in the skill; recommend --subject-contains as the primary search method and treat --text as best-effort.
4. No way to distinguish filtered vs missing vs wrong-folder
All three cases return empty results with no error, making it impossible for the agent to know whether mail is absent, hidden by a whitelist, or simply in another folder.
Fix: emcli (or the skill) should surface a diagnostic hint when results are empty — e.g. suggest running folders or retrying with --all-folders.
+110 -12
View File
@@ -21,6 +21,7 @@ type Mailer interface {
FetchHeadersRange(folder string, since, before uint32, limit int) ([]mail.Header, error) FetchHeadersRange(folder string, since, before uint32, limit int) ([]mail.Header, error)
FetchFull(folder string, uid uint32) (mail.Message, error) FetchFull(folder string, uid uint32) (mail.Message, error)
Search(folder string, sc mail.SearchCriteria, limit int) ([]mail.Header, error) Search(folder string, sc mail.SearchCriteria, limit int) ([]mail.Header, error)
ListFolders() ([]mail.FolderInfo, error)
Logout() error Logout() error
} }
@@ -50,18 +51,18 @@ func (d Deps) audit(account, action, target, result, reason string) {
}) })
} }
// setup loads the account, builds the inbound rule, dials IMAP, and selects the // connect loads the account, builds the inbound rule, and dials IMAP — no
// folder (establishing the baseline). Returns a cleanup func. // folder selection and no baseline side effects.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, uint32, func(), *Envelope) { func (d Deps) connect(account string) (store.Account, policy.InboundRule, Mailer, *Envelope) {
acc, err := d.Store.GetAccount(account) acc, err := d.Store.GetAccount(account)
if err != nil { if err != nil {
e := Failure(CodeNotFound, "account not found: "+account) e := Failure(CodeNotFound, "account not found: "+account)
return acc, policy.InboundRule{}, nil, 0, nil, &e return acc, policy.InboundRule{}, nil, &e
} }
re, err := policy.CompileSubject(acc.SubjectRegex) re, err := policy.CompileSubject(acc.SubjectRegex)
if err != nil { if err != nil {
e := Failure(CodeConfig, "invalid subject_regex: "+err.Error()) e := Failure(CodeConfig, "invalid subject_regex: "+err.Error())
return acc, policy.InboundRule{}, nil, 0, nil, &e return acc, policy.InboundRule{}, nil, &e
} }
wlIn, _ := d.Store.ListWhitelist(account, store.DirIn) wlIn, _ := d.Store.ListWhitelist(account, store.DirIn)
rule := policy.InboundRule{ rule := policy.InboundRule{
@@ -72,7 +73,17 @@ func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule,
m, err := d.Dial(acc) m, err := d.Dial(acc)
if err != nil { if err != nil {
e := Failure(CodeNetwork, "imap connect failed: "+err.Error()) e := Failure(CodeNetwork, "imap connect failed: "+err.Error())
return acc, rule, nil, 0, nil, &e return acc, rule, nil, &e
}
return acc, rule, m, nil
}
// setup loads the account, builds the inbound rule, dials IMAP, and selects the
// folder (establishing the baseline). Returns a cleanup func.
func (d Deps) setup(account, folder string) (store.Account, policy.InboundRule, Mailer, uint32, func(), *Envelope) {
acc, rule, m, fail := d.connect(account)
if fail != nil {
return acc, rule, nil, 0, nil, fail
} }
uidv, maxUID, err := m.SelectFolder(folder) uidv, maxUID, err := m.SelectFolder(folder)
if err != nil { if err != nil {
@@ -184,6 +195,41 @@ func GetCmd(d Deps, account, folder string, uid uint32) error {
})) }))
} }
// Empty-search hints. These MUST stay constant per command variant: the same
// text is emitted whether matching mail is absent, in another folder, or
// hidden by inbound policy, so an empty result reveals nothing about
// filtering (see the invisibility invariant in the skill docs).
const (
searchEmptyHint = "no matches in this folder; mail may be in another mailbox — " +
"run 'folders' to list mailboxes or retry with --all-folders; " +
"note the account's inbound policy may hide some messages " +
"(hidden and non-existent messages are indistinguishable)"
searchAllEmptyHint = "no matches in any folder; try adjusting criteria — " +
"--subject-contains is more reliable than --text on some servers; " +
"note the account's inbound policy may hide some messages " +
"(hidden and non-existent messages are indistinguishable)"
)
// appendVisible appends policy-visible headers to out, up to limit total
// visible results (0 = uncapped). A non-empty folder tags each message with
// its mailbox (needed when sweeping: UIDs are only unique per folder).
func appendVisible(out []map[string]any, rule policy.InboundRule, headers []mail.Header, limit int, folder string) []map[string]any {
for _, h := range headers {
if !rule.Allows(h.From, h.Subject) {
continue
}
m := headerMap(h)
if folder != "" {
m["folder"] = folder
}
out = append(out, m)
if limit > 0 && len(out) >= limit {
break
}
}
return out
}
func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int) error { func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int) error {
_, rule, m, _, done, fail := d.setup(account, folder) _, rule, m, _, done, fail := d.setup(account, folder)
if fail != nil { if fail != nil {
@@ -194,18 +240,70 @@ func SearchCmd(d Deps, account, folder string, sc mail.SearchCriteria, limit int
if err != nil { if err != nil {
return d.emit(Failure(CodeNetwork, err.Error())) return d.emit(Failure(CodeNetwork, err.Error()))
} }
out := make([]map[string]any, 0, len(headers)) out := appendVisible(make([]map[string]any, 0, len(headers)), rule, headers, limit, "")
for _, h := range headers { d.audit(account, "search", folder, "allowed", "")
if !rule.Allows(h.From, h.Subject) { data := map[string]any{"messages": out}
if len(out) == 0 {
data["hint"] = searchEmptyHint
}
return d.emit(Success(data))
}
// SearchAllCmd sweeps every selectable folder. It deliberately skips
// EnsureFolderBaseline: baselining folders the agent never listed would mark
// their existing mail "not new" as a side effect of a read command.
func SearchAllCmd(d Deps, account string, sc mail.SearchCriteria, limit int) error {
_, rule, m, fail := d.connect(account)
if fail != nil {
return d.emit(*fail)
}
defer m.Logout()
folders, err := m.ListFolders()
if err != nil {
return d.emit(Failure(CodeNetwork, "list folders failed: "+err.Error()))
}
out := make([]map[string]any, 0)
skipped := make([]string, 0)
for _, f := range folders {
if !f.Selectable {
continue continue
} }
out = append(out, headerMap(h))
if limit > 0 && len(out) >= limit { if limit > 0 && len(out) >= limit {
break break
} }
headers, err := m.Search(f.Name, sc, 0)
if err != nil {
skipped = append(skipped, f.Name)
continue
} }
d.audit(account, "search", folder, "allowed", "") out = appendVisible(out, rule, headers, limit, f.Name)
return d.emit(Success(map[string]any{"messages": out})) }
d.audit(account, "search", "*", "allowed", "")
data := map[string]any{"messages": out, "skipped_folders": skipped}
if len(out) == 0 {
data["hint"] = searchAllEmptyHint
}
return d.emit(Success(data))
}
func FoldersCmd(d Deps, account string) error {
_, _, m, fail := d.connect(account)
if fail != nil {
return d.emit(*fail)
}
defer m.Logout()
folders, err := m.ListFolders()
if err != nil {
return d.emit(Failure(CodeNetwork, err.Error()))
}
out := make([]map[string]any, 0, len(folders))
for _, f := range folders {
out = append(out, map[string]any{
"name": f.Name, "delimiter": f.Delimiter, "selectable": f.Selectable,
})
}
d.audit(account, "folders", "", "allowed", "")
return d.emit(Success(map[string]any{"folders": out}))
} }
func AckCmd(d Deps, account, folder string, uids []uint32) error { func AckCmd(d Deps, account, folder string, uids []uint32) error {
+252 -1
View File
@@ -16,6 +16,13 @@ type fakeMailer struct {
maxUID uint32 maxUID uint32
headers []mail.Header headers []mail.Header
full map[uint32]mail.Message full map[uint32]mail.Message
// Multi-folder fields, used by folders/--all-folders tests. When
// headersByFolder is non-nil Search consults it instead of headers.
folders []mail.FolderInfo
headersByFolder map[string][]mail.Header
searchErr map[string]error
searched []string
} }
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) { func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) {
@@ -43,9 +50,19 @@ func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Head
func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) { func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) {
return f.full[uid], nil return f.full[uid], nil
} }
func (f *fakeMailer) Search(string, mail.SearchCriteria, int) ([]mail.Header, error) { func (f *fakeMailer) Search(folder string, _ mail.SearchCriteria, _ int) ([]mail.Header, error) {
f.searched = append(f.searched, folder)
if err := f.searchErr[folder]; err != nil {
return nil, err
}
if f.headersByFolder != nil {
return f.headersByFolder[folder], nil
}
return f.headers, nil return f.headers, nil
} }
func (f *fakeMailer) ListFolders() ([]mail.FolderInfo, error) {
return f.folders, nil
}
func (f *fakeMailer) Logout() error { return nil } func (f *fakeMailer) Logout() error { return nil }
func testKey() []byte { func testKey() []byte {
@@ -219,3 +236,237 @@ func TestAckAdvancesStateAndFiltered(t *testing.T) {
t.Fatalf("want 0 new messages, got %d", len(msgs)) t.Fatalf("want 0 new messages, got %d", len(msgs))
} }
} }
func TestFoldersListsMailboxes(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
{Name: "Public", Delimiter: ".", Selectable: false},
},
}
d, buf := newDeps(t, fm)
if err := FoldersCmd(d, "work"); err != nil {
t.Fatalf("FoldersCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
data := res["data"].(map[string]any)
folders := data["folders"].([]any)
if len(folders) != 3 {
t.Fatalf("want 3 folders, got %d: %v", len(folders), folders)
}
first := folders[0].(map[string]any)
if first["name"] != "INBOX" || first["delimiter"] != "." || first["selectable"] != true {
t.Fatalf("unexpected first folder: %v", first)
}
last := folders[2].(map[string]any)
if last["name"] != "Public" || last["selectable"] != false {
t.Fatalf("unexpected last folder: %v", last)
}
}
func TestSearchAllFoldersTagsFolderAndFilters(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
},
headersByFolder: map[string][]mail.Header{
"INBOX": {
{UID: 1, From: "a@trusted.com", Subject: "one"},
{UID: 2, From: "x@evil.com", Subject: "spam"}, // filtered
},
"INBOX.Archive": {
{UID: 7, From: "b@trusted.com", Subject: "two"},
},
},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("want 2 visible messages, got %d: %v", len(msgs), msgs)
}
m0 := msgs[0].(map[string]any)
m1 := msgs[1].(map[string]any)
if m0["folder"] != "INBOX" || m1["folder"] != "INBOX.Archive" {
t.Fatalf("wrong folder tags: %v / %v", m0["folder"], m1["folder"])
}
skipped := data["skipped_folders"].([]any)
if len(skipped) != 0 {
t.Fatalf("want no skipped folders, got %v", skipped)
}
}
func TestSearchAllFoldersSkipsNoselectAndErrors(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "Broken", Delimiter: ".", Selectable: true},
{Name: "Public", Delimiter: ".", Selectable: false},
},
headersByFolder: map[string][]mail.Header{
"INBOX": {{UID: 1, From: "a@trusted.com", Subject: "one"}},
},
searchErr: map[string]error{"Broken": errCommandFailed},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
for _, f := range fm.searched {
if f == "Public" {
t.Fatal("searched a \\Noselect folder")
}
}
data := res["data"].(map[string]any)
if got := len(data["messages"].([]any)); got != 1 {
t.Fatalf("want 1 message, got %d", got)
}
skipped := data["skipped_folders"].([]any)
if len(skipped) != 1 || skipped[0] != "Broken" {
t.Fatalf("want skipped_folders [Broken], got %v", skipped)
}
}
func TestSearchAllFoldersLimitCountsVisibleAcrossFolders(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX", Delimiter: ".", Selectable: true},
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
{Name: "INBOX.Sent", Delimiter: ".", Selectable: true},
},
headersByFolder: map[string][]mail.Header{
"INBOX": {
{UID: 1, From: "a@trusted.com", Subject: "one"},
{UID: 2, From: "x@evil.com", Subject: "spam"}, // filtered
},
"INBOX.Archive": {
{UID: 3, From: "y@evil.com", Subject: "spam"}, // filtered
{UID: 4, From: "b@trusted.com", Subject: "two"},
},
"INBOX.Sent": {
{UID: 5, From: "c@trusted.com", Subject: "three"},
},
},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 2); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
res := decode(t, buf.Bytes())
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("want exactly 2 visible messages, got %d: %v", len(msgs), msgs)
}
for _, f := range fm.searched {
if f == "INBOX.Sent" {
t.Fatal("searched a folder after the visible limit was reached")
}
}
}
func TestSearchAllFoldersDoesNotBaseline(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{
{Name: "INBOX.Archive", Delimiter: ".", Selectable: true},
},
headersByFolder: map[string][]mail.Header{
"INBOX.Archive": {{UID: 7, From: "b@trusted.com", Subject: "two"}},
},
}
d, _ := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
// IsNew errors (no folder_state row) iff the sweep did not baseline.
if _, err := d.Store.IsNew("work", "INBOX.Archive", 7); err == nil {
t.Fatal("sweep must not create a folder_state baseline for swept folders")
}
}
func TestSearchEmptyEmitsHint(t *testing.T) {
fm := &fakeMailer{uidValidity: 1, maxUID: 5}
d, buf := newDeps(t, fm)
if err := SearchCmd(d, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd: %v", err)
}
data := decode(t, buf.Bytes())["data"].(map[string]any)
if hint, ok := data["hint"].(string); !ok || hint == "" {
t.Fatalf("want non-empty hint on empty result, got %v", data["hint"])
}
// Non-empty results must omit the hint.
fm2 := &fakeMailer{
uidValidity: 1, maxUID: 5,
headers: []mail.Header{{UID: 1, From: "a@trusted.com", Subject: "one"}},
}
d2, buf2 := newDeps(t, fm2)
if err := SearchCmd(d2, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd: %v", err)
}
data2 := decode(t, buf2.Bytes())["data"].(map[string]any)
if _, ok := data2["hint"]; ok {
t.Fatalf("hint must be absent when messages exist, got %v", data2["hint"])
}
}
func TestSearchAllFoldersEmptyEmitsHint(t *testing.T) {
fm := &fakeMailer{
folders: []mail.FolderInfo{{Name: "INBOX", Delimiter: ".", Selectable: true}},
headersByFolder: map[string][]mail.Header{},
}
d, buf := newDeps(t, fm)
if err := SearchAllCmd(d, "work", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchAllCmd: %v", err)
}
data := decode(t, buf.Bytes())["data"].(map[string]any)
if hint, ok := data["hint"].(string); !ok || hint == "" {
t.Fatalf("want non-empty hint on empty sweep, got %v", data["hint"])
}
}
// TestSearchEmptyHintIndistinguishableFromFiltered codifies the invisibility
// invariant for the empty-search hint: the envelope for "no mail at all" must
// be byte-identical to the envelope for "mail exists but is all filtered", so
// an empty result reveals nothing about inbound policy. Do not weaken this to
// a structural comparison.
func TestSearchEmptyHintIndistinguishableFromFiltered(t *testing.T) {
absent := &fakeMailer{uidValidity: 1, maxUID: 5}
dAbsent, bufAbsent := newDeps(t, absent)
if err := SearchCmd(dAbsent, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd (absent): %v", err)
}
filtered := &fakeMailer{
uidValidity: 1, maxUID: 5,
headers: []mail.Header{
{UID: 1, From: "x@evil.com", Subject: "spam1"},
{UID: 2, From: "y@evil.com", Subject: "spam2"},
},
}
dFiltered, bufFiltered := newDeps(t, filtered)
if err := SearchCmd(dFiltered, "work", "INBOX", mail.SearchCriteria{}, 50); err != nil {
t.Fatalf("SearchCmd (filtered): %v", err)
}
if !bytes.Equal(bufAbsent.Bytes(), bufFiltered.Bytes()) {
t.Fatalf("empty-vs-filtered envelopes differ:\nabsent: %s\nfiltered: %s",
bufAbsent.Bytes(), bufFiltered.Bytes())
}
}
+2 -1
View File
@@ -16,7 +16,8 @@ type cmdHelp struct {
var agentCmds = []cmdHelp{ var agentCmds = []cmdHelp{
{"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."}, {"list", "list --account <name> [--folder F] [--new] [--limit N] [--before U] [--since U]", "List message headers, newest first."},
{"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."}, {"get", "get --account <name> [--folder F] --uid <uid>", "Fetch one full message (body + attachments)."},
{"search", "search --account <name> [--folder F] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."}, {"search", "search --account <name> [--folder F | --all-folders] [--from A] [--subject-contains S] [--text S] [--since-date D] [--before-date D] [--limit N]", "Server-side IMAP search."},
{"folders", "folders --account <name>", "List the account's mailboxes/folders."},
{"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."}, {"ack", "ack --account <name> [--folder F] --uid-list U1,U2,…", "Mark message(s) processed."},
{"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."}, {"send", "send --account <name> --to A… [--cc A…] [--bcc A…] --subject S --body B [--attach P]… [--reply-to U [--folder F]]", "Send or reply (RW accounts only)."},
} }
+1 -1
View File
@@ -15,7 +15,7 @@ func TestMainHelpListsAllCommands(t *testing.T) {
t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text) t.Fatalf("%v: want exit 0, got %d\n%s", args, code, text)
} }
for _, want := range []string{ for _, want := range []string{
"Usage", "list", "get", "search", "ack", "send", "Usage", "list", "get", "search", "ack", "send", "folders",
"account", "whitelist", "config", "audit", "doctor", "version", "account", "whitelist", "config", "audit", "doctor", "version",
"EMCLI_KEY", "EMCLI_DB", "EMCLI_KEY", "EMCLI_DB",
} { } {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestCommandRole(t *testing.T) {
{"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"}, {"account"}, {"account", "add"}, {"account", "edit"}, {"account", "remove"},
} }
agentCmds := [][]string{ agentCmds := [][]string{
{"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"}, {"list"}, {"get"}, {"search"}, {"ack"}, {"send"}, {"doctor"}, {"folders"},
{"account", "list"}, {"account", "list"},
} }
for _, c := range adminCmds { for _, c := range adminCmds {
+54 -1
View File
@@ -165,6 +165,8 @@ func Run(args []string, out, errOut io.Writer) int {
switch normalizeVerb(cmd) { switch normalizeVerb(cmd) {
case "list", "get", "search", "ack": case "list", "get", "search", "ack":
return runAgent(normalizeVerb(cmd), rest, role, out, errOut) return runAgent(normalizeVerb(cmd), rest, role, out, errOut)
case "folders":
return runFolders(rest, role, out, errOut)
case "send": case "send":
return runSend(rest, role, out, errOut) return runSend(rest, role, out, errOut)
case "account": case "account":
@@ -185,6 +187,37 @@ func Run(args []string, out, errOut io.Writer) int {
} }
} }
// runFolders handles `folders --account <name>` — list the account's mailboxes.
func runFolders(args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet("folders", flag.ContinueOnError)
fs.SetOutput(errOut)
usageFlags(fs, "folders", errOut)
account := fs.String("account", "", "account name")
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
_ = Failure(CodeUsage, err.Error()).Write(out)
return 2
}
if *account == "" {
_ = Failure(CodeUsage, "--account is required").Write(out)
return 2
}
st, err := openStore(role)
if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out)
return 1
}
defer st.Close()
_, _ = st.PurgeAudit(time.Now())
d := newDepsLive(st, out)
if err := FoldersCmd(d, *account); err != nil {
return 1
}
return 0
}
// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes. // runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes.
func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int { func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer) int {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError) fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
@@ -203,6 +236,10 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
sinceDate := fs.String("since-date", "", "search: RFC3339 date lower bound") sinceDate := fs.String("since-date", "", "search: RFC3339 date lower bound")
beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound") beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound")
ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs") ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs")
allFolders := new(bool)
if cmd == "search" {
allFolders = fs.Bool("all-folders", false, "search: sweep all mailboxes")
}
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) { if errors.Is(err, flag.ErrHelp) {
return 0 // usage already printed to stderr; help isn't an error return 0 // usage already printed to stderr; help isn't an error
@@ -217,6 +254,18 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
_ = Failure(CodeUsage, "--account is required").Write(out) _ = Failure(CodeUsage, "--account is required").Write(out)
return 2 return 2
} }
if *allFolders {
folderSet := false
fs.Visit(func(f *flag.Flag) {
if f.Name == "folder" {
folderSet = true
}
})
if folderSet {
_ = Failure(CodeUsage, "--folder and --all-folders are mutually exclusive").Write(out)
return 2
}
}
st, err := openStore(role) st, err := openStore(role)
if err != nil { if err != nil {
_ = Failure(CodeConfig, err.Error()).Write(out) _ = Failure(CodeConfig, err.Error()).Write(out)
@@ -251,7 +300,11 @@ func runAgent(cmd string, args []string, role store.Role, out, errOut io.Writer)
sc.Before = tm sc.Before = tm
} }
} }
if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil { if *allFolders {
if err := SearchAllCmd(d, *account, sc, *limit); err != nil {
return 1
}
} else if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil {
return 1 return 1
} }
case "ack": case "ack":
+44
View File
@@ -121,3 +121,47 @@ func TestAccountLsAliasAgentRole(t *testing.T) {
t.Fatalf("account ls leaked host/username:\n%s", out) t.Fatalf("account ls leaked host/username:\n%s", out)
} }
} }
func TestSearchAllFoldersConflictsWithFolder(t *testing.T) {
adminEnv(t)
code, out, _ := run(t, "search", "--account", "x", "--folder", "F", "--all-folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func TestAllFoldersRejectedForList(t *testing.T) {
adminEnv(t)
// --all-folders is only registered for search; list must reject it at
// flag-parse time with a JSON usage envelope.
code, out, _ := run(t, "list", "--account", "x", "--all-folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
if env["error"] != true {
t.Fatalf("want error envelope: %v", env)
}
}
func TestFoldersRequiresAccount(t *testing.T) {
adminEnv(t)
code, out, _ := run(t, "folders")
if code != 2 {
t.Fatalf("want usage exit 2, got %d (out=%q)", code, out)
}
var env map[string]any
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("usage error must be a JSON envelope, got %q", out)
}
}
+39
View File
@@ -52,6 +52,45 @@ func Dial(cfg IMAPConfig) (*Client, error) {
func (c *Client) Logout() error { return c.c.Logout() } func (c *Client) Logout() error { return c.c.Logout() }
// FolderInfo describes one mailbox as reported by the server. Selectable is
// false for hierarchy-only entries (\Noselect) that cannot be passed to SELECT.
type FolderInfo struct {
Name string
Delimiter string
Selectable bool
}
// ListFolders enumerates all mailboxes, INBOX first, then case-insensitive
// name order.
func (c *Client) ListFolders() ([]FolderInfo, error) {
infoCh := make(chan *imap.MailboxInfo, 16)
done := make(chan error, 1)
go func() { done <- c.c.List("", "*", infoCh) }()
var out []FolderInfo
for info := range infoCh {
selectable := true
for _, attr := range info.Attributes {
if strings.EqualFold(attr, imap.NoSelectAttr) {
selectable = false
break
}
}
out = append(out, FolderInfo{Name: info.Name, Delimiter: info.Delimiter, Selectable: selectable})
}
if err := <-done; err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool {
ii, ji := out[i].Name == "INBOX", out[j].Name == "INBOX"
if ii != ji {
return ii
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
func (c *Client) SelectFolder(folder string) (uint32, uint32, error) { func (c *Client) SelectFolder(folder string) (uint32, uint32, error) {
mbox, err := c.c.Select(folder, true) // read-only select mbox, err := c.c.Select(folder, true) // read-only select
if err != nil { if err != nil {
+19
View File
@@ -49,6 +49,25 @@ func TestSelectAndFetch(t *testing.T) {
t.Logf("inbox has %d messages, maxUID=%d", len(headers), maxUID) t.Logf("inbox has %d messages, maxUID=%d", len(headers), maxUID)
} }
func TestListFolders(t *testing.T) {
c, err := Dial(testCfg(t))
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer c.Logout()
folders, err := c.ListFolders()
if err != nil {
t.Fatalf("ListFolders: %v", err)
}
if len(folders) == 0 {
t.Fatal("expected at least one folder")
}
if folders[0].Name != "INBOX" || !folders[0].Selectable {
t.Fatalf("expected selectable INBOX first, got %+v", folders[0])
}
}
func atoiEnv(t *testing.T, k string) int { func atoiEnv(t *testing.T, k string) int {
t.Helper() t.Helper()
n, err := strconv.Atoi(os.Getenv(k)) n, err := strconv.Atoi(os.Getenv(k))
+2 -2
View File
@@ -54,7 +54,7 @@ checksum, makes it executable in `~/.local/bin` (ensure that's on your PATH), an
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `EMCLI_VERSION` | `v0.6.0` | Release tag to fetch | | `EMCLI_VERSION` | `v0.7.0` | Release tag to fetch |
| `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL | | `EMCLI_BASE_URL` | `https://gitea.dcglab.co.uk/steve/emcli` | Repo base URL |
| `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location | | `EMCLI_INSTALL_DIR` | `$HOME/.local/bin` | Install location |
@@ -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 ## 3. Confirm the agent key is present
emcli uses two keys; **you (the agent) are given only `EMCLI_KEY`** (the agent key). It authorises 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`. 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. For agent use, **the orchestrator that launched you provides `EMCLI_KEY`** in the environment.
+18 -4
View File
@@ -4,7 +4,7 @@ description: Read and send email through emcli, a guard-railed CLI gateway that
compatibility: Requires the emcli binary (run scripts/install.sh to fetch it; needs curl or wget and a Linux/macOS/Windows shell) and the EMCLI_KEY environment variable, which the orchestrator provides. Needs network access to the configured mail server. compatibility: Requires the emcli binary (run scripts/install.sh to fetch it; needs curl or wget and a Linux/macOS/Windows shell) and the EMCLI_KEY environment variable, which the orchestrator provides. Needs network access to the configured mail server.
metadata: metadata:
author: steve author: steve
version: "0.4.0" version: "0.7.0"
homepage: "https://gitea.dcglab.co.uk/steve/emcli" homepage: "https://gitea.dcglab.co.uk/steve/emcli"
--- ---
@@ -17,8 +17,8 @@ sets its exit code to match.
## Security model — read this first ## Security model — read this first
- **You only run agent commands:** `list`, `get`, `search`, `ack`, `send`, `doctor`, and - **You only run agent commands:** `list`, `get`, `search`, `folders`, `ack`, `send`, `doctor`,
`account list` (to discover accounts). You are provided only `EMCLI_KEY` (the agent key), which 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, 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`) — 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 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 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 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 ack --account A [--folder F] --uid-list U1,U2` | Mark message(s) processed |
| `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply | | `emcli send --account A --to X [--cc X] [--bcc X] --subject S --body B [--attach P]… [--reply-to U]` | Send / reply |
| `emcli account list` | Discover accounts: JSON `name` / `from` / `can_send` per account | | `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. Defaults: `--folder INBOX`, `--limit 50` (max 500). Dates are RFC 3339 (e.g.
`2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output. `2026-06-01T00:00:00Z`). UIDs come from `list`/`search` output.
### 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 **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 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. 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 # 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 exit code (0 success, non-zero error). All take `--account <name>`; most take `--folder` (default
`INBOX`). `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] [--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 | | `--from` | Sender contains |
| `--subject-contains` | Subject 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` | | `--since-date` / `--before-date` | RFC 3339 bounds, e.g. `2026-06-01T00:00:00Z` |
| `--limit` | Max results (default 50) | | `--limit` | Max results (default 50) |
`data` shape is identical to `list` (`{ "messages": [ … ] }`). Searches the whole folder regardless `data` shape is identical to `list` (`{ "messages": [ … ] }`). Searches the whole folder regardless
of new/acked state. Filtered mail never appears. 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 ## `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. - **Mode:** `RO` accounts reject `send`. `RW` can read and send.
- **Inbound whitelist / subject filter:** disallowed mail is invisible everywhere (`list`/`search` - **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 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. - **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 - **Address matching:** case-insensitive; an entry `@domain.com` matches any address at that
domain; otherwise an exact-address match. domain; otherwise an exact-address match.
+3 -3
View File
@@ -7,17 +7,17 @@
# bash install.sh # bash install.sh
# #
# Environment overrides: # Environment overrides:
# EMCLI_VERSION release tag to fetch (default: v0.6.0) # EMCLI_VERSION release tag to fetch (default: v0.7.0)
# EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli) # EMCLI_BASE_URL repo base URL (default: https://gitea.dcglab.co.uk/steve/emcli)
# EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin) # EMCLI_INSTALL_DIR where to put the binary (default: $HOME/.local/bin)
# #
# Release assets follow this naming scheme: # Release assets follow this naming scheme:
# emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.6.0_linux_amd64 # emcli_<version>_<os>_<arch>[.exe] e.g. emcli_0.7.0_linux_amd64
# checksums.txt (sha256, one "<sum> <asset>" line per asset) # checksums.txt (sha256, one "<sum> <asset>" line per asset)
set -euo pipefail set -euo pipefail
VERSION="${EMCLI_VERSION:-v0.6.0}" VERSION="${EMCLI_VERSION:-v0.7.0}"
BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}" BASE_URL="${EMCLI_BASE_URL:-https://gitea.dcglab.co.uk/steve/emcli}"
INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}" INSTALL_DIR="${EMCLI_INSTALL_DIR:-$HOME/.local/bin}"