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