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) {