diff --git a/USER-MANUAL.md b/USER-MANUAL.md index 4442125..5be4b61 100644 --- a/USER-MANUAL.md +++ b/USER-MANUAL.md @@ -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 "` | | `--subject-regex` | — | Inbound subject filter (optional) | | `--whitelist-in` | off | Enable inbound whitelist | | `--whitelist-out` | off | Enable outbound whitelist | diff --git a/internal/cli/admin.go b/internal/cli/admin.go index fc48089..81a42b5 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -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 } diff --git a/internal/cli/send.go b/internal/cli/send.go index ccb2244..14e1d77 100644 --- a/internal/cli/send.go +++ b/internal/cli/send.go @@ -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() diff --git a/internal/cli/send_test.go b/internal/cli/send_test.go index 2356aca..a83121b 100644 --- a/internal/cli/send_test.go +++ b/internal/cli/send_test.go @@ -139,6 +139,32 @@ func TestSendReplyToThreadsHeaders(t *testing.T) { } } +func TestSendUsesConfiguredFromAddress(t *testing.T) { + acc := rwAccount() + acc.FromAddress = "Steve Cliff " + 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 " { + 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 diff --git a/internal/tui/account.go b/internal/tui/account.go index e2dd8d7..e4d9ae0 100644 --- a/internal/tui/account.go +++ b/internal/tui/account.go @@ -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 "). 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": diff --git a/internal/tui/account_test.go b/internal/tui/account_test.go index 0c196c7..2e46e60 100644 --- a/internal/tui/account_test.go +++ b/internal/tui/account_test.go @@ -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 " + 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 " + acc, _ := f.ToAccount() + if acc.FromAddress != "Steve Cliff " { + t.Fatalf("ToAccount lost FromAddress: %q", acc.FromAddress) + } + back := FieldsFromAccount(acc) + if back.FromAddress != "Steve Cliff " { + t.Fatalf("FieldsFromAccount lost FromAddress: %q", back.FromAddress) + } +}