generated from VLADIMIR/template
updates email
This commit is contained in:
@@ -3,8 +3,14 @@ package email_sender
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type sender struct {
|
||||
@@ -12,6 +18,9 @@ type sender struct {
|
||||
smtpPort string
|
||||
smtpUser string
|
||||
smtpPassword string
|
||||
from string
|
||||
timeout time.Duration
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func NewSender(
|
||||
@@ -19,76 +28,131 @@ func NewSender(
|
||||
smtpPort string,
|
||||
smtpUser string,
|
||||
smtpPassword string,
|
||||
timeout time.Duration,
|
||||
) IEmailSender {
|
||||
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
|
||||
from := smtpUser
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &sender{
|
||||
smtpHost: smtpHost,
|
||||
smtpPort: smtpPort,
|
||||
smtpUser: smtpUser,
|
||||
smtpPassword: smtpPassword,
|
||||
from: from,
|
||||
timeout: timeout,
|
||||
// Проверка имени сервера включена всегда; поле переопределяется
|
||||
// только в тестах (свой RootCAs для самоподписанного сертификата).
|
||||
tlsConfig: &tls.Config{ServerName: smtpHost},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sender) Send(_ context.Context, message Message) error {
|
||||
emailMessage := []byte(fmt.Sprintf(
|
||||
"To: %s\r\n"+
|
||||
"Subject: %s\r\n"+
|
||||
"MIME-Version: 1.0\r\n"+
|
||||
"Content-Type: text/plain; charset=utf-8\r\n"+
|
||||
"\r\n"+
|
||||
"%s\r\n",
|
||||
message.To,
|
||||
message.Subject,
|
||||
message.Body,
|
||||
))
|
||||
|
||||
// Настраиваем TLS
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.smtpHost,
|
||||
}
|
||||
|
||||
// Подключаемся к серверу
|
||||
conn, err := tls.Dial("tcp", s.smtpHost+":"+s.smtpPort, tlsConfig)
|
||||
if err != nil {
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
body := buildMessage(s.from, to, subject, message.Body)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
|
||||
addr := net.JoinHostPort(s.smtpHost, s.smtpPort)
|
||||
rawConn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("email: smtp dial %s: %w", addr, err)
|
||||
}
|
||||
defer rawConn.Close()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем SMTP клиент
|
||||
client, err := smtp.NewClient(conn, s.smtpHost)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email: smtp greeting: %w", err)
|
||||
}
|
||||
defer client.Quit()
|
||||
// Close гарантированно закрывает соединение при раннем выходе; в конце
|
||||
// штатного пути вместо него вызывается Quit.
|
||||
defer client.Close() //nolint:errcheck
|
||||
|
||||
// Аутентификация
|
||||
auth := smtp.PlainAuth("", s.smtpUser, s.smtpPassword, s.smtpHost)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email: smtp auth: %w", err)
|
||||
}
|
||||
if err = client.Mail(s.from); err != nil {
|
||||
return fmt.Errorf("email: smtp mail from: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("email: smtp rcpt to: %w", err)
|
||||
}
|
||||
|
||||
// Указываем отправителя
|
||||
if err = client.Mail(s.smtpUser); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Указываем получателя
|
||||
if err = client.Rcpt(message.To); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Отправляем данные письма
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("email: smtp data: %w", err)
|
||||
}
|
||||
_, err = w.Write(emailMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
if _, err = w.Write(body); err != nil {
|
||||
return fmt.Errorf("email: smtp write: %w", err)
|
||||
}
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("email: smtp data close: %w", err)
|
||||
}
|
||||
if err = client.Quit(); err != nil {
|
||||
return fmt.Errorf("email: smtp quit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMessage собирает тело письма: заголовки (From/To/Subject) и текст.
|
||||
// Subject кодируется по RFC 2047 (заголовки обязаны быть ASCII, кириллица
|
||||
// иначе может быть испорчена промежуточными серверами).
|
||||
func buildMessage(from, to, subject, body string) []byte {
|
||||
var b strings.Builder
|
||||
b.Grow(len(from) + len(to) + len(subject) + len(body) + 128)
|
||||
b.WriteString("From: ")
|
||||
b.WriteString(from)
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("To: ")
|
||||
b.WriteString(to)
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("Subject: ")
|
||||
b.WriteString(mime.QEncoding.Encode("utf-8", subject))
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(body)
|
||||
b.WriteString("\r\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if subject == "" {
|
||||
return errors.New("email: пустая тема письма")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package email_sender
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"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()
|
||||
|
||||
ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
messages = make(chan []byte, 8)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hang {
|
||||
go func() {
|
||||
// Приветствие отправляем, дальше молчим.
|
||||
_, _ = conn.Write([]byte("220 hang.example.com ESMTP\r\n"))
|
||||
}()
|
||||
continue
|
||||
}
|
||||
go handleSMTPConn(conn, messages)
|
||||
}
|
||||
}()
|
||||
|
||||
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 conn.Close()
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
write := func(s string) { _, _ = conn.Write([]byte(s)) }
|
||||
|
||||
write("220 test.example.com ESMTP\r\n")
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "EHLO"):
|
||||
write("250-test.example.com\r\n250-AUTH PLAIN\r\n250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "AUTH"):
|
||||
write("235 2.7.0 Authentication successful\r\n")
|
||||
case strings.HasPrefix(cmd, "MAIL"):
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "RCPT"):
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "DATA"):
|
||||
write("354 End data with <CR><LF>.<CR><LF>\r\n")
|
||||
var body []byte
|
||||
for {
|
||||
b, err := r.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
body = append(body, b...)
|
||||
if string(b) == ".\r\n" {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case messages <- body:
|
||||
default:
|
||||
}
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "QUIT"):
|
||||
write("221 Bye\r\n")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testTLSCert генерирует самоподписанный сертификат для 127.0.0.1 и
|
||||
// возвращает его вместе с распарсенным x509-представлением (Leaf), чтобы
|
||||
// тест мог добавить его в корни доверия клиента.
|
||||
func testTLSCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
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)
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(cert.Leaf)
|
||||
|
||||
return &sender{
|
||||
smtpHost: host,
|
||||
smtpPort: port,
|
||||
smtpUser: "sender@example.com",
|
||||
smtpPassword: "secret",
|
||||
from: "sender@example.com",
|
||||
timeout: timeout,
|
||||
tlsConfig: &tls.Config{ServerName: host, RootCAs: pool},
|
||||
}
|
||||
}
|
||||
|
||||
func splitHostPort(t *testing.T, addr string) (host, port string) {
|
||||
t.Helper()
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split host port: %v", err)
|
||||
}
|
||||
return host, port
|
||||
}
|
||||
|
||||
func TestSendSuccess(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, messages := startSMTPTestServer(t, false, cert)
|
||||
s := testSender(t, addr, 5*time.Second, cert)
|
||||
|
||||
err := s.Send(context.Background(), Message{
|
||||
To: "user@example.com",
|
||||
Subject: "Привет, детектив!",
|
||||
Body: "Текст письма",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case data := <-messages:
|
||||
raw := string(data)
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
"To: user@example.com",
|
||||
"Subject: =?utf-8?q?",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"\r\n\r\n",
|
||||
"Текст письма",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("письмо не получено сервером")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendRejectsCRLFInjection(t *testing.T) {
|
||||
cert := testTLSCert(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",
|
||||
Body: "body",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка для адреса с CRLF")
|
||||
}
|
||||
select {
|
||||
case <-messages:
|
||||
t.Fatal("письмо не должно было уйти на сервер")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendContextCancelled(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, _ := startSMTPTestServer(t, false, cert)
|
||||
s := testSender(t, addr, 10*time.Second, cert)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
start := time.Now()
|
||||
err := s.Send(ctx, Message{To: "user@example.com", Subject: "Test", Body: "body"})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка при отменённом контексте")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Fatalf("отмена контекста должна прерывать отправку быстро, заняло %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTimeout(t *testing.T) {
|
||||
cert := testTLSCert(t)
|
||||
addr, _ := startSMTPTestServer(t, true, cert) // сервер молчит после приветствия
|
||||
s := testSender(t, addr, 500*time.Millisecond, cert)
|
||||
|
||||
start := time.Now()
|
||||
err := s.Send(context.Background(), Message{To: "user@example.com", Subject: "Test", Body: "body"})
|
||||
if err == nil {
|
||||
t.Fatal("ожидалась ошибка таймаута")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Fatalf("таймаут должен сработать быстро, заняло %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
raw := string(buildMessage("sender@example.com", "user@example.com", "Привет", "текст"))
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
"To: user@example.com",
|
||||
"Subject: =?utf-8?q?",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"\r\n\r\n",
|
||||
"текст",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHeader(t *testing.T) {
|
||||
got := sanitizeHeader("a@b.c\r\nBcc: x@y.z\x00")
|
||||
want := "a@b.cBcc: x@y.z"
|
||||
if got != want {
|
||||
t.Errorf("sanitizeHeader = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessage(t *testing.T) {
|
||||
if err := validateMessage("user@example.com", "тема"); err != nil {
|
||||
t.Errorf("валидное письмо отклонено: %v", err)
|
||||
}
|
||||
if err := validateMessage("not-an-email", "тема"); err == nil {
|
||||
t.Error("невалидный адрес принят")
|
||||
}
|
||||
if err := validateMessage("user@example.com", ""); err == nil {
|
||||
t.Error("пустая тема принята")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user