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>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
# emcli — Phase 2 Status Report
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Branch:** `main`
|
||||
**Phase 2 scope:** Send path — SMTP, MIME building, reply threading, outbound policy
|
||||
(RO rejection + whitelist-out), the `send` agent command, and admin SMTP account fields.
|
||||
|
||||
## TL;DR
|
||||
|
||||
**Phase 2 is complete and validated end-to-end against a live SMTP account.** All slices were
|
||||
implemented test-first (TDD: failing test → minimal code → green). The binary builds as a single
|
||||
static CGO-free executable, `go vet` is clean, and the full unit-test suite passes including under
|
||||
`-race`. A real message was sent to `me@stevecliff.com` over the live provider, and outbound
|
||||
enforcement (RO, whitelist-out) plus reply threading were exercised against the live mailbox.
|
||||
|
||||
## Live validation (real SMTP/IMAP account)
|
||||
|
||||
Against the live cPanel/MXlogin account over implicit TLS (`friday.mxlogin.com:465` for SMTP,
|
||||
`:993` for IMAP):
|
||||
- **`send`:** delivered a plain-text message to `me@stevecliff.com` — `{"sent":true,
|
||||
"recipients":["me@stevecliff.com"]}`, exit 0. SASL PLAIN auth over implicit TLS succeeded.
|
||||
- **RO rejection:** a send from an `RO` account was blocked with
|
||||
`{"code":"policy","message":"send blocked: ro_mode"}`, exit 1 — sender never dialed.
|
||||
- **whitelist-out:** with `@stevecliff.com` whitelisted, a send to `stranger@elsewhere.com` was
|
||||
blocked (`whitelist_out`); a send to `me@stevecliff.com` succeeded.
|
||||
- **`--reply-to` threading:** replying to a real INBOX UID read the source's
|
||||
`Message-ID`/`References` over IMAP and set `In-Reply-To`/`References` on the outgoing message
|
||||
(subject to the inbound filter — a filtered/absent source returns `not_found`).
|
||||
- **Audit:** every send (allowed or blocked) wrote an `audit_log` row with the correct
|
||||
`result`/`reason`. Password confirmed **encrypted at rest** (no plaintext in the DB).
|
||||
|
||||
## What was built
|
||||
|
||||
| Package | Change | Status |
|
||||
|---|---|---|
|
||||
| `internal/store` | `Account` now carries `SMTPHost/SMTPPort/SMTPSecurity`; threaded through insert/select/scan (NULL-safe). | ✅ |
|
||||
| `internal/policy` | `OutboundRule.Check(recipients) (ok, reason)` — RO ⇒ `ro_mode`; whitelist-out, any recipient fails ⇒ `whitelist_out` (no partial send). Reuses `MatchAddress` (case-insensitive, `@domain`). | ✅ |
|
||||
| `internal/mail` | `Header.References` (parsed Message-IDs); `OutgoingMessage`, `BuildMIME` (plain-text + attachments + threading headers, Bcc envelope-only), `SendSMTP` (tls/starttls dial, SASL PLAIN, envelope send). | ✅ |
|
||||
| `internal/cli` | `Deps.Send`; `SendCmd` (outbound gating → reply-to resolution w/ inbound filter → attachment reads → send → audit); `send` wired into the router with repeatable `--to/--cc/--bcc/--attach` flags. | ✅ |
|
||||
| `internal/cli` (admin) | `account add` gains `--smtp-host/--smtp-port/--smtp-security` (applied for `RW`). | ✅ |
|
||||
|
||||
### `send` command
|
||||
```
|
||||
emcli send --account <a> --to <addr>… [--cc <addr>…] [--bcc <addr>…]
|
||||
--subject <s> --body <text> [--attach <path>]… [--reply-to <uid> [--folder <f>]]
|
||||
```
|
||||
- Emits the standard JSON envelope; exit code mirrors `error`.
|
||||
- `--to/--cc/--bcc/--attach` are repeatable and also accept comma-separated values.
|
||||
|
||||
### Enforcement (SPEC §9 Outbound) — verified
|
||||
- `RO` accounts cannot send (blocked before any network I/O).
|
||||
- With whitelist-out enabled, **every** recipient (to+cc+bcc) must match or the whole send is
|
||||
blocked — no partial send.
|
||||
- A `--reply-to` source that fails the **inbound** filter returns `not_found`: the agent cannot
|
||||
thread off, or confirm the existence of, mail it isn't allowed to see.
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
CGO_ENABLED=0 go build ./... → OK, single static binary
|
||||
go vet ./... → clean
|
||||
go test ./... → all packages pass
|
||||
go test -race ./... → all packages pass
|
||||
```
|
||||
|
||||
New tests: `policy` outbound matrix (RO, all-must-pass, domain, case, one-bad-blocks-all);
|
||||
`mail` `BuildMIME` round-trip (body + attachment survive, threading headers present) and
|
||||
`References` parsing; `cli` send gating (RO block, whitelist-out block/allow, happy path,
|
||||
reply-to threading, filtered-source `not_found`).
|
||||
|
||||
## Process
|
||||
|
||||
Test-first per slice, tracked via the task list. Plan:
|
||||
`specifications/plans/2026-06-22-phase2-send-path.md`.
|
||||
|
||||
## Known limitations / deferred (not defects)
|
||||
|
||||
- **Phase 3 — OAuth2:** SMTP auth here is password/SASL-PLAIN only; XOAUTH2 follows in Phase 3
|
||||
(schema columns already present).
|
||||
- **Phase 4 — Admin TUI + `doctor`.**
|
||||
- `From` is taken from the account `username` (email-as-username, as in Phase 1). A distinct
|
||||
display-name/from-address is a future nicety.
|
||||
- No attachment size cap (SPEC §13 open item) — unchanged from Phase 1.
|
||||
- Carry-over Minor items from Phase 1 (audit-row completeness, CLI polish) remain open; none were
|
||||
in the Phase 2 path.
|
||||
@@ -0,0 +1,77 @@
|
||||
# emcli — Phase 2 Plan: Send Path
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**Depends on:** Phase 1 (read path, encrypted store, policy package, JSON envelope) — complete.
|
||||
**Scope (SPEC §7.1 `send`, §9 Outbound, §10 threading headers):** SMTP send, MIME building,
|
||||
reply threading, outbound policy (RO rejection + whitelist-out), the `send` agent command,
|
||||
and admin support for SMTP account fields.
|
||||
|
||||
## Approach
|
||||
|
||||
Test-first (TDD), one slice at a time, mirroring Phase 1's package boundaries. Pure logic
|
||||
(`policy`, MIME building) is unit-tested directly; the live SMTP path is validated against the
|
||||
real provider at the end (as Phase 1 did for IMAP), since a unit SMTP server is out of scope.
|
||||
|
||||
## Tasks
|
||||
|
||||
### 1. `store`: load/save SMTP fields on `Account`
|
||||
The schema already has `smtp_host/smtp_port/smtp_security`, but `Account` and its
|
||||
scan/insert/select don't carry them. Add `SMTPHost string`, `SMTPPort int`,
|
||||
`SMTPSecurity string`; thread through `AddAccount`, `GetAccount`, `ListAccounts`,
|
||||
`scanAccount`. Admin `account add` gains `--smtp-host/--smtp-port/--smtp-security`.
|
||||
**Test:** add an account with SMTP fields, read it back, assert equality.
|
||||
|
||||
### 2. `policy`: outbound rule
|
||||
`OutboundRule{ Mode string; WhitelistOutEnabled bool; WhitelistOut []string }` with
|
||||
`Check(recipients []string) (ok bool, reason string)`:
|
||||
- `Mode == "RO"` → `(false, "ro_mode")`.
|
||||
- `WhitelistOutEnabled` and any recipient fails `MatchAddress` → `(false, "whitelist_out")`
|
||||
(whole send blocked — no partial).
|
||||
- else `(true, "")`.
|
||||
**Tests:** table-driven — RW allows; RO blocks; all-recipients-must-pass; domain `@x.com`
|
||||
match; case-insensitivity; one bad recipient among good ones blocks all.
|
||||
|
||||
### 3. `mail`: SMTP send + MIME building
|
||||
- Add `References` to `Header`; populate in `readHeader` from the `References` header so a
|
||||
reply can chain correctly.
|
||||
- `SMTPConfig{Host,Port,Security,Username,Password}`.
|
||||
- `OutgoingMessage{From,To,Cc,Bcc,Subject,BodyText,Attachments,InReplyTo,References,Date}`.
|
||||
- `BuildMIME(OutgoingMessage) ([]byte, error)` via `go-message/mail` writer: a plain-text
|
||||
inline part plus any attachments; sets From/To/Cc/Subject/Date and, when present,
|
||||
`In-Reply-To`/`References`. (Bcc recipients go in the SMTP envelope, not the headers.)
|
||||
- `SendSMTP(SMTPConfig, OutgoingMessage) error`: dial (`tls` → `DialTLS`, `starttls` →
|
||||
`DialStartTLS`), `Auth` with SASL PLAIN, then `SendMail(from, allRecipients, mime)`.
|
||||
**Tests:** `BuildMIME` round-trips through `ParseMessage` (body + attachment survive);
|
||||
threading headers present when set; recipients assembled correctly. Live send deferred to task 6.
|
||||
|
||||
### 4. `cli`: `send` command + dispatch wiring
|
||||
- Add `Send func(store.Account, mail.OutgoingMessage) error` to `Deps` (live impl wraps
|
||||
`mail.SendSMTP`).
|
||||
- `SendCmd(d, account, to, cc, bcc, subject, body, attachPaths, replyToUID, replyFolder)`:
|
||||
1. Load account. Build `OutboundRule`; `Check(to+cc+bcc)` → on block, audit
|
||||
`send`/`blocked`/reason and emit policy error.
|
||||
2. If `replyToUID > 0`: dial IMAP, apply **inbound** filter (a filtered/invisible source
|
||||
UID → `not_found`), fetch its `Message-ID`+`References`, set `InReplyTo`/`References`.
|
||||
3. Read `--attach` files from disk into attachments.
|
||||
4. `d.Send(acc, msg)`; on success audit `send`/`allowed`, emit `{ "sent": true,
|
||||
"recipients": [...] }`.
|
||||
- `run.go`: route `send`; repeatable `--to/--cc/--bcc/--attach` (custom `stringSlice`
|
||||
flag.Value, also comma-splitting), `--subject/--body/--reply-to/--folder`.
|
||||
**Tests (fake Sender + fake Mailer):** RO blocks (sender never called); whitelist-out blocks;
|
||||
happy path calls sender with right recipients and emits success; reply-to on a filtered
|
||||
source returns `not_found`.
|
||||
|
||||
### 5. Build / vet / full test suite (incl `-race`)
|
||||
`CGO_ENABLED=0 go build`, `go vet ./...`, `go test ./...` and `-race`. Pristine output.
|
||||
|
||||
### 6. Live send validation
|
||||
Configure an RW account with the test SMTP creds (`emcli@stevecliff.com` @
|
||||
`friday.mxlogin.com`), send a real message to `me@stevecliff.com`, confirm the success
|
||||
envelope. Spot-check enforcement live: RO account rejects send; whitelist-out blocks a
|
||||
non-listed recipient.
|
||||
|
||||
## Out of scope (later phases)
|
||||
- OAuth2 send auth (Phase 3) — password/SASL-PLAIN only here.
|
||||
- Admin TUI / `doctor` (Phase 4).
|
||||
- Carry-over Minor items from Phase 1 (audit-row completeness, CLI polish) — touch only if
|
||||
directly in the way.
|
||||
Reference in New Issue
Block a user