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>
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").WhitelistOutEnabledand any recipient failsMatchAddress→(false, "whitelist_out")(whole send blocked — no partial).- else
(true, ""). Tests: table-driven — RW allows; RO blocks; all-recipients-must-pass; domain@x.commatch; case-insensitivity; one bad recipient among good ones blocks all.
3. mail: SMTP send + MIME building
- Add
ReferencestoHeader; populate inreadHeaderfrom theReferencesheader 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)viago-message/mailwriter: 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),Authwith SASL PLAIN, thenSendMail(from, allRecipients, mime). Tests:BuildMIMEround-trips throughParseMessage(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) errortoDeps(live impl wrapsmail.SendSMTP). SendCmd(d, account, to, cc, bcc, subject, body, attachPaths, replyToUID, replyFolder):- Load account. Build
OutboundRule;Check(to+cc+bcc)→ on block, auditsend/blocked/reason and emit policy error. - If
replyToUID > 0: dial IMAP, apply inbound filter (a filtered/invisible source UID →not_found), fetch itsMessage-ID+References, setInReplyTo/References. - Read
--attachfiles from disk into attachments. d.Send(acc, msg); on success auditsend/allowed, emit{ "sent": true, "recipients": [...] }.
- Load account. Build
run.go: routesend; repeatable--to/--cc/--bcc/--attach(customstringSliceflag.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 returnsnot_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.