Files
evening_detective_server/internal/modules/email_sender/sender.go
T
2026-08-20 02:31:14 +07:00

159 lines
5.3 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/tls"
"errors"
"fmt"
"mime"
"net"
"net/mail"
"net/smtp"
"strings"
"time"
)
type sender struct {
smtpHost string
smtpPort string
smtpUser string
smtpPassword string
from string
timeout time.Duration
tlsConfig *tls.Config
}
func NewSender(
smtpHost string,
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},
}
}
// 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, 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)
}
}
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 собирает тело письма: заголовки (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
}