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