Files
emcli/specifications/SPEC.md
T
steve 79b62b24c2 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>
2026-06-21 19:36:13 +01:00

12 KiB
Raw Blame History

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:

{
  "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.