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:
2026-06-22 17:39:07 +01:00
parent 3224a87b6e
commit c99eaedafd
17 changed files with 923 additions and 15 deletions
+170
View File
@@ -0,0 +1,170 @@
package mail
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"time"
gomail "github.com/emersion/go-message/mail"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
)
// SMTPConfig is the connection/auth detail for an account's send endpoint.
type SMTPConfig struct {
Host string
Port int
Security string // tls | starttls
Username string
Password string
}
// OutgoingMessage is a plain-text message (with optional attachments) to send.
// InReplyTo and References hold bare Message-IDs (no angle brackets) and, when
// set, produce threading headers so a reply chains onto its source.
type OutgoingMessage struct {
From string
To []string
Cc []string
Bcc []string
Subject string
BodyText string
Attachments []Attachment
InReplyTo string
References []string
Date time.Time
}
// Recipients returns the full envelope recipient set: to + cc + bcc.
func (m OutgoingMessage) Recipients() []string {
out := make([]string, 0, len(m.To)+len(m.Cc)+len(m.Bcc))
out = append(out, m.To...)
out = append(out, m.Cc...)
out = append(out, m.Bcc...)
return out
}
func addrList(addrs []string) []*gomail.Address {
out := make([]*gomail.Address, 0, len(addrs))
for _, a := range addrs {
if parsed, err := gomail.ParseAddress(a); err == nil {
out = append(out, parsed)
} else {
out = append(out, &gomail.Address{Address: a})
}
}
return out
}
// BuildMIME renders an OutgoingMessage to RFC822 bytes: a plain-text inline part
// plus any attachments. Bcc recipients are intentionally omitted from the headers
// (they travel only in the SMTP envelope).
func BuildMIME(m OutgoingMessage) ([]byte, error) {
var h gomail.Header
if m.Date.IsZero() {
m.Date = time.Now()
}
h.SetDate(m.Date)
h.SetAddressList("From", addrList([]string{m.From}))
if len(m.To) > 0 {
h.SetAddressList("To", addrList(m.To))
}
if len(m.Cc) > 0 {
h.SetAddressList("Cc", addrList(m.Cc))
}
h.SetSubject(m.Subject)
if m.InReplyTo != "" {
h.SetMsgIDList("In-Reply-To", []string{m.InReplyTo})
}
if len(m.References) > 0 {
h.SetMsgIDList("References", m.References)
}
if err := h.GenerateMessageID(); err != nil {
return nil, fmt.Errorf("generate message-id: %w", err)
}
var buf bytes.Buffer
w, err := gomail.CreateWriter(&buf, h)
if err != nil {
return nil, fmt.Errorf("create mail writer: %w", err)
}
tw, err := w.CreateInline()
if err != nil {
return nil, fmt.Errorf("create inline: %w", err)
}
var th gomail.InlineHeader
th.Set("Content-Type", "text/plain; charset=utf-8")
pw, err := tw.CreatePart(th)
if err != nil {
return nil, fmt.Errorf("create text part: %w", err)
}
if _, err := io.WriteString(pw, m.BodyText); err != nil {
return nil, fmt.Errorf("write body: %w", err)
}
if err := pw.Close(); err != nil {
return nil, err
}
if err := tw.Close(); err != nil {
return nil, err
}
for _, a := range m.Attachments {
var ah gomail.AttachmentHeader
if a.MIME != "" {
ah.Set("Content-Type", a.MIME)
}
ah.SetFilename(a.Name)
aw, err := w.CreateAttachment(ah)
if err != nil {
return nil, fmt.Errorf("create attachment %q: %w", a.Name, err)
}
if _, err := aw.Write(a.Content); err != nil {
return nil, fmt.Errorf("write attachment %q: %w", a.Name, err)
}
if err := aw.Close(); err != nil {
return nil, err
}
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// SendSMTP builds the MIME body and delivers it via the account's SMTP endpoint,
// authenticating with SASL PLAIN. The envelope sender is m.From and the envelope
// recipients are m.Recipients() (to + cc + bcc).
func SendSMTP(cfg SMTPConfig, m OutgoingMessage) error {
raw, err := BuildMIME(m)
if err != nil {
return err
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
tlsConf := &tls.Config{ServerName: cfg.Host}
var c *smtp.Client
switch cfg.Security {
case "tls":
c, err = smtp.DialTLS(addr, tlsConf)
case "starttls":
c, err = smtp.DialStartTLS(addr, tlsConf)
default:
return fmt.Errorf("unknown smtp security %q", cfg.Security)
}
if err != nil {
return fmt.Errorf("smtp connect: %w", err)
}
defer c.Close()
auth := sasl.NewPlainClient("", cfg.Username, cfg.Password)
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
if err := c.SendMail(m.From, m.Recipients(), bytes.NewReader(raw)); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
return c.Quit()
}