This commit is contained in:
2026-10-03 13:22:11 +07:00
parent 05090722a0
commit 2caa2c1a88
74 changed files with 44 additions and 676 deletions
+1 -26
View File
@@ -36,7 +36,6 @@ func NewSender(
replyTo string,
timeout time.Duration,
) IEmailSender {
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
from := smtpUser
if timeout <= 0 {
timeout = 10 * time.Second
@@ -50,18 +49,11 @@ func NewSender(
fromName: sanitizeHeader(fromName),
replyTo: sanitizeHeader(replyTo),
timeout: timeout,
// Проверка имени сервера включена всегда; поле переопределяется
// только в тестах (свой RootCAs для самоподписанного сертификата).
tlsConfig: &tls.Config{ServerName: smtpHost},
tlsConfig: &tls.Config{ServerName: smtpHost},
}
}
// Send доставляет письмо через SMTP (implicit TLS) с учётом контекста и
// таймаута: соединение устанавливается через DialContext, а все фазы SMTP-
// диалога ограничены общим deadline, производным от ctx.
func (s *sender) Send(ctx context.Context, message Message) error {
// Санитизация пользовательского ввода: CR/LF/NUL в заголовках ломают
// формат письма и позволяют инъекцию произвольных заголовков (Bcc и т.п.).
to := sanitizeHeader(message.To)
subject := sanitizeHeader(message.Subject)
if err := validateMessage(to, subject); err != nil {
@@ -81,8 +73,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
conn := tls.Client(rawConn, s.tlsConfig)
if deadline, ok := ctx.Deadline(); ok {
// Единый бюджет времени на весь SMTP-диалог: зависший сервер не
// должен держать запрос дольше таймаута.
if err = conn.SetDeadline(deadline); err != nil {
return fmt.Errorf("email: set deadline: %w", err)
}
@@ -92,8 +82,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
if err != nil {
return fmt.Errorf("email: smtp greeting: %w", err)
}
// Close гарантированно закрывает соединение при раннем выходе; в конце
// штатного пути вместо него вызывается Quit.
defer client.Close() //nolint:errcheck
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
@@ -123,12 +111,6 @@ func (s *sender) Send(ctx context.Context, message Message) error {
return nil
}
// buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому
// не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе
// кириллица может быть испорчена промежуточными серверами. Заголовки
// Date и Message-ID добавляются явно: их отсутствие — типичный признак
// спама для почтовых фильтров (включая Gmail). Если задана HTML-версия,
// письмо собирается как multipart/alternative (text/plain + text/html).
func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byte {
var b strings.Builder
b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512)
@@ -190,7 +172,6 @@ func buildMessage(from, fromName, to, subject, replyTo, body, html string) []byt
return []byte(b.String())
}
// messageID генерирует уникальный Message-ID в домене отправителя.
func messageID(from string) string {
domain := ""
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
@@ -202,8 +183,6 @@ func messageID(from string) string {
return "<" + randomHex(12) + "@" + domain + ">"
}
// randomHex возвращает случайную hex-строку (криптостойкий генератор;
// при сбое — значение из таймера, чтобы отправка не падала).
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
@@ -212,15 +191,11 @@ func randomHex(n int) string {
return fmt.Sprintf("%x", b)
}
// sanitizeHeader удаляет символы, ломающие структуру заголовков письма
// (CRLF-инъекция заголовков, NUL).
func sanitizeHeader(s string) string {
r := strings.NewReplacer("\r", "", "\n", "", "\x00", "")
return r.Replace(s)
}
// validateMessage проверяет адрес получателя и наличие темы до обращения к
// SMTP-серверу.
func validateMessage(to, subject string) error {
if _, err := mail.ParseAddress(to); err != nil {
return fmt.Errorf("email: некорректный адрес получателя: %w", err)
+1 -18
View File
@@ -16,11 +16,6 @@ import (
"time"
)
// startSMTPTestServer поднимает минимальный SMTP-сервер (implicit TLS) на
// случайном порту 127.0.0.1 и возвращает его адрес и канал с принятыми
// телами писем. При hang=true сервер отправляет приветствие и молчит —
// для проверки таймаутов. Сертификат генерируется один на тест и
// передаётся и серверу, и клиенту (testSender).
func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr string, messages chan []byte) {
t.Helper()
@@ -41,7 +36,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st
}
if hang {
go func() {
// Приветствие отправляем, дальше молчим.
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
}()
continue
@@ -53,7 +47,6 @@ func startSMTPTestServer(t *testing.T, hang bool, cert tls.Certificate) (addr st
return ln.Addr().String(), messages
}
// handleSMTPConn обслуживает один SMTP-диалог, достаточный для net/smtp:
// EHLO, AUTH PLAIN, MAIL, RCPT, DATA, QUIT.
func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
defer func() { _ = conn.Close() }()
@@ -102,9 +95,6 @@ func handleSMTPConn(conn net.Conn, messages chan<- []byte) {
}
}
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
// тест мог добавить его в корни доверия клиента.
func testTLSCert(t *testing.T) tls.Certificate {
t.Helper()
@@ -133,8 +123,6 @@ func testTLSCert(t *testing.T) tls.Certificate {
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}
}
// testSender собирает sender, доверяющий самоподписанному сертификату
// тестового SMTP-сервера (в проде NewSender использует системные корни).
func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certificate) *sender {
t.Helper()
host, port := splitHostPort(t, addr)
@@ -183,7 +171,6 @@ func TestSendSuccess(t *testing.T) {
case data := <-messages:
raw := string(data)
for _, want := range []string{
// From с отображаемым именем (RFC 2047).
"From: =?utf-8?b?",
"<sender@example.com>",
"To: user@example.com",
@@ -213,8 +200,6 @@ func TestSendRejectsCRLFInjection(t *testing.T) {
addr, messages := startSMTPTestServer(t, false, cert)
s := testSender(t, addr, 5*time.Second, cert)
// CRLF в адресе — попытка инъекции заголовков (Bcc и т.п.): письмо
// должно быть отклонено до обращения к SMTP-серверу.
err := s.Send(context.Background(), Message{
To: "user@example.com\r\nBcc: victim@example.com",
Subject: "Test",
@@ -250,7 +235,7 @@ func TestSendContextCancelled(t *testing.T) {
func TestSendTimeout(t *testing.T) {
cert := testTLSCert(t)
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
addr, _ := startSMTPTestServer(t, true, cert)
s := testSender(t, addr, 500*time.Millisecond, cert)
start := time.Now()
@@ -264,7 +249,6 @@ func TestSendTimeout(t *testing.T) {
}
func TestBuildMessage(t *testing.T) {
// С HTML-версией — multipart/alternative.
raw := string(buildMessage(
"sender@example.com", "Вечерний детектив",
"user@example.com", "Привет", "support@example.com",
@@ -290,7 +274,6 @@ func TestBuildMessage(t *testing.T) {
}
}
// Без HTML-версии — только text/plain (обратная совместимость).
raw = string(buildMessage(
"sender@example.com", "Вечерний детектив",
"user@example.com", "Привет", "",
@@ -10,7 +10,6 @@ func ButtonHTML(link, label string) string {
</td></tr></table>`
}
// WrapHTML возвращает общую светлую/тёмную HTML-обёртку для писем сервиса.
func WrapHTML(content, baseURL string) string {
fontURL := ""
if baseURL != "" {