update mail

This commit is contained in:
2026-08-20 03:27:26 +07:00
parent 4ff43cc2dd
commit 6d37610348
9 changed files with 433 additions and 21 deletions
+82 -9
View File
@@ -2,6 +2,7 @@ package email_sender
import (
"context"
"crypto/rand"
"crypto/tls"
"errors"
"fmt"
@@ -19,6 +20,8 @@ type sender struct {
smtpUser string
smtpPassword string
from string
fromName string
replyTo string
timeout time.Duration
tlsConfig *tls.Config
}
@@ -28,6 +31,8 @@ func NewSender(
smtpPort string,
smtpUser string,
smtpPassword string,
fromName string,
replyTo string,
timeout time.Duration,
) IEmailSender {
// From заголовка письма по умолчанию совпадает с учётной записью SMTP.
@@ -41,6 +46,8 @@ func NewSender(
smtpUser: smtpUser,
smtpPassword: smtpPassword,
from: from,
fromName: sanitizeHeader(fromName),
replyTo: sanitizeHeader(replyTo),
timeout: timeout,
// Проверка имени сервера включена всегда; поле переопределяется
// только в тестах (свой RootCAs для самоподписанного сертификата).
@@ -59,7 +66,7 @@ func (s *sender) Send(ctx context.Context, message Message) error {
if err := validateMessage(to, subject); err != nil {
return err
}
body := buildMessage(s.from, to, subject, message.Body)
body := buildMessage(s.from, s.fromName, to, subject, s.replyTo, message.Body, message.HTML)
ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel()
@@ -115,29 +122,95 @@ func (s *sender) Send(ctx context.Context, message Message) error {
return nil
}
// buildMessage собирает тело письма: заголовки (From/To/Subject) и текст.
// Subject кодируется по RFC 2047 (заголовки обязаны быть ASCII, кириллица
// иначе может быть испорчена промежуточными серверами).
func buildMessage(from, to, subject, body string) []byte {
// 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(to) + len(subject) + len(body) + 128)
b.Grow(len(from) + len(fromName) + len(to) + len(subject) + len(body) + len(html) + 512)
b.WriteString("From: ")
b.WriteString(from)
b.WriteString("\r\n")
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("\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 {