Commit Graph

37 Commits

Author SHA1 Message Date
steve 1b2fe99055 feat(cli): add help for all commands
emcli had only raw flag usage and no command listing; `--help` on agent commands
even emitted a JSON error envelope and exited 2. Add real help:

- Top-level `emcli` / `help` / `-h` / `--help` prints a grouped command catalogue
  (agent vs admin) with one-line summaries and the EMCLI_KEY/EMCLI_DB env vars.
- `emcli help <command>` prints that command's synopsis + summary.
- `emcli <command> --help` prints synopsis + summary + flags and exits 0. Agent
  commands keep stdout JSON-free (usage goes to stderr); admin commands print to
  stdout. Help works without EMCLI_KEY (no DB access).
- help.go holds the command catalogue; flag.ErrHelp is handled as success, and
  admin handlers short-circuit help before opening the store.

Unknown commands still error (exit 2). Full suite passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:11:40 +01:00
steve 7087533644 docs(skill): split setup into AGENTIC-MANUAL.md; keep SKILL.md lean
The SKILL.md body loads into context on every activation, so one-time install/
setup prose was wasted context once emcli is running. Move it out:

- New AGENTIC-MANUAL.md: get-the-files bootstrap, binary install (incl. options
  and build-from-source, folding in the old references/install.md), EMCLI_KEY,
  account discovery. Fetched only during first-time setup.
- SKILL.md trimmed (182→~145 lines) to the recurring path: security model, a short
  "Files & first run" pointer + per-session preflight, the list/get/ack/send
  workflow, JSON envelope, command table, enforcement, do/don't.
- Remove references/install.md (folded in); fix RELEASING.md pointer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:01:46 +01:00
steve 93dbebb982 docs(skill): make SKILL.md self-bootstrapping from the repo
An agent pointed at the repo may load only SKILL.md and then guess a wrong path
for the installer (it fetched /scripts/install.sh at repo root → 404; the file is
under skills/emcli/). Fix:

- Add a "First: get this skill's files" section: the supporting scripts/ and
  references/ files, the absolute raw base URL to fetch them, and the Gitea
  contents API to enumerate the directory.
- Install step now gives an absolute-URL fetch-then-run for the only-SKILL.md case,
  keeping `bash scripts/install.sh` for the bundled case.
- State that every scripts/… and references/… path is relative to the skill dir and
  resolvable against the raw base URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:51:15 +01:00
steve 68a29ad5c7 docs: mark CI release verified; note releases must be public for the installer
The Gitea Actions workflow published v0.4.0 successfully, so drop the "untested"
caveat. Document that release assets download anonymously — the repo/releases must
be public or install.sh gets a 404 (private repos 404 unauthenticated downloads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:40:45 +01:00
steve 5e3101647d build: add cross-platform release target, tea publish, and Gitea Actions release
release / release (push) Successful in 3m33s
- Makefile `release`: cross-compiles CGO-free static binaries for linux/amd64,
  linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 into dist/, named
  emcli_<version>_<os>_<arch>[.exe] (matching skills/emcli/scripts/install.sh),
  plus a sha256 checksums.txt. VERSION is injected into internal/version.String.
- Makefile `publish`: creates the Gitea release and uploads all dist/ assets via tea.
- .gitea/workflows/release.yml: on a v* tag, build + publish via the Gitea API.
- RELEASING.md: the local (make) and CI flows.

Verified end-to-end: `make release VERSION=v0.4.0` builds all five assets with the
version baked in; serving them locally, skills/emcli/scripts/install.sh downloads,
passes checksum verification, and the installed binary reports v0.4.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v0.4.0
2026-06-22 20:32:22 +01:00
steve 7ad4f1adc2 feat(skill): add emcli Agent Skill (agentskills.io standard)
skills/emcli/ — an Agent Skill teaching an agent to read and send mail through
emcli's JSON agent commands:

- SKILL.md: name/description (what + when + trigger keywords), compatibility,
  metadata; body covers the security model (agent-only commands, never touch
  EMCLI_KEY), setup, the list→get→ack workflow, sending, and enforcement
  awareness. Frontmatter validated against the spec (name matches dir; desc
  574/1024; compatibility 239/500); body 146 lines (<500).
- scripts/install.sh: detects OS/arch, downloads the release binary, verifies
  the sha256 checksum when present, fails gracefully. Release tag/assets
  (v0.4.0, emcli_<ver>_<os>_<arch>) are placeholders until the first release.
- references/{commands.md,install.md}: full agent command reference (flags, JSON
  shapes, error codes, enforcement) and install options, loaded on demand.

README links to the skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:25:08 +01:00
steve 55c763d641 docs: add user manual; expand README
USER-MANUAL.md — full user-facing guide: setup (EMCLI_KEY/EMCLI_DB), adding
accounts (incl. Gmail app-password walkthrough), admin commands (account /
whitelist / config / audit / doctor / init), agent commands (list/get/search/
ack/send) with exact flags, the JSON envelope + error codes, enforcement rules,
troubleshooting, and a cheat sheet. README now summarizes emcli and links it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:14:06 +01:00
steve a837b25d73 feat(admin): Phase 4 — doctor, admin completeness, and bubbletea TUI
Adds the admin/diagnostics surface from SPEC §7.2:

- doctor [--account]: per-account IMAP + (RW) SMTP connectivity/auth checks via
  new mail.CheckIMAP/CheckSMTP (connect+auth only, no mail). Exit non-zero on any
  failure; secrets never printed.
- store.UpdateAccount: partial edit, re-encrypts password/secrets only when a
  non-empty value is supplied (blank keeps existing). RecentAuditFor(account).
- config set/get (validates audit_retention_days), audit list [--account][--limit],
  account edit (flag partial-update) / remove [--yes].
- internal/tui: bubbletea AccountForm with pure, fully-tested Fields (validation +
  store.Account assembly + edit prefill). init / bare `account add` / `account edit
  --name X` drop into the TUI; flag forms remain for scripting.

Built test-first; full suite green incl -race. Validated live against the mxlogin
(password) and Gmail (app-password) accounts. Live validation caught a real bug:
doctor authenticated with empty passwords because it iterated ListAccounts (which
strips secrets) — fixed to re-fetch via GetAccount, locked in by a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:09:43 +01:00
steve 193815dd25 docs: Phase 3 decision — Gmail via app password; OAuth2 deferred
Evaluated OAuth2 (SPEC §10) and chose not to build it this phase. A self-built,
unverified OAuth app suffers Google's 7-day refresh-token expiry in Testing
status (or the unverified-warning + restricted-scope verification cost in
Production). For a single-user personal tool, a Gmail App Password (2FA) is
strictly simpler and reuses the IMAP/SMTP password auth from Phases 1–2.

Validated live against a real Gmail account over app-password auth: list/get/
search, send, and a full SMTP-out → IMAP-in round-trip. No code changes were
required; the speculative OAuth store fields started mid-session were reverted.
OAuth2 remains a clean future addition (schema columns already present).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:16:20 +01:00
steve c99eaedafd feat(send): Phase 2 send path — SMTP, MIME, reply threading, outbound policy
Adds the `send` agent command and everything behind it:

- store: Account carries SMTP host/port/security (NULL-safe scan/insert/select);
  admin `account add` gains --smtp-* flags (applied for RW accounts).
- policy: OutboundRule.Check(recipients) → (ok, reason); RO ⇒ ro_mode,
  whitelist-out blocks the whole send if any recipient fails (no partial send).
- mail: Header.References; OutgoingMessage + BuildMIME (plain text + attachments,
  In-Reply-To/References threading, Bcc envelope-only); SendSMTP (tls/starttls,
  SASL PLAIN, envelope send) via emersion/go-smtp.
- cli: SendCmd gates outbound, resolves --reply-to under the inbound filter
  (filtered/absent source ⇒ not_found), reads attachments, audits, emits the
  JSON envelope; repeatable --to/--cc/--bcc/--attach flags wired into the router.

Implemented test-first; full suite passes incl -race. Validated live against
friday.mxlogin.com: real send to me@stevecliff.com, RO + whitelist-out blocks,
and --reply-to threading off a live INBOX message. test-creds.md gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:39:07 +01:00
steve 3224a87b6e docs: status report — live validation done, header-only fetch resolved
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 07:52:42 +01:00
steve 5d2461ad94 fix(mail): drain UidFetch channel on early error; clarify ParseHeaderOnly doc
If header/body parsing errored mid-fetch we returned without draining the
message channel, so the UidFetch goroutine could block on a full channel.
Both fetch paths now break, drain remaining messages, then read the done
error. Verified with the race detector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 07:51:45 +01:00
steve 8379fddbb2 perf(mail): fetch only headers for list/search (no body download)
list and search now fetch BODY.PEEK[HEADER] + BODYSTRUCTURE instead of the
whole RFC822 message, so listing a large mailbox no longer downloads every
message body and attachment. Header parsing reuses the same go-message path
(RFC2047 decoding/formatting preserved); has_attachments is derived from the
BODYSTRUCTURE tree. FetchFull keeps fetching the full message for get.

Validated end-to-end against a live IMAP account: list/search/get output
identical to the prior full-fetch behaviour, has_attachments correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 07:47:27 +01:00
steve a1440719ae docs: Phase 1 status report
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:19:05 +01:00
steve 6061bd2a78 fix(cli): search limit counts visible results, filter before cap
Pass 0 (unlimited) to m.Search so the mail layer returns all matching
headers; the existing post-filter loop already caps at the caller's
limit, mirroring ListCmd. Add TestSearchLimitCountsVisibleOnly to prove
filtering happens before the cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 00:17:39 +01:00
steve dd181ef63c fix(cli): non-zero exit when an agent command emits an error envelope
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 00:13:29 +01:00
steve e1e5f245e1 feat(cli): command router, real IMAP wiring, flag-based admin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:09:38 +01:00
steve e1d86dc587 fix(cli): reuse folder uidvalidity from setup in AckCmd
Thread uidv through setup's return value (new uint32 before the cleanup
func) so AckCmd no longer makes a redundant SelectFolder round-trip that
silently returned 0 on failure and recorded acks under the wrong
UID-validity epoch. All four callers updated; read-only callers ignore
the value with _.
2026-06-22 00:06:26 +01:00
steve ccf6fa0542 feat(cli): agent read commands (list/get/search/ack) with policy filtering 2026-06-22 00:03:27 +01:00
steve 05abcf3bac feat(cli): JSON output envelope with stable error codes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:00:25 +01:00
steve 47f877ad82 fix(mail): apply search limit and propagate body read error
- Cap search results to limit (keep most-recent UIDs)
- Propagate io.ReadAll errors from body reads in fetchByUIDSet

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:58:09 +01:00
steve 83bf3019c5 feat(mail): IMAP client — select, fetch headers/full, search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:55:30 +01:00
steve 7df0c95339 fix(mail): propagate io.ReadAll errors when parsing parts 2026-06-21 23:53:24 +01:00
steve d73aabca96 feat(mail): RFC822 message parsing (headers, body, attachments)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 23:51:29 +01:00
steve b9d0b57f84 feat(policy): inbound whitelist + subject-regex filter 2026-06-21 23:49:04 +01:00
steve 4d6ac3e7c6 feat(policy): case-insensitive address and domain matching 2026-06-21 23:47:39 +01:00
steve 5fb022bbaf feat(store): audit log with retention-based purge 2026-06-21 23:45:57 +01:00
steve a4e72b2178 feat(store): seen-set read state with floor baseline and compaction 2026-06-21 23:43:35 +01:00
steve a1e9f601ce feat(store): per-account inbound/outbound whitelist CRUD
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:41:10 +01:00
steve 2db459d701 feat(store): accounts CRUD with encrypted password column 2026-06-21 23:38:51 +01:00
steve aaab744b15 fix(store): pin connection pool so foreign_keys pragma sticks
SQLite PRAGMAs are connection-scoped, but database/sql uses a connection
pool. Without pinning to one connection, new pooled connections won't have
foreign_keys enabled, breaking ON DELETE CASCADE enforcement.

Also mark modernc.org/sqlite as a direct dependency in go.mod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:37:11 +01:00
steve 673ed5f350 feat(store): open encrypted SQLite, schema v1, settings 2026-06-21 23:34:31 +01:00
steve 8d04b0fde9 feat(crypto): AES-256-GCM field encryption keyed from EMCLI_KEY
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 23:32:23 +01:00
steve afad3bf3f1 feat: project scaffold, version command, build 2026-06-21 23:30:44 +01:00
steve 04d3b61bb0 Plan: Phase 1 — foundation & read path implementation plan
TDD task-by-task plan for the read-only emcli: crypto, encrypted store,
seen-set read state, policy filtering, IMAP read, and the agent
list/get/search/ack commands with flag-based admin. Phases 2-4 (send,
OAuth2, TUI) to follow as their own plans.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:17:29 +01:00
steve e3f8afbc7c Spec: replace read pointer with per-message seen-set model
Reading state is now a per-(account,folder) floor plus an acked set of
UIDs above it, instead of a single monotonic pointer. This makes
acknowledgement per-message and order-independent so concurrent
subagents can process and ack out of order. Internal compaction collapses
contiguous acked runs into the floor to bound storage. Adds stateless
search and ack commands; reads no longer mutate state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:01:34 +01:00
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