From 97dd724424c651c0e2b1c0cdff6b6ab5982bf789 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 14 Jul 2026 22:57:26 +0200 Subject: [PATCH] fix(email): resolve a name-addr smtpFrom into bare envelope address and display name The save-time validator accepts any RFC 5322 address form, so a value like '3x-ui Panel ' passes validation, but Send and TestConnection fed that raw string to MAIL FROM, which strict servers reject with 501, and buildMessage mangled it into a quoted local part. Parse the configured sender at the point of use: the envelope gets the bare address and, when no explicit sender name is set, the display name embedded in the setting is used for the From header. --- internal/web/service/email/email.go | 13 +++ internal/web/service/email/email_test.go | 118 +++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/internal/web/service/email/email.go b/internal/web/service/email/email.go index 1aca8b0d6..df7892588 100644 --- a/internal/web/service/email/email.go +++ b/internal/web/service/email/email.go @@ -57,6 +57,7 @@ func (s *EmailService) Send(subject, body string) error { if from == "" { return fmt.Errorf("smtp from not configured") } + from, fromName = resolveFrom(from, fromName) recipients := parseRecipients(toStr) if len(recipients) == 0 { @@ -116,6 +117,7 @@ func (s *EmailService) TestConnection() SMTPTestResult { if from == "" { from = username } + from, fromName = resolveFrom(from, fromName) recipients := parseRecipients(toStr) if len(recipients) == 0 { @@ -304,6 +306,17 @@ func parseRecipients(toStr string) []string { // (entity.AllSetting.CheckValid), this is defense in depth for buildMessage. var headerSanitizer = strings.NewReplacer("\r", "", "\n", "") +func resolveFrom(from, fromName string) (string, string) { + parsed, err := mail.ParseAddress(from) + if err != nil { + return from, fromName + } + if fromName == "" { + fromName = parsed.Name + } + return parsed.Address, fromName +} + func buildMessage(fromAddr, fromName string, to []string, subject, body string) []byte { fromAddr = headerSanitizer.Replace(fromAddr) fromName = headerSanitizer.Replace(fromName) diff --git a/internal/web/service/email/email_test.go b/internal/web/service/email/email_test.go index 225f3cef6..15a461306 100644 --- a/internal/web/service/email/email_test.go +++ b/internal/web/service/email/email_test.go @@ -1,11 +1,20 @@ package email import ( + "bufio" + "fmt" "io" "mime" + "net" "net/mail" + "path/filepath" + "strconv" "strings" + "sync" "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) func TestBuildMessageIsRFC5322(t *testing.T) { @@ -62,6 +71,115 @@ func TestBuildMessageFromWithoutName(t *testing.T) { } } +func startFakeSMTPServer(t *testing.T) (string, func() []string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = ln.Close() }) + + var mu sync.Mutex + var lines []string + record := func(line string) { + mu.Lock() + defer mu.Unlock() + lines = append(lines, line) + } + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + fmt.Fprint(conn, "220 fake ready\r\n") + inData := false + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + record(line) + if inData { + if line == "." { + inData = false + fmt.Fprint(conn, "250 ok\r\n") + } + continue + } + switch { + case strings.HasPrefix(line, "DATA"): + inData = true + fmt.Fprint(conn, "354 send\r\n") + case strings.HasPrefix(line, "QUIT"): + fmt.Fprint(conn, "221 bye\r\n") + return + default: + fmt.Fprint(conn, "250 ok\r\n") + } + } + }() + + return ln.Addr().String(), func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), lines...) + } +} + +func TestSendUsesBareAddressFromNameAddrSmtpFrom(t *testing.T) { + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + addr, recordedLines := startFakeSMTPServer(t) + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + t.Fatal(err) + } + + settingService := service.SettingService{} + mustSet := func(name string, err error) { + t.Helper() + if err != nil { + t.Fatalf("set %s: %v", name, err) + } + } + mustSet("host", settingService.SetSmtpHost(host)) + mustSet("port", settingService.SetSmtpPort(port)) + mustSet("from", settingService.SetSmtpFrom("3x-ui Panel ")) + mustSet("to", settingService.SetSmtpTo("admin@example.com")) + mustSet("encryption", settingService.SetSmtpEncryptionType("none")) + + if err := NewEmailService(settingService).Send("subject", "hi"); err != nil { + t.Fatalf("send: %v", err) + } + + var mailFrom, fromHeader string + for _, line := range recordedLines() { + if strings.HasPrefix(line, "MAIL FROM:") { + mailFrom = line + } + if strings.HasPrefix(line, "From: ") { + fromHeader = line + } + } + if want := "MAIL FROM:"; mailFrom != want { + t.Errorf("envelope sender = %q, want %q", mailFrom, want) + } + if want := `From: "3x-ui Panel" `; fromHeader != want { + t.Errorf("from header = %q, want %q", fromHeader, want) + } +} + func TestBuildMessageStripsHeaderInjection(t *testing.T) { raw := buildMessage( "panel@example.com\r\nBcc: evil@example.com",