mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #4993 from 17Yuns/fix/smtp-message-format
fix(email): generate standards-compliant SMTP messages
This commit is contained in:
@@ -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 & 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"))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user