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>
12 KiB
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 —
emcliis 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_KEYis supplied by the environment/orchestrator that launchesemcli, 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_KEYenv 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_DBenv var; default~/.config/emcli/emcli.db(%AppData%\emcli\emcli.dbon 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_pointersstoresuidvalidity; if the server reports a differentUIDVALIDITYfor a folder than the stored value, the pointer is reset (treated aslast_uid = 0) and the newuidvalidityrecorded.
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. --newreturns only messages withuidgreater than the stored pointer for(account, folder), then advances the pointer to the highest UID returned.- Without
--new, the pointer is not advanced. --limitcaps 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'sMessage-IDandReferencesand setsIn-Reply-To/Referencesheaders 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;listprints 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— verifiesEMCLI_KEYis present and valid, the DB opens, and each account's IMAP/SMTP connectivity and auth succeed. Human-readable diagnostics.
7.3 Defaults & limits
list --limitdefault: 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:
{
"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 awhitelist_inentry. - If
subject_regexis set (non-empty), the subject must match the regex. - A message that fails either check is invisible: excluded from
listresults and not retrievable viaget(returns not-found). The agent has no way to learn that the message exists.
Outbound (send)
- If account
modeisRO,sendis rejected. - If
whitelist_out_enabled, every recipient (to + cc + bcc) must match awhitelist_outentry. If any recipient fails, the entire send is blocked — no partial send.
Address matching (both directions)
- Case-insensitive.
- An entry of the form
@domain.commatches any address at that domain. - Any other entry matches a full address exactly.
Audit
- Every action (
list,get,send), allowed or blocked, writes oneaudit_logrow. - 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_daysare 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:
emclistarts a temporary listener on127.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 onUIDVALIDITYchange.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
getif 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.