Merge pull request #4993 from 17Yuns/fix/smtp-message-format

fix(email): generate standards-compliant SMTP messages
This commit is contained in:
Wesley Liddick
2026-07-31 11:43:32 +08:00
committed by GitHub
5 changed files with 254 additions and 15 deletions
+111
View File
@@ -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
}
@@ -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 := "<html>\n<body>验证码:123456 &amp; ready</body>\n</html>"
message, err := buildSMTPMessage(config, "User <user@example.net>", "邮箱验证码", 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"))
}
+5 -12
View File
@@ -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.
@@ -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()
@@ -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, `<section data-template="legacy">`)
require.Contains(t, message, `<h2>日报</h2>`)
messageBody := smtpServer.lastMessageBody(t)
require.Contains(t, messageBody, `<section data-template="legacy">`)
require.Contains(t, messageBody, `<h2>日报</h2>`)
}
func TestFormatOpsReportIntegerGroupsDigits(t *testing.T) {