generated from VLADIMIR/template
update mail
This commit is contained in:
@@ -5,7 +5,11 @@ import "context"
|
||||
type Message struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
// Body — текстовая версия письма (text/plain).
|
||||
Body string
|
||||
// HTML — версия письма для почтовых клиентов (text/html).
|
||||
// Если пусто, письмо уходит только в text/plain.
|
||||
HTML string
|
||||
}
|
||||
|
||||
type IEmailSender interface {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -147,6 +147,8 @@ func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certi
|
||||
smtpUser: "sender@example.com",
|
||||
smtpPassword: "secret",
|
||||
from: "sender@example.com",
|
||||
fromName: "Вечерний детектив",
|
||||
replyTo: "support@example.com",
|
||||
timeout: timeout,
|
||||
tlsConfig: &tls.Config{ServerName: host, RootCAs: pool},
|
||||
}
|
||||
@@ -170,6 +172,7 @@ func TestSendSuccess(t *testing.T) {
|
||||
To: "user@example.com",
|
||||
Subject: "Привет, детектив!",
|
||||
Body: "Текст письма",
|
||||
HTML: "<html><body>Текст письма</body></html>",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
@@ -179,13 +182,21 @@ func TestSendSuccess(t *testing.T) {
|
||||
case data := <-messages:
|
||||
raw := string(data)
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
// From с отображаемым именем (RFC 2047).
|
||||
"From: =?utf-8?q?",
|
||||
"<sender@example.com>",
|
||||
"To: user@example.com",
|
||||
"Subject: =?utf-8?q?",
|
||||
"Date: ",
|
||||
"Message-ID: <",
|
||||
"@example.com>",
|
||||
"Reply-To: support@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative; boundary=",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"\r\n\r\n",
|
||||
"Текст письма",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"<html><body>Текст письма</body></html>",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
@@ -252,15 +263,71 @@ func TestSendTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
raw := string(buildMessage("sender@example.com", "user@example.com", "Привет", "текст"))
|
||||
// С HTML-версией — multipart/alternative.
|
||||
raw := string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "support@example.com",
|
||||
"текст", "<html><body>текст</body></html>",
|
||||
))
|
||||
for _, want := range []string{
|
||||
"From: sender@example.com",
|
||||
"From: =?utf-8?q?",
|
||||
"<sender@example.com>",
|
||||
"To: user@example.com",
|
||||
"Subject: =?utf-8?q?",
|
||||
"Date: ",
|
||||
"Message-ID: <",
|
||||
"Reply-To: support@example.com",
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: multipart/alternative; boundary=",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"\r\n\r\n",
|
||||
"текст",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"<html><body>текст</body></html>",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Без HTML-версии — только text/plain (обратная совместимость).
|
||||
raw = string(buildMessage(
|
||||
"sender@example.com", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "",
|
||||
"текст", "",
|
||||
))
|
||||
for _, want := range []string{
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"текст",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("plain-письмо не содержит %q:\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(raw, "multipart/alternative") {
|
||||
t.Errorf("plain-письмо не должно быть multipart:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageNoFromName(t *testing.T) {
|
||||
raw := string(buildMessage(
|
||||
"sender@example.com", "",
|
||||
"user@example.com", "Привет", "",
|
||||
"текст", "",
|
||||
))
|
||||
if !strings.Contains(raw, "From: sender@example.com\r\n") {
|
||||
t.Errorf("адрес без имени отправителя должен идти без угловых скобок:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageMessageIDDomain(t *testing.T) {
|
||||
raw := string(buildMessage(
|
||||
"evening_detective@crabs-games.art", "Вечерний детектив",
|
||||
"user@example.com", "Привет", "",
|
||||
"текст", "",
|
||||
))
|
||||
for _, want := range []string{
|
||||
"Message-ID: <",
|
||||
"@crabs-games.art>",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Errorf("письмо не содержит %q:\n%s", want, raw)
|
||||
|
||||
Reference in New Issue
Block a user