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,92 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/mail"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/policy"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
// SendCmd sends a plain-text message (with optional attachments) via the
|
||||
// account's SMTP endpoint, enforcing outbound policy (RO rejection +
|
||||
// whitelist-out). When replyToUID > 0, the source message is read from
|
||||
// replyFolder (subject to inbound filtering) and its Message-ID/References are
|
||||
// used to thread the reply.
|
||||
func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string, attachPaths []string, replyToUID uint32, replyFolder string) error {
|
||||
acc, err := d.Store.GetAccount(account)
|
||||
if err != nil {
|
||||
return d.emit(Failure(CodeNotFound, "account not found: "+account))
|
||||
}
|
||||
|
||||
msg := mail.OutgoingMessage{
|
||||
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
|
||||
Subject: subject, BodyText: body,
|
||||
}
|
||||
recipients := msg.Recipients()
|
||||
if len(recipients) == 0 {
|
||||
return d.emit(Failure(CodeUsage, "at least one recipient is required"))
|
||||
}
|
||||
|
||||
// Outbound enforcement first — block before any network I/O.
|
||||
wlOut, _ := d.Store.ListWhitelist(account, store.DirOut)
|
||||
rule := policy.OutboundRule{
|
||||
Mode: acc.Mode,
|
||||
WhitelistOutEnabled: acc.WhitelistOutEnabled,
|
||||
WhitelistOut: wlOut,
|
||||
}
|
||||
if ok, reason := rule.Check(recipients); !ok {
|
||||
d.audit(account, "send", strings.Join(recipients, ","), "blocked", reason)
|
||||
return d.emit(Failure(CodePolicy, "send blocked: "+reason))
|
||||
}
|
||||
|
||||
// Reply threading: resolve the source's Message-ID/References, applying the
|
||||
// inbound filter so a hidden source cannot be replied to.
|
||||
if replyToUID > 0 {
|
||||
_, irule, m, _, done, fail := d.setup(account, replyFolder)
|
||||
if fail != nil {
|
||||
return d.emit(*fail)
|
||||
}
|
||||
defer done()
|
||||
hs, err := m.FetchHeaders(replyFolder, []uint32{replyToUID})
|
||||
if err != nil {
|
||||
return d.emit(Failure(CodeNetwork, err.Error()))
|
||||
}
|
||||
if len(hs) == 0 || !irule.Allows(hs[0].From, hs[0].Subject) {
|
||||
d.audit(account, "send", uitoa(replyToUID), "blocked", "filtered")
|
||||
return d.emit(Failure(CodeNotFound, "reply source not found"))
|
||||
}
|
||||
src := hs[0]
|
||||
if src.MessageID != "" {
|
||||
msg.InReplyTo = src.MessageID
|
||||
msg.References = append(append([]string{}, src.References...), src.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
// Read attachments from disk.
|
||||
for _, p := range attachPaths {
|
||||
content, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return d.emit(Failure(CodeUsage, "read attachment: "+err.Error()))
|
||||
}
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(p))
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
msg.Attachments = append(msg.Attachments, mail.Attachment{
|
||||
Name: filepath.Base(p), Size: len(content), MIME: mimeType, Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
if err := d.Send(acc, msg); err != nil {
|
||||
d.audit(account, "send", strings.Join(recipients, ","), "blocked", "smtp_error")
|
||||
return d.emit(Failure(CodeNetwork, err.Error()))
|
||||
}
|
||||
d.audit(account, "send", strings.Join(recipients, ","), "allowed", "")
|
||||
return d.emit(Success(map[string]any{
|
||||
"sent": true, "recipients": recipients,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user