generated from VLADIMIR/template
159 lines
5.3 KiB
Go
159 lines
5.3 KiB
Go
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
|
||
}
|