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>
This commit is contained in:
2026-06-23 20:25:14 +01:00
parent 6a99e5bb6e
commit b6e68ddeae
6 changed files with 100 additions and 2 deletions
+1
View File
@@ -191,6 +191,7 @@ emcli account add --name work --mode RW \
| `--smtp-security` | `tls` | `tls` or `starttls` | | `--smtp-security` | `tls` | `tls` or `starttls` |
| `--username` | — | Login username, usually your full email (required) | | `--username` | — | Login username, usually your full email (required) |
| `--password` | — | Login password or app password | | `--password` | — | Login password or app password |
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
| `--subject-regex` | — | Inbound subject filter (optional) | | `--subject-regex` | — | Inbound subject filter (optional) |
| `--whitelist-in` | off | Enable inbound whitelist | | `--whitelist-in` | off | Enable inbound whitelist |
| `--whitelist-out` | off | Enable outbound whitelist | | `--whitelist-out` | off | Enable outbound whitelist |
+13
View File
@@ -45,6 +45,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "tls", "tls|starttls") smtpSec := fs.String("smtp-security", "tls", "tls|starttls")
user := fs.String("username", "", "login username") user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password") pass := fs.String("password", "", "login password")
from := fs.String("from", "", "send-as address (blank = use username)")
subj := fs.String("subject-regex", "", "inbound subject filter") subj := fs.String("subject-regex", "", "inbound subject filter")
wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist") wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist")
wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist") wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist")
@@ -56,9 +57,14 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
fmt.Fprintln(errOut, "name, imap-host, and username are required") fmt.Fprintln(errOut, "name, imap-host, and username are required")
return 2 return 2
} }
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc := store.Account{ acc := store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec, Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass, AuthType: "password", Username: *user, Password: *pass,
FromAddress: *from,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut, SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
ProcessBacklog: *backlog, ProcessBacklog: *backlog,
} }
@@ -85,6 +91,7 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
smtpSec := fs.String("smtp-security", "", "tls|starttls") smtpSec := fs.String("smtp-security", "", "tls|starttls")
user := fs.String("username", "", "login username") user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password (blank keeps existing)") pass := fs.String("password", "", "login password (blank keeps existing)")
from := fs.String("from", "", "send-as address (blank keeps existing)")
subj := fs.String("subject-regex", "", "inbound subject filter") subj := fs.String("subject-regex", "", "inbound subject filter")
if err := fs.Parse(rest); err != nil { if err := fs.Parse(rest); err != nil {
return 2 return 2
@@ -96,6 +103,10 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut) return editInteractive(st, *name, out, errOut)
} }
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc, err := st.GetAccount(*name) acc, err := st.GetAccount(*name)
if err != nil { if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err) fmt.Fprintf(errOut, "edit: %v\n", err)
@@ -122,6 +133,8 @@ func runAccount(args []string, role store.Role, out, errOut io.Writer) int {
acc.Username = *user acc.Username = *user
case "password": case "password":
acc.Password = *pass acc.Password = *pass
case "from":
acc.FromAddress = *from
case "subject-regex": case "subject-regex":
acc.SubjectRegex = *subj acc.SubjectRegex = *subj
} }
+1 -1
View File
@@ -23,7 +23,7 @@ func SendCmd(d Deps, account string, to, cc, bcc []string, subject, body string,
} }
msg := mail.OutgoingMessage{ msg := mail.OutgoingMessage{
From: acc.Username, To: to, Cc: cc, Bcc: bcc, From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body, Subject: subject, BodyText: body,
} }
recipients := msg.Recipients() recipients := msg.Recipients()
+26
View File
@@ -139,6 +139,32 @@ func TestSendReplyToThreadsHeaders(t *testing.T) {
} }
} }
func TestSendUsesConfiguredFromAddress(t *testing.T) {
acc := rwAccount()
acc.FromAddress = "Steve Cliff <me@stevecliff.com>"
d, sent, _ := sendDeps(t, acc, nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if len(*sent) != 1 {
t.Fatalf("want 1 send, got %d", len(*sent))
}
if got := (*sent)[0].From; got != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("From = %q, want configured from-address", got)
}
}
func TestSendFallsBackToUsernameAsFrom(t *testing.T) {
// rwAccount has no FromAddress, so From must be the login username.
d, sent, _ := sendDeps(t, rwAccount(), nil)
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
t.Fatalf("SendCmd: %v", err)
}
if got := (*sent)[0].From; got != "emcli@stevecliff.com" {
t.Fatalf("From = %q, want username fallback", got)
}
}
func TestSendReplyToFilteredSourceNotFound(t *testing.T) { func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
acc := rwAccount() acc := rwAccount()
acc.WhitelistInEnabled = true // inbound filter active acc.WhitelistInEnabled = true // inbound filter active
+25
View File
@@ -6,6 +6,7 @@ package tui
import ( import (
"errors" "errors"
"fmt" "fmt"
"net/mail"
"strconv" "strconv"
"strings" "strings"
@@ -22,10 +23,24 @@ type Fields struct {
IMAPHost, IMAPPort, IMAPSecurity string IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool WhitelistIn, WhitelistOut, ProcessBacklog bool
SubjectRegex string SubjectRegex string
} }
// ValidFromAddress returns an error if s is set but is not a valid RFC 5322
// address (bare or "Display Name <addr>"). A blank value is valid: sending
// falls back to the login username.
func ValidFromAddress(s string) error {
if strings.TrimSpace(s) == "" {
return nil
}
if _, err := mail.ParseAddress(s); err != nil {
return errors.New("from address must be a valid email address")
}
return nil
}
func validSecurity(s string) bool { return s == "tls" || s == "starttls" } func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
// Validate checks required fields, enum fields, and numeric ports. RW accounts // Validate checks required fields, enum fields, and numeric ports. RW accounts
@@ -60,6 +75,9 @@ func (f Fields) Validate() error {
return errors.New("smtp port must be a number") return errors.New("smtp port must be a number")
} }
} }
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
return nil return nil
} }
@@ -71,6 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
Name: strings.TrimSpace(f.Name), Mode: f.Mode, Name: strings.TrimSpace(f.Name), Mode: f.Mode,
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity, IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password, AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut, WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog, SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
} }
@@ -96,6 +115,7 @@ func FieldsFromAccount(a store.Account) Fields {
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity, IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity, SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username, Username: a.Username,
FromAddress: a.FromAddress,
WhitelistIn: a.WhitelistInEnabled, WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled, WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog, ProcessBacklog: a.ProcessBacklog,
@@ -122,6 +142,7 @@ var fieldDefs = []fieldDef{
{key: "smtp_port", label: "SMTP port (RW)"}, {key: "smtp_port", label: "SMTP port (RW)"},
{key: "smtp_security", label: "SMTP security (tls/starttls)"}, {key: "smtp_security", label: "SMTP security (tls/starttls)"},
{key: "username", label: "Username"}, {key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true}, {key: "password", label: "Password", password: true},
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true}, {key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
{key: "whitelist_out", label: "Whitelist outbound (y/n)", isBool: true}, {key: "whitelist_out", label: "Whitelist outbound (y/n)", isBool: true},
@@ -164,6 +185,8 @@ func fieldValue(f Fields, key string) string {
return f.SMTPSecurity return f.SMTPSecurity
case "username": case "username":
return f.Username return f.Username
case "from_address":
return f.FromAddress
case "password": case "password":
return f.Password return f.Password
case "whitelist_in": case "whitelist_in":
@@ -249,6 +272,8 @@ func (m AccountForm) collect() Fields {
f.SMTPSecurity = strings.ToLower(v) f.SMTPSecurity = strings.ToLower(v)
case "username": case "username":
f.Username = v f.Username = v
case "from_address":
f.FromAddress = v
case "password": case "password":
f.Password = m.inputs[i].Value() // do not trim a password f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in": case "whitelist_in":
+33
View File
@@ -157,3 +157,36 @@ func TestAccountFormCancel(t *testing.T) {
t.Fatal("esc should cancel the form") t.Fatal("esc should cancel the form")
} }
} }
func TestValidateRejectsBadFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "not an address"
if err := f.Validate(); err == nil {
t.Fatal("malformed from-address should fail validation")
}
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
if err := f.Validate(); err != nil {
t.Fatalf("display-name from-address should validate: %v", err)
}
f.FromAddress = "me@stevecliff.com"
if err := f.Validate(); err != nil {
t.Fatalf("bare from-address should validate: %v", err)
}
f.FromAddress = "" // blank ⇒ fall back, always valid
if err := f.Validate(); err != nil {
t.Fatalf("blank from-address should validate: %v", err)
}
}
func TestFieldsFromToAccountCarriesFromAddress(t *testing.T) {
f := validFields()
f.FromAddress = "Steve Cliff <me@stevecliff.com>"
acc, _ := f.ToAccount()
if acc.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress)
}
back := FieldsFromAccount(acc)
if back.FromAddress != "Steve Cliff <me@stevecliff.com>" {
t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress)
}
}