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 }