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 } // NewSender creates an SMTP email sender. func NewSender( smtpHost string, smtpPort string, smtpUser string, smtpPassword string, fromName string, replyTo string, timeout time.Duration, ) IEmailSender { 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, tlsConfig: &tls.Config{ServerName: smtpHost}, } } func (s *sender) Send(ctx context.Context, message Message) error { 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 func() { _ = rawConn.Close() }() conn := tls.Client(rawConn, s.tlsConfig) if deadline, ok := ctx.Deadline(); ok { 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) } 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 } 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.BEncoding.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()) } 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 + ">" } 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) } func sanitizeHeader(s string) string { r := strings.NewReplacer("\r", "", "\n", "", "\x00", "") return r.Replace(s) } 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 }