feat(mail): sender display name (SMTP_FROM_NAME, default MindSift)
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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 23:47:38 +01:00
1 parent 1632dd22c4
commit 56c1fa02e6
12 files changed
+86 -14

No files matched your search

+24 -5
View File
@@ -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}
}
+14
View File
@@ -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)
}
}
}
+4 -1
View File
@@ -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{
+5 -3
View File
@@ -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
+6 -1
View File
@@ -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,