feat(mail): sender display name (SMTP_FROM_NAME, default MindSift)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m54s
A bare SMTP_FROM address gets the display name in the From header; a name already in SMTP_FROM wins, the envelope keeps the bare address. Also a field in the admin mail settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1632dd22c4
commit
56c1fa02e6
12 files changed
+86
-14
No files matched your search
+24
-5
@@ -20,8 +20,9 @@ import (
|
||||
// smtpSettings is the effective mail server configuration (admin settings, else environment).
|
||||
type smtpSettings struct {
|
||||
Host, Port, User, Pass, From string
|
||||
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
|
||||
SkipVerify bool // accept self-signed / mismatching certificates
|
||||
FromName string // display name when From is a bare address
|
||||
TLS bool // 465: implicit TLS, otherwise STARTTLS (required when set)
|
||||
SkipVerify bool // accept self-signed / mismatching certificates
|
||||
|
||||
authMech string // "" = automatic; "LOGIN" forces AUTH LOGIN (fallback after a refused PLAIN)
|
||||
}
|
||||
@@ -46,13 +47,15 @@ func (s *Server) smtpSettings() (smtpSettings, error) {
|
||||
cfg = smtpSettings{
|
||||
Host: strings.TrimSpace(sys.SMTPHost), Port: strings.TrimSpace(sys.SMTPPort),
|
||||
User: strings.TrimSpace(sys.SMTPUser), Pass: sys.SMTPPass, From: strings.TrimSpace(sys.SMTPFrom),
|
||||
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
|
||||
FromName: strings.TrimSpace(sys.SMTPFromName),
|
||||
TLS: sys.SMTPTLS, SkipVerify: sys.SMTPSkipVerify,
|
||||
}
|
||||
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
|
||||
cfg = smtpSettings{
|
||||
Host: strings.TrimSpace(s.cfg.SMTPHost), Port: strings.TrimSpace(s.cfg.SMTPPort),
|
||||
User: strings.TrimSpace(s.cfg.SMTPUser), Pass: s.cfg.SMTPPass, From: strings.TrimSpace(s.cfg.SMTPFrom),
|
||||
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
|
||||
FromName: strings.TrimSpace(s.cfg.SMTPFromName),
|
||||
TLS: s.cfg.SMTPTLS, SkipVerify: s.cfg.SMTPSkipVerify,
|
||||
}
|
||||
}
|
||||
if cfg.Host == "" {
|
||||
@@ -61,6 +64,9 @@ func (s *Server) smtpSettings() (smtpSettings, error) {
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "587"
|
||||
}
|
||||
if cfg.FromName == "" {
|
||||
cfg.FromName = "MindSift"
|
||||
}
|
||||
if cfg.From == "" {
|
||||
if strings.Contains(cfg.User, "@") {
|
||||
cfg.From = cfg.User
|
||||
@@ -96,6 +102,19 @@ func splitAddress(raw string) (envelope, header string, err error) {
|
||||
return addr.Address, addr.String(), nil
|
||||
}
|
||||
|
||||
// senderAddress is splitAddress for the sender: a bare address gets the display name, so mail clients
|
||||
// show "MindSift" instead of only the address. A name already in SMTP_FROM wins.
|
||||
func senderAddress(raw, name string) (envelope, header string, err error) {
|
||||
addr, err := mail.ParseAddress(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("ungültige E-Mail-Adresse %q", raw)
|
||||
}
|
||||
if addr.Name == "" {
|
||||
addr.Name = strings.TrimSpace(name)
|
||||
}
|
||||
return addr.Address, addr.String(), nil
|
||||
}
|
||||
|
||||
// buildMessage creates an RFC 5322 message: Date and Message-ID headers, RFC 2047 encoded subject
|
||||
// (umlauts, emoji) and a quoted-printable UTF-8 body with CRLF line endings.
|
||||
func buildMessage(from, to, subject, body string, now time.Time) []byte {
|
||||
@@ -202,7 +221,7 @@ func sendMail(cfg smtpSettings, to, subject, body string) error {
|
||||
// sendMailOnce runs the SMTP dialogue step by step instead of smtp.SendMail, so it can introduce itself
|
||||
// properly, honour SkipVerify and report the exact step a server refuses.
|
||||
func sendMailOnce(cfg smtpSettings, to, subject, body string) error {
|
||||
fromAddr, fromHeader, err := splitAddress(cfg.From)
|
||||
fromAddr, fromHeader, err := senderAddress(cfg.From, cfg.FromName)
|
||||
if err != nil {
|
||||
return &smtpStepError{"Absender", err}
|
||||
}
|
||||
|
||||
@@ -270,3 +270,17 @@ func TestAuthLoginOnlyAndBothFailing(t *testing.T) {
|
||||
t.Fatalf("expected both mechanisms and the server hint in the error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderAddressGetsDisplayName(t *testing.T) {
|
||||
cases := []struct{ raw, name, envelope, header string }{
|
||||
{"alerts@example.com", "MindSift", "alerts@example.com", `"MindSift" <alerts@example.com>`},
|
||||
{`"Mein Dienst" <alerts@example.com>`, "MindSift", "alerts@example.com", `"Mein Dienst" <alerts@example.com>`},
|
||||
{"alerts@example.com", "Büro Müller", "alerts@example.com", "=?utf-8?q?B=C3=BCro_M=C3=BCller?= <alerts@example.com>"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
env, hdr, err := senderAddress(c.raw, c.name)
|
||||
if err != nil || env != c.envelope || hdr != c.header {
|
||||
t.Fatalf("senderAddress(%q, %q) = %q, %q, %v", c.raw, c.name, env, hdr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,9 @@ type Config struct {
|
||||
SMTPUser string
|
||||
SMTPPass string
|
||||
SMTPFrom string
|
||||
SMTPTLS bool
|
||||
// SMTPFromName is the sender's display name when SMTP_FROM is a bare address
|
||||
SMTPFromName string
|
||||
SMTPTLS bool
|
||||
// SMTPSkipVerify accepts self-signed or mismatching certificates (internal mail servers)
|
||||
SMTPSkipVerify bool
|
||||
|
||||
@@ -220,6 +222,7 @@ func Load() *Config {
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Speech: SpeechConfig{
|
||||
|
||||
@@ -287,9 +287,11 @@ type SystemSettings struct {
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
|
||||
// writing it empty keeps the stored password
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
|
||||
SMTPFrom string `json:"smtpFrom"`
|
||||
// SMTPFromName is shown as sender when SMTPFrom is a bare address (empty = "MindSift")
|
||||
SMTPFromName string `json:"smtpFromName"`
|
||||
SMTPTLS bool `json:"smtpTls"`
|
||||
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
|
||||
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) {
|
||||
|
||||
// --- SYSTEM SETTINGS ---
|
||||
|
||||
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls, skipVerify bool) {
|
||||
func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from, fromName string, tls, skipVerify bool) {
|
||||
cur, err := s.GetSystemSettings()
|
||||
if err != nil || cur == nil {
|
||||
return
|
||||
@@ -106,6 +106,7 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls
|
||||
cur.SMTPUser = strings.TrimSpace(user)
|
||||
cur.SMTPPass = pass
|
||||
cur.SMTPFrom = strings.TrimSpace(from)
|
||||
cur.SMTPFromName = strings.TrimSpace(fromName)
|
||||
cur.SMTPTLS = tls
|
||||
cur.SMTPSkipVerify = skipVerify
|
||||
_ = s.UpdateSystemSettings(cur)
|
||||
@@ -151,6 +152,8 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings.SMTPPass = v
|
||||
case "smtp_from":
|
||||
settings.SMTPFrom = v
|
||||
case "smtp_from_name":
|
||||
settings.SMTPFromName = v
|
||||
case "smtp_tls":
|
||||
settings.SMTPTLS = (v != "false" && v != "0")
|
||||
case "smtp_skip_verify":
|
||||
@@ -212,6 +215,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
('smtp_user', ?),
|
||||
('smtp_pass', ?),
|
||||
('smtp_from', ?),
|
||||
('smtp_from_name', ?),
|
||||
('smtp_tls', ?),
|
||||
('smtp_skip_verify', ?),
|
||||
('allow_private_webhooks', ?),
|
||||
@@ -227,6 +231,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
strings.TrimSpace(settings.SMTPUser),
|
||||
settings.SMTPPass,
|
||||
strings.TrimSpace(settings.SMTPFrom),
|
||||
strings.TrimSpace(settings.SMTPFromName),
|
||||
tlsStr,
|
||||
skipVerifyStr,
|
||||
privateHooksStr,
|
||||
|
||||
Reference in new issue
Block a user