Files
emcli/internal/cli/send.go
T
steve b6e68ddeae feat(cli): configurable send-as From address (flags, TUI, validation)
- tui.ValidFromAddress: exported validator; blank passes, malformed rejects
- Fields.FromAddress: new field, round-trips through ToAccount/FieldsFromAccount
- Fields.Validate: calls ValidFromAddress before returning nil
- TUI form: from_address fieldDef between username and password
- send.go: From set via acc.SendFrom() instead of acc.Username
- admin.go account add: --from flag with pre-parse validation
- admin.go account edit: --from flag; validate before Visit, apply in Visit
- USER-MANUAL.md: --from flag added to account add flags table

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:25:14 +01:00

93 lines
3.0 KiB
Go

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.SendFrom(), 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,
}))
}