updates email

This commit is contained in:
2026-08-20 02:31:14 +07:00
parent be15264764
commit 4ff43cc2dd
8 changed files with 802 additions and 64 deletions
+110 -46
View File
@@ -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
}