diff --git a/backend/internal/service/email_message.go b/backend/internal/service/email_message.go
new file mode 100644
index 0000000000..fc325c59c1
--- /dev/null
+++ b/backend/internal/service/email_message.go
@@ -0,0 +1,111 @@
+package service
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "mime"
+ "mime/quotedprintable"
+ "net/mail"
+ "strings"
+ "time"
+)
+
+type smtpMessage struct {
+ envelopeFrom string
+ envelopeTo string
+ data []byte
+}
+
+func buildSMTPMessage(config *SMTPConfig, to, subject, body string) (smtpMessage, error) {
+ if config == nil {
+ return smtpMessage{}, errors.New("missing SMTP configuration")
+ }
+
+ fromAddress, err := parseSMTPAddress(config.From, "from")
+ if err != nil {
+ return smtpMessage{}, err
+ }
+ recipientAddress, err := parseSMTPAddress(to, "recipient")
+ if err != nil {
+ return smtpMessage{}, err
+ }
+ messageID, err := generateEmailMessageID(fromAddress.Address, config.Host)
+ if err != nil {
+ return smtpMessage{}, fmt.Errorf("generate message ID: %w", err)
+ }
+
+ fromName := sanitizeEmailHeader(config.FromName)
+ if strings.TrimSpace(fromName) == "" {
+ fromName = fromAddress.Name
+ }
+ fromHeader := (&mail.Address{
+ Name: fromName,
+ Address: fromAddress.Address,
+ }).String()
+ toHeader := (&mail.Address{
+ Name: recipientAddress.Name,
+ Address: recipientAddress.Address,
+ }).String()
+ subjectHeader := mime.QEncoding.Encode("UTF-8", sanitizeEmailHeader(subject))
+
+ var message bytes.Buffer
+ fmt.Fprintf(&message, "From: %s\r\n", fromHeader)
+ fmt.Fprintf(&message, "To: %s\r\n", toHeader)
+ fmt.Fprintf(&message, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
+ fmt.Fprintf(&message, "Message-ID: %s\r\n", messageID)
+ fmt.Fprintf(&message, "Subject: %s\r\n", subjectHeader)
+ fmt.Fprint(&message, "MIME-Version: 1.0\r\n"+
+ "Content-Type: text/html; charset=UTF-8\r\n"+
+ "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
+
+ bodyWriter := quotedprintable.NewWriter(&message)
+ if _, err := bodyWriter.Write([]byte(body)); err != nil {
+ return smtpMessage{}, fmt.Errorf("encode email body: %w", err)
+ }
+ if err := bodyWriter.Close(); err != nil {
+ return smtpMessage{}, fmt.Errorf("close email body encoder: %w", err)
+ }
+
+ return smtpMessage{
+ envelopeFrom: fromAddress.Address,
+ envelopeTo: recipientAddress.Address,
+ data: message.Bytes(),
+ }, nil
+}
+
+func parseSMTPAddress(value, field string) (*mail.Address, error) {
+ if strings.ContainsAny(value, "\r\n") {
+ return nil, fmt.Errorf("invalid SMTP %s address: contains a line break", field)
+ }
+
+ cleaned := strings.TrimSpace(value)
+ address, err := mail.ParseAddress(cleaned)
+ if err != nil || strings.TrimSpace(address.Address) == "" {
+ if err == nil {
+ err = fmt.Errorf("address is empty")
+ }
+ return nil, fmt.Errorf("invalid SMTP %s address: %w", field, err)
+ }
+ return address, nil
+}
+
+func generateEmailMessageID(fromAddress, smtpHost string) (string, error) {
+ randomID := make([]byte, 16)
+ if _, err := rand.Read(randomID); err != nil {
+ return "", err
+ }
+
+ domain := strings.TrimSpace(sanitizeEmailHeader(smtpHost))
+ if at := strings.LastIndexByte(fromAddress, '@'); at >= 0 && at < len(fromAddress)-1 {
+ domain = fromAddress[at+1:]
+ }
+ domain = strings.Trim(domain, "[]<>")
+ if domain == "" {
+ domain = "localhost"
+ }
+
+ return fmt.Sprintf("<%s@%s>", hex.EncodeToString(randomID), domain), nil
+}
diff --git a/backend/internal/service/email_message_test.go b/backend/internal/service/email_message_test.go
new file mode 100644
index 0000000000..b5632dbd05
--- /dev/null
+++ b/backend/internal/service/email_message_test.go
@@ -0,0 +1,117 @@
+//go:build unit
+
+package service
+
+import (
+ "bytes"
+ "io"
+ "mime"
+ "mime/quotedprintable"
+ "net/mail"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildSMTPMessageProducesStandardsCompliantMIME(t *testing.T) {
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ From: "reply@example.com",
+ FromName: "Sub2API 通知",
+ }
+ body := "\n
验证码:123456 & ready\n"
+
+ message, err := buildSMTPMessage(config, "User ", "邮箱验证码", body)
+ require.NoError(t, err)
+ require.Equal(t, "reply@example.com", message.envelopeFrom)
+ require.Equal(t, "user@example.net", message.envelopeTo)
+
+ parsed, err := mail.ReadMessage(bytes.NewReader(message.data))
+ require.NoError(t, err)
+
+ from, err := mail.ParseAddress(parsed.Header.Get("From"))
+ require.NoError(t, err)
+ require.Equal(t, "Sub2API 通知", from.Name)
+ require.Equal(t, "reply@example.com", from.Address)
+
+ recipient, err := mail.ParseAddress(parsed.Header.Get("To"))
+ require.NoError(t, err)
+ require.Equal(t, "User", recipient.Name)
+ require.Equal(t, "user@example.net", recipient.Address)
+
+ decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
+ require.NoError(t, err)
+ require.Equal(t, "邮箱验证码", decodedSubject)
+ require.NotEmpty(t, parsed.Header.Get("Date"))
+ _, err = mail.ParseDate(parsed.Header.Get("Date"))
+ require.NoError(t, err)
+ require.Regexp(t, regexp.MustCompile(`^<[0-9a-f]{32}@example\.com>$`), parsed.Header.Get("Message-ID"))
+ require.Equal(t, "1.0", parsed.Header.Get("MIME-Version"))
+ require.Equal(t, "quoted-printable", parsed.Header.Get("Content-Transfer-Encoding"))
+
+ mediaType, params, err := mime.ParseMediaType(parsed.Header.Get("Content-Type"))
+ require.NoError(t, err)
+ require.Equal(t, "text/html", mediaType)
+ require.Equal(t, "UTF-8", params["charset"])
+
+ decodedBody, err := io.ReadAll(quotedprintable.NewReader(parsed.Body))
+ require.NoError(t, err)
+ require.Equal(t, strings.ReplaceAll(body, "\n", "\r\n"), string(decodedBody))
+}
+
+func TestBuildSMTPMessagePreventsHeaderInjection(t *testing.T) {
+ config := &SMTPConfig{
+ Host: "smtp.example.com",
+ From: "reply@example.com",
+ FromName: "Sender\r\nBcc: hidden@example.com",
+ }
+
+ message, err := buildSMTPMessage(config, "user@example.net", "Subject\r\nCc: hidden@example.com", "body")
+ require.NoError(t, err)
+
+ parsed, err := mail.ReadMessage(bytes.NewReader(message.data))
+ require.NoError(t, err)
+ require.Empty(t, parsed.Header.Get("Bcc"))
+ require.Empty(t, parsed.Header.Get("Cc"))
+
+ decodedSubject, err := new(mime.WordDecoder).DecodeHeader(parsed.Header.Get("Subject"))
+ require.NoError(t, err)
+ require.Equal(t, "SubjectCc: hidden@example.com", decodedSubject)
+}
+
+func TestBuildSMTPMessageRejectsInvalidConfiguration(t *testing.T) {
+ _, err := buildSMTPMessage(nil, "user@example.net", "subject", "body")
+ require.ErrorContains(t, err, "missing SMTP configuration")
+
+ _, err = buildSMTPMessage(&SMTPConfig{Host: "smtp.example.com"}, "user@example.net", "subject", "body")
+ require.ErrorContains(t, err, "invalid SMTP from address")
+
+ _, err = buildSMTPMessage(&SMTPConfig{
+ Host: "smtp.example.com",
+ From: "reply@example.com",
+ }, "invalid recipient <>", "subject", "body")
+ require.ErrorContains(t, err, "invalid SMTP recipient address")
+
+ _, err = buildSMTPMessage(&SMTPConfig{
+ Host: "smtp.example.com",
+ From: "reply@example.com",
+ }, "user@example.net\r\nBcc: hidden@example.net", "subject", "body")
+ require.ErrorContains(t, err, "invalid SMTP recipient address")
+}
+
+func TestBuildSMTPMessageUsesUniqueMessageIDs(t *testing.T) {
+ config := &SMTPConfig{Host: "smtp.example.com", From: "reply@example.com"}
+
+ first, err := buildSMTPMessage(config, "user@example.net", "subject", "body")
+ require.NoError(t, err)
+ second, err := buildSMTPMessage(config, "user@example.net", "subject", "body")
+ require.NoError(t, err)
+
+ firstParsed, err := mail.ReadMessage(bytes.NewReader(first.data))
+ require.NoError(t, err)
+ secondParsed, err := mail.ReadMessage(bytes.NewReader(second.data))
+ require.NoError(t, err)
+ require.NotEqual(t, firstParsed.Header.Get("Message-ID"), secondParsed.Header.Get("Message-ID"))
+}
diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go
index 663c39c2f1..0825257546 100644
--- a/backend/internal/service/email_service.go
+++ b/backend/internal/service/email_service.go
@@ -186,26 +186,19 @@ const smtpIOTimeout = 20 * time.Second
// SendEmailWithConfig 使用指定配置发送邮件
func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body string) error {
- // Sanitize all SMTP header fields to prevent header injection (CR/LF removal).
- to = sanitizeEmailHeader(to)
- subject = sanitizeEmailHeader(subject)
-
- from := sanitizeEmailHeader(config.From)
- if config.FromName != "" {
- from = fmt.Sprintf("%s <%s>", sanitizeEmailHeader(config.FromName), sanitizeEmailHeader(config.From))
+ message, err := buildSMTPMessage(config, to, subject, body)
+ if err != nil {
+ return err
}
- msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%s",
- from, to, subject, body)
-
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
if config.UseTLS {
- return s.sendMailTLS(addr, auth, config.From, to, []byte(msg), config.Host)
+ return s.sendMailTLS(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host)
}
- return s.sendMailPlain(addr, auth, config.From, to, []byte(msg), config.Host)
+ return s.sendMailPlain(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host)
}
// sendMailPlain sends mail without TLS using a dialer with timeout.
diff --git a/backend/internal/service/notification_email_service_test.go b/backend/internal/service/notification_email_service_test.go
index 772eb45e9e..9f12761400 100644
--- a/backend/internal/service/notification_email_service_test.go
+++ b/backend/internal/service/notification_email_service_test.go
@@ -4,7 +4,10 @@ import (
"bufio"
"context"
"errors"
+ "io"
+ "mime/quotedprintable"
"net"
+ "net/mail"
"strings"
"sync"
"sync/atomic"
@@ -598,6 +601,21 @@ func (s *notificationEmailTestSMTPServer) lastMessage() string {
return s.messageBodies[len(s.messageBodies)-1]
}
+func (s *notificationEmailTestSMTPServer) lastMessageBody(t *testing.T) string {
+ t.Helper()
+
+ message, err := mail.ReadMessage(strings.NewReader(s.lastMessage()))
+ require.NoError(t, err)
+
+ bodyReader := io.Reader(message.Body)
+ if strings.EqualFold(message.Header.Get("Content-Transfer-Encoding"), "quoted-printable") {
+ bodyReader = quotedprintable.NewReader(message.Body)
+ }
+ body, err := io.ReadAll(bodyReader)
+ require.NoError(t, err)
+ return string(body)
+}
+
func (s *notificationEmailTestSMTPServer) close() {
_ = s.listener.Close()
s.wg.Wait()
diff --git a/backend/internal/service/ops_scheduled_report_service_test.go b/backend/internal/service/ops_scheduled_report_service_test.go
index e4bfcbf05b..0904f10c32 100644
--- a/backend/internal/service/ops_scheduled_report_service_test.go
+++ b/backend/internal/service/ops_scheduled_report_service_test.go
@@ -131,9 +131,9 @@ func TestOpsScheduledReportLegacyTemplateReceivesSummaryHTML(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 1, attempts)
require.Equal(t, int64(1), smtpServer.messageCount())
- message := smtpServer.lastMessage()
- require.Contains(t, message, ``)
- require.Contains(t, message, `日报
`)
+ messageBody := smtpServer.lastMessageBody(t)
+ require.Contains(t, messageBody, ``)
+ require.Contains(t, messageBody, `日报
`)
}
func TestFormatOpsReportIntegerGroupsDigits(t *testing.T) {