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, })) }