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) <noreply@anthropic.com>
This commit is contained in:
+21
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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 <name> --folder <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 <name> --folder <folder> --uid <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 <name> --to <addr>… [--cc <addr>…] [--bcc <addr>…] --subject <s> --body <text> [--attach <path>]… [--reply-to <uid>]`**
|
||||||
|
- Sends a plain-text message via the account's SMTP endpoint.
|
||||||
|
- `--reply-to <uid>` 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 <name>`** — manage whitelist entries.
|
||||||
|
- **`emcli config set|get`** — global settings (e.g. `audit_retention_days`).
|
||||||
|
- **`emcli audit list [--account <name>] [--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:<ephemeral-port>`, 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.
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user