Files
emcli/specifications/plans/2026-06-22-phase2-send-path.md
T
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

78 lines
4.3 KiB
Markdown

# 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.