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

4.3 KiB

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 (tlsDialTLS, starttlsDialStartTLS), 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.