From 79b62b24c247fcb61194d1d8820b05c868a61155 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 21 Jun 2026 19:36:13 +0100 Subject: [PATCH] Initial commit: PRD and SPEC for emcli emcli is a Go CLI that mediates an AI agent's email access, enforcing per-account read/send restrictions so credentials never reach the agent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 21 ++++ README.md | 1 + specifications/PRD.md | 27 ++++ specifications/SPEC.md | 277 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 specifications/PRD.md create mode 100644 specifications/SPEC.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58293ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Binaries +/emcli +/dist/ +*.exe +*.test + +# Go +/vendor/ +*.out +coverage.* + +# Local config / secrets — never commit +*.db +*.sqlite +.env +.env.* + +# Editor / OS +.DS_Store +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..7f380ac --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# emcli diff --git a/specifications/PRD.md b/specifications/PRD.md new file mode 100644 index 0000000..6a7ae02 --- /dev/null +++ b/specifications/PRD.md @@ -0,0 +1,27 @@ +# emcli - PRD + +A CLI utility to allows an agent skill to send and receive IMAP emails *but* with only with enforced restrictions configured by the user +Agent never has access directly to IMAP account - everything filters through emcli +Email credentials are never exposed to Agent + +## The reason is exists + +Even with strong mandatory instructions, AI can still hallucinate - there is a need to protect against these hallucinations when using live emails + +## Required Functionality + +- single cross platform binary +- config in encrypted SQLite file (to ensure email credentials don't leak) +- encryption key held as ENV variable - should never be exposed to calling Agent +- multiple email account support +- mark an account as "RO" (read only) or "RW" (read and send) +- whitelist-in toggle (false = allow emails from anybody to be read, true = process only emails from whitelisted inbound email addresses) +- whitelist-out toggle (false = allow emails to be sent to anybody, true = send emails only to those in whitelisted outbound email addresses) +- subject filtering - blank to ignore subject, regex to only read specific emails matching a subject +- CLI output generally as structured JSON only (for Agentic use) +- Structured JSON would likely be boolean for error (true/false), JSON blob for error details (or empty if no error), JSON blob for returned data +- Admin functions (CRUD of email accounts, white lists and other config) would be human readable output and may be interactive +- "Pointer" to last read email per account held in config - Agent can ask for only new emails +- No graphical UI needed +- TUI used for init and (re)configuration +- TUI not used for reading emails etc. - we are not building a full email client \ No newline at end of file diff --git a/specifications/SPEC.md b/specifications/SPEC.md new file mode 100644 index 0000000..edaa856 --- /dev/null +++ b/specifications/SPEC.md @@ -0,0 +1,277 @@ +# emcli — Specification + +**Status:** Draft for implementation +**Source:** `specifications/PRD.md` +**Date:** 2026-06-21 + +## 1. Summary + +`emcli` is a single cross-platform Go binary that mediates all email access for an AI +agent. The agent never holds email credentials and never connects to IMAP/SMTP directly — +every read and send passes through `emcli`, which enforces user-configured restrictions. +The goal is to contain the blast radius of agent hallucination when operating on live +email: even with faulty instructions, the agent cannot read mail it is not permitted to +see or send mail to recipients it is not permitted to contact. + +## 2. Goals & non-goals + +### Goals +- Single static binary, no runtime dependencies, cross-compiled per OS/arch. +- Email credentials and OAuth tokens are encrypted at rest and never exposed to the agent. +- Per-account enforcement of read-only/read-write mode, inbound/outbound whitelists, and + subject filtering. +- Machine-readable JSON for agent commands; human-readable/interactive output for admin. +- Auditable history of agent actions with configurable retention. + +### Non-goals +- Not a full email client (no threading UI, no flags/labels management beyond what is + needed, no draft management). +- No graphical UI. TUI is used only for init and (re)configuration. +- No multi-user/server mode — `emcli` is a local utility invoked per-process. + +## 3. Technology choices + +| Concern | Choice | +| --- | --- | +| Language/runtime | Go (no CGO) | +| IMAP | `github.com/emersion/go-imap` | +| SMTP | `github.com/emersion/go-smtp` | +| MIME parsing/building | `github.com/emersion/go-message` | +| SASL / XOAUTH2 | `github.com/emersion/go-sasl` | +| SQLite | `modernc.org/sqlite` (pure Go) | +| TUI | `github.com/charmbracelet/bubbletea` (+ `lipgloss`) | +| Crypto | Go stdlib `crypto/aes` + `crypto/cipher` (AES-256-GCM) | + +Rationale: the emersion email libraries are mature and designed to be used together, +including XOAUTH2 for Gmail. Pure-Go SQLite avoids CGO so the binary is genuinely static +and trivially cross-compiled. + +## 4. Architecture + +The binary is organized into independently testable packages: + +- **`store`** — encrypted SQLite config and state. Owns the schema, migrations, and + field-level encryption of secret columns. +- **`policy`** — pure enforcement functions: mode (RO/RW), whitelist-in, whitelist-out, + subject regex, folder access. No I/O. The single gate every agent action passes through. +- **`mail`** — IMAP read and SMTP send, including SASL XOAUTH2 and password auth. +- **`oauth`** — loopback-redirect consent flow, refresh-token storage, automatic + access-token refresh. +- **`audit`** — append-only action log with retention-based purge. +- **`cli`** — command dispatch and the two output surfaces (agent JSON, admin + human-readable/TUI). + +### Trust boundary +- The agent invokes only the **agent commands** (Section 7.1). +- `EMCLI_KEY` is supplied by the environment/orchestrator that launches `emcli`, never as + an argument the agent constructs. The agent has no command that reveals secret values. +- All policy decisions happen inside `emcli`; the agent cannot bypass them because it has + no other path to the mail servers. + +## 5. Configuration & secrets + +- **Encryption key:** `EMCLI_KEY` env var, a base64-encoded 32-byte key (AES-256). If + absent or malformed, every command that touches the DB fails closed with an error + envelope; no plaintext fallback. +- **Database path:** `EMCLI_DB` env var; default `~/.config/emcli/emcli.db` + (`%AppData%\emcli\emcli.db` on Windows). +- **Field-level encryption:** secret columns are stored as AES-256-GCM ciphertext with a + random 96-bit nonce per value, prefixed to the ciphertext. Non-secret config remains + plaintext for debuggability. Decryption with the wrong key fails (GCM auth tag) and is + surfaced as an error, never silently ignored. + +Secret columns: account password, OAuth client secret, OAuth refresh token. + +## 6. Data model (SQLite) + +``` +accounts + id INTEGER PK + name TEXT UNIQUE -- agent-facing identifier + mode TEXT -- 'RO' | 'RW' + imap_host TEXT + imap_port INTEGER + imap_security TEXT -- 'tls' | 'starttls' + smtp_host TEXT -- nullable for RO accounts + smtp_port INTEGER + smtp_security TEXT -- 'tls' | 'starttls' + auth_type TEXT -- 'password' | 'oauth2' + username TEXT + enc_password BLOB -- encrypted (password auth) + enc_oauth_client_id BLOB -- encrypted (oauth2) + enc_oauth_client_secret BLOB -- encrypted (oauth2) + enc_oauth_refresh_token BLOB -- encrypted (oauth2) + whitelist_in_enabled INTEGER -- 0 | 1 + whitelist_out_enabled INTEGER -- 0 | 1 + subject_regex TEXT -- nullable; blank/null = no subject filter + +whitelist_in + account_id INTEGER FK + address TEXT -- exact addr or '@domain.com' + +whitelist_out + account_id INTEGER FK + address TEXT + +read_pointers + account_id INTEGER FK + folder TEXT + uidvalidity INTEGER + last_uid INTEGER + PRIMARY KEY (account_id, folder) + +audit_log + id INTEGER PK + ts TEXT -- RFC3339 UTC + account TEXT + action TEXT -- 'list' | 'get' | 'send' + target TEXT -- folder or recipient set + result TEXT -- 'allowed' | 'blocked' + reason TEXT -- nullable; populated on block + +settings + key TEXT PK + value TEXT + -- includes: audit_retention_days, schema_version +``` + +Notes: +- Folders are agent-specified; there is no folder whitelist. Read state is tracked per + `(account, folder)`. +- `read_pointers` stores `uidvalidity`; if the server reports a different `UIDVALIDITY` + for a folder than the stored value, the pointer is reset (treated as `last_uid = 0`) and + the new `uidvalidity` recorded. + +## 7. Command surface + +### 7.1 Agent commands (JSON output only) + +All agent commands emit a single JSON object (Section 8) and nothing else on stdout. + +**`emcli list --account --folder [--new] [--limit N]`** +- Returns message headers only: `uid`, `from`, `to`, `subject`, `date`, `message_id`, + `has_attachments`. +- `--new` returns only messages with `uid` greater than the stored pointer for + `(account, folder)`, then advances the pointer to the highest UID returned. +- Without `--new`, the pointer is not advanced. +- `--limit` caps the number of messages returned (default applied if omitted; see 7.3). +- Whitelist-in and subject-regex filtering are applied before results are returned + (Section 9). + +**`emcli get --account --folder --uid `** +- Returns full message: headers, decoded plain-text body, and attachments as + `{name, size, mime, content_b64}`. +- If the message is filtered by whitelist-in or subject-regex, returns an error envelope + (not-found) — the agent cannot retrieve filtered mail. + +**`emcli send --account --to … [--cc …] [--bcc …] --subject --body [--attach ]… [--reply-to ]`** +- Sends a plain-text message via the account's SMTP endpoint. +- `--reply-to ` fetches the source message's `Message-ID` and `References` and sets + `In-Reply-To`/`References` headers so the reply threads correctly. The referenced UID is + read from the same account (subject to inbound filtering — a filtered source UID cannot + be replied to). +- Enforcement: RO accounts are rejected; whitelist-out (if enabled) must pass for every + recipient across to/cc/bcc or the entire send is blocked (Section 9). + +### 7.2 Admin commands (human-readable / TUI) + +- **`emcli init`** — TUI flow: creates the DB (generating schema), adds the first account, + and runs OAuth consent if the account is OAuth2. +- **`emcli account add | edit | remove | list`** — interactive add/edit; `list` prints a + table (never secrets). +- **`emcli whitelist in|out add|remove|list --account `** — manage whitelist entries. +- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`). +- **`emcli audit list [--account ] [--limit N]`** — view recent audit entries. +- **`emcli doctor`** — verifies `EMCLI_KEY` is present and valid, the DB opens, and each + account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics. + +### 7.3 Defaults & limits +- `list --limit` default: 50; maximum: 500. +- Attachment handling in `get`: full base64 contents are returned. (No size cap in v1; the + caller is responsible for limits. Revisit if payloads prove unwieldy.) + +## 8. JSON output envelope + +Every agent command prints exactly one object: + +```json +{ + "error": false, + "error_detail": {}, + "data": {} +} +``` + +- `error` — boolean. +- `error_detail` — object; empty `{}` on success, otherwise `{ "code": "...", "message": "..." }`. + Never contains secret values. +- `data` — command-specific payload; `{}` or `[]` when not applicable. +- Process exit code mirrors `error` (0 on success, non-zero on error) for scripting, but + the JSON is authoritative. + +## 9. Enforcement semantics + +Enforcement lives entirely in the `policy` package and is exercised on every agent action. + +### Inbound (read: `list`, `get`) +- If `whitelist_in_enabled`, the message sender must match a `whitelist_in` entry. +- If `subject_regex` is set (non-empty), the subject must match the regex. +- A message that fails either check is **invisible**: excluded from `list` results and + not retrievable via `get` (returns not-found). The agent has no way to learn that the + message exists. + +### Outbound (send) +- If account `mode` is `RO`, `send` is rejected. +- If `whitelist_out_enabled`, every recipient (to + cc + bcc) must match a `whitelist_out` + entry. If any recipient fails, the **entire** send is blocked — no partial send. + +### Address matching (both directions) +- Case-insensitive. +- An entry of the form `@domain.com` matches any address at that domain. +- Any other entry matches a full address exactly. + +### Audit +- Every action (`list`, `get`, `send`), allowed or blocked, writes one `audit_log` row. +- Blocked actions record a `reason` (e.g. `ro_mode`, `whitelist_out`, `filtered`). +- On each run that opens the DB, audit rows older than `audit_retention_days` are purged. + +## 10. OAuth2 (Gmail and compatible) + +- The user supplies their own OAuth client ID/secret (registered in their Google Cloud + project) during account configuration. +- Consent uses the **loopback redirect** flow: `emcli` starts a temporary listener on + `127.0.0.1:`, opens the consent URL, captures the authorization code on + redirect, exchanges it for tokens, and stores the **refresh token** (encrypted). +- Access tokens are obtained/refreshed automatically before IMAP/SMTP use and held only in + memory. +- IMAP and SMTP authenticate via SASL XOAUTH2 using the current access token. + +## 11. Error handling + +- All agent-command failures return the JSON error envelope; they never crash with an + uncaught panic or emit partial non-JSON output on stdout. +- Categories: configuration/key errors, DB errors, network/connection errors, auth + errors, policy blocks, and not-found. Each maps to a stable `error_detail.code`. +- Secrets never appear in output, error details, or the audit log. + +## 12. Testing + +- **`policy`** — table-driven unit tests covering the matrix of mode × whitelist-in × + whitelist-out × subject-regex, including domain-match and case-insensitivity, and the + "any recipient fails ⇒ whole send blocked" rule. +- **`store`** — encryption round-trip; decryption with the wrong key fails closed; schema + migration; pointer reset on `UIDVALIDITY` change. +- **`mail`** — integration tests against a containerized IMAP/SMTP server (e.g. GreenMail + or Dovecot) for list/get/send and threading headers. +- **`oauth`** — token exchange/refresh against a mocked authorization server; loopback + capture logic unit-tested. +- **CLI** — golden-file tests of the JSON envelope for representative success and error + cases; assert no secret ever appears in output. + +## 13. Open items / future work + +- Optional attachment size cap on `get` if payloads prove unwieldy. +- Additional auth mechanisms (e.g. OAuth for non-Google providers) follow the same model. +- Whitelist semantics are currently per-account only; global defaults with overrides are + explicitly out of scope for v1. +```