Przeglądaj źródła

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 <panel(at)example.com>' 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.
MHSanaei 1 dzień temu
rodzic
commit
97dd724424

+ 13 - 0
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)

+ 118 - 0
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 <[email protected]>"))
+	mustSet("to", settingService.SetSmtpTo("[email protected]"))
+	mustSet("encryption", settingService.SetSmtpEncryptionType("none"))
+
+	if err := NewEmailService(settingService).Send("subject", "<b>hi</b>"); 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:<[email protected]>"; mailFrom != want {
+		t.Errorf("envelope sender = %q, want %q", mailFrom, want)
+	}
+	if want := `From: "3x-ui Panel" <[email protected]>`; fromHeader != want {
+		t.Errorf("from header = %q, want %q", fromHeader, want)
+	}
+}
+
 func TestBuildMessageStripsHeaderInjection(t *testing.T) {
 	raw := buildMessage(
 		"[email protected]\r\nBcc: [email protected]",