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` |
| `--username` | — | Login username, usually your full email (required) |
| `--password` | — | Login password or app password |
| `--from` | — | Send-as address (blank = use username); bare or `"Display Name <addr>"` |
| `--subject-regex` | — | Inbound subject filter (optional) |
| `--whitelist-in` | off | Enable inbound 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")
user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password")
from := fs.String("from", "", "send-as address (blank = use username)")
subj := fs.String("subject-regex", "", "inbound subject filter")
wlIn := fs.Bool("whitelist-in", false, "enable inbound 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")
return 2
}
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc := store.Account{
Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec,
AuthType: "password", Username: *user, Password: *pass,
FromAddress: *from,
SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut,
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")
user := fs.String("username", "", "login username")
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")
if err := fs.Parse(rest); err != nil {
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
return editInteractive(st, *name, out, errOut)
}
if err := tui.ValidFromAddress(*from); err != nil {
fmt.Fprintln(errOut, err)
return 2
}
acc, err := st.GetAccount(*name)
if err != nil {
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
case "password":
acc.Password = *pass
case "from":
acc.FromAddress = *from
case "subject-regex":
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{
From: acc.Username, To: to, Cc: cc, Bcc: bcc,
From: acc.SendFrom(), To: to, Cc: cc, Bcc: bcc,
Subject: subject, BodyText: body,
}
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) {
acc := rwAccount()
acc.WhitelistInEnabled = true // inbound filter active
+25
View File
@@ -6,6 +6,7 @@ package tui
import (
"errors"
"fmt"
"net/mail"
"strconv"
"strings"
@@ -22,10 +23,24 @@ type Fields struct {
IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
FromAddress string
WhitelistIn, WhitelistOut, ProcessBacklog bool
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" }
// 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")
}
}
if err := ValidFromAddress(f.FromAddress); err != nil {
return err
}
return nil
}
@@ -71,6 +89,7 @@ func (f Fields) ToAccount() (store.Account, bool) {
Name: strings.TrimSpace(f.Name), Mode: f.Mode,
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password,
FromAddress: f.FromAddress,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
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,
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username,
FromAddress: a.FromAddress,
WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog,
@@ -122,6 +142,7 @@ var fieldDefs = []fieldDef{
{key: "smtp_port", label: "SMTP port (RW)"},
{key: "smtp_security", label: "SMTP security (tls/starttls)"},
{key: "username", label: "Username"},
{key: "from_address", label: "From address (optional)"},
{key: "password", label: "Password", password: true},
{key: "whitelist_in", label: "Whitelist inbound (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
case "username":
return f.Username
case "from_address":
return f.FromAddress
case "password":
return f.Password
case "whitelist_in":
@@ -249,6 +272,8 @@ func (m AccountForm) collect() Fields {
f.SMTPSecurity = strings.ToLower(v)
case "username":
f.Username = v
case "from_address":
f.FromAddress = v
case "password":
f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in":
+33
View File
@@ -157,3 +157,36 @@ func TestAccountFormCancel(t *testing.T) {
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)
}
}