fix(settings): show the SMTP failure reason instead of a raw i18n key

classifySMTPError returned keys already carrying "pages.settings.", while the
four keys TestConnection returns directly do not, and the alert renders every
Message under that one prefix. Any classified failure therefore looked up
pages.settings.pages.settings.smtpErrorAuth, which does not exist, so the panel
printed the key instead of "Authentication failed — check username and
password". The unknown case was worse: it appended the raw error to the key, and
its own text interpolated {{ .Error }}, Go template syntax the frontend's
i18next never fills.

Return the keys unprefixed like the rest, and point the unknown case at the
panel log, which already carries the underlying error.
This commit is contained in:
Sanaei
2026-09-09 00:51:44 +02:00
parent c392f367e1
commit efc603f59c
15 changed files with 55 additions and 22 deletions
+11 -9
View File
@@ -321,28 +321,30 @@ func (s *EmailService) SendTest() error {
)
}
// classifySMTPError maps raw SMTP errors to human-readable messages.
// classifySMTPError maps a raw SMTP error to an i18n key. The key is returned
// unprefixed: the caller renders it under "pages.settings.", as does every
// other Message this file produces.
func classifySMTPError(err error) string {
msg := err.Error()
msgLower := strings.ToLower(msg)
switch {
case strings.Contains(msg, "535") || strings.Contains(msgLower, "authentication"):
return "pages.settings.smtpErrorAuth"
return "smtpErrorAuth"
case strings.Contains(msg, "534") || strings.Contains(msgLower, "starttls"):
return "pages.settings.smtpErrorStarttls"
return "smtpErrorStarttls"
case strings.Contains(msg, "465") || strings.Contains(msgLower, "tls"):
return "pages.settings.smtpErrorTls"
return "smtpErrorTls"
case strings.Contains(msgLower, "connection refused") || strings.Contains(msgLower, "dial"):
return "pages.settings.smtpErrorRefused"
return "smtpErrorRefused"
case strings.Contains(msgLower, "timeout"):
return "pages.settings.smtpErrorTimeout"
return "smtpErrorTimeout"
case strings.Contains(msg, "550") || strings.Contains(msgLower, "relay"):
return "pages.settings.smtpErrorRelay"
return "smtpErrorRelay"
case strings.Contains(msgLower, "eof"):
return "pages.settings.smtpErrorEof"
return "smtpErrorEof"
default:
return fmt.Sprintf("pages.settings.smtpErrorUnknown: %s", msg)
return "smtpErrorUnknown"
}
}
+31
View File
@@ -2,6 +2,7 @@ package email
import (
"bufio"
"errors"
"fmt"
"io"
"mime"
@@ -222,3 +223,33 @@ func TestBuildMessageStripsHeaderInjection(t *testing.T) {
t.Errorf("injected X-Evil header leaked: %q", got)
}
}
// Every Message this file emits is rendered by the frontend under a single
// "pages.settings." prefix, so a key that carries its own resolves to nothing
// and the alert shows the raw key instead of the classified reason.
func TestClassifySMTPErrorReturnsUnprefixedKeys(t *testing.T) {
cases := []struct {
name string
err error
want string
}{
{"auth", errors.New("535 5.7.8 Authentication credentials invalid"), "smtpErrorAuth"},
{"starttls", errors.New("534 must issue a STARTTLS command first"), "smtpErrorStarttls"},
{"refused", errors.New("dial tcp 127.0.0.1:25: connection refused"), "smtpErrorRefused"},
{"timeout", errors.New("i/o timeout"), "smtpErrorTimeout"},
{"relay", errors.New("550 relay not permitted"), "smtpErrorRelay"},
{"eof", errors.New("unexpected EOF"), "smtpErrorEof"},
{"unknown", errors.New("something nobody classified"), "smtpErrorUnknown"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classifySMTPError(tc.err)
if got != tc.want {
t.Fatalf("classifySMTPError = %q, want %q", got, tc.want)
}
if strings.HasPrefix(got, "pages.settings.") {
t.Fatalf("key %q carries the prefix the frontend adds, so it resolves to nothing", got)
}
})
}
}