Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8babdcfa88 | |||
| dfb434aec3 | |||
| d023df1b4a | |||
| 4c0c6b94db |
@@ -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
@@ -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
@@ -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]]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)."},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -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
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user