Files
evening_detective_server/internal/modules/email_sender/sender.go
T
2026-08-20 03:27:26 +07:00

232 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package email_sender
import (
"context"
"crypto/rand"
"crypto/tls"
"errors"
"fmt"
"mime"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
)
type sender struct {
smtpHost string
smtpPort string
smtpUser string
smtpPassword string
from string
fromName string
replyTo string
timeout time.Duration
tlsConfig *tls.Config
}
func NewSender(
smtpHost string,
smtpPort string,
smtpUser string,
smtpPassword string,
fromName string,
replyTo 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,
fromName: sanitizeHeader(fromName),
replyTo: sanitizeHeader(replyTo),
timeout: timeout,
// Проверка имени сервера включена всегда; поле переопределяется
// только в тестах (свой RootCAs для самоподписанного сертификата).
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 {
return err
}
body := buildMessage(s.from, s.fromName, to, subject, s.replyTo, message.Body, message.HTML)
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)
}
}
client, err := smtp.NewClient(conn, s.smtpHost)
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)
if err = client.Auth(auth); err != nil {
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)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("email: smtp data: %w", err)
}
if _, err = w.Write(body); err != nil {
return fmt.Errorf("email: smtp write: %w", 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 собирает тело письма. Заголовки обязаны быть 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)
b.WriteString("From: ")
if fromName != "" {
b.WriteString(mime.QEncoding.Encode("utf-8", fromName))
b.WriteString(" <")
b.WriteString(from)
b.WriteString(">\r\n")
} else {
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("Date: ")
b.WriteString(time.Now().UTC().Format(time.RFC1123Z))
b.WriteString("\r\n")
b.WriteString("Message-ID: ")
b.WriteString(messageID(from))
b.WriteString("\r\n")
if replyTo != "" {
b.WriteString("Reply-To: ")
b.WriteString(replyTo)
b.WriteString("\r\n")
}
b.WriteString("MIME-Version: 1.0\r\n")
if html == "" {
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())
}
boundary := fmt.Sprintf("----=_evening_detective_%s", randomHex(10))
b.WriteString("Content-Type: multipart/alternative; boundary=\"")
b.WriteString(boundary)
b.WriteString("\"\r\n\r\n")
b.WriteString("--")
b.WriteString(boundary)
b.WriteString("\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(body)
b.WriteString("\r\n--")
b.WriteString(boundary)
b.WriteString("\r\n")
b.WriteString("Content-Type: text/html; charset=utf-8\r\n")
b.WriteString("Content-Transfer-Encoding: 8bit\r\n\r\n")
b.WriteString(html)
b.WriteString("\r\n--")
b.WriteString(boundary)
b.WriteString("--\r\n")
return []byte(b.String())
}
// messageID генерирует уникальный Message-ID в домене отправителя.
func messageID(from string) string {
domain := ""
if i := strings.LastIndex(from, "@"); i >= 0 && i < len(from)-1 {
domain = from[i+1:]
}
if domain == "" {
domain = "localhost"
}
return "<" + randomHex(12) + "@" + domain + ">"
}
// randomHex возвращает случайную hex-строку (криптостойкий генератор;
// при сбое — значение из таймера, чтобы отправка не падала).
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())
}
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)
}
if subject == "" {
return errors.New("email: пустая тема письма")
}
return nil
}