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:
+26
-1
@@ -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,
|
||||
}
|
||||
@@ -95,7 +114,8 @@ func FieldsFromAccount(a store.Account) Fields {
|
||||
Name: a.Name, Mode: a.Mode,
|
||||
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
|
||||
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
|
||||
Username: a.Username,
|
||||
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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user