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 }