diff --git a/.env.example b/.env.example index f33f3d1..b5c7ba1 100644 --- a/.env.example +++ b/.env.example @@ -4,11 +4,20 @@ SMTP_HOST=mail.crabs-games.art SMTP_PORT=465 SMTP_USER=evening_detective@crabs-games.art SMTP_PASSWORD=your_password +# Отображаемое имя отправителя: Gmail показывает его вместо голого адреса, +# письма от «имени» выглядят человечнее и реже попадают в спам +SMTP_FROM_NAME=Вечерний детектив +# Адрес, на который пользователь сможет ответить на письмо +SMTP_REPLY_TO=evening_detective@crabs-games.art # Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s SMTP_TIMEOUT=10s JWT_SECRET=your_secret +# Публичный адрес фронтенда: используется для ссылки «Войти в игру» в +# письмах. Если не задан или не http(s) — ссылка в письма не добавляется. +APP_BASE_URL=https://evening-detective.crabs-games.art + S3_HOST=http://0.0.0.0:9000 S3_USER=rustfs S3_PASSWORD=your_password diff --git a/bin/evening_detective_server b/bin/evening_detective_server index 320d09c..b09a39e 100755 Binary files a/bin/evening_detective_server and b/bin/evening_detective_server differ diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index bed5cb5..6452ddb 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -71,11 +71,13 @@ func main() { os.Getenv("SMTP_PORT"), os.Getenv("SMTP_USER"), os.Getenv("SMTP_PASSWORD"), + os.Getenv("SMTP_FROM_NAME"), + os.Getenv("SMTP_REPLY_TO"), smtpTimeout, ) processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET")) refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool) - usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo) + usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL")) uiService := ui_service.NewUiService() fileStorage, err := file_storage.NewRustFSStorage( os.Getenv("S3_HOST"), diff --git a/internal/modules/email_sender/interface.go b/internal/modules/email_sender/interface.go index 08ded4f..6973c4a 100644 --- a/internal/modules/email_sender/interface.go +++ b/internal/modules/email_sender/interface.go @@ -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 { diff --git a/internal/modules/email_sender/sender.go b/internal/modules/email_sender/sender.go index 96c9dd3..521fea8 100644 --- a/internal/modules/email_sender/sender.go +++ b/internal/modules/email_sender/sender.go @@ -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 { diff --git a/internal/modules/email_sender/sender_test.go b/internal/modules/email_sender/sender_test.go index d58a167..176e4e7 100644 --- a/internal/modules/email_sender/sender_test.go +++ b/internal/modules/email_sender/sender_test.go @@ -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: "
Текст письма", }) 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?", + "Привет, ") + content.WriteString(html.EscapeString(username)) + content.WriteString("!
\n") + content.WriteString("Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:
\n") + content.WriteString(credentialsBlockHTML(email, password)) + if loginURL != "" { + content.WriteString(buttonHTML(loginURL, "Войти в игру")) + } + content.WriteString("Если ты не регистрировался — просто проигнорируй это письмо.
") + return wrapHTML(content.String()) +} + +// resetPlain возвращает текстовую версию письма о сбросе пароля. +func resetPlain(username, email, password, loginURL string) string { + var b strings.Builder + b.WriteString(greeting(username)) + b.WriteString("\n\n") + b.WriteString("Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:\n\n") + b.WriteString(" Логин: ") + b.WriteString(email) + b.WriteString("\n") + b.WriteString(" Пароль: ") + b.WriteString(password) + b.WriteString("\n\n") + if loginURL != "" { + b.WriteString("Войти в игру: ") + b.WriteString(loginURL) + b.WriteString("\n\n") + } + b.WriteString("Никому не сообщай пароль и не пересылай это письмо.\n") + b.WriteString("Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.\n\n") + b.WriteString("— Команда «Вечернего детектива»\n") + return sanitizeLine(b.String()) +} + +// resetHTML возвращает HTML-версию письма о сбросе пароля. +func resetHTML(username, email, password, loginURL string) string { + var content strings.Builder + content.WriteString("Привет, ") + content.WriteString(html.EscapeString(username)) + content.WriteString("!
\n") + content.WriteString("Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:
\n") + content.WriteString(credentialsBlockHTML(email, password)) + if loginURL != "" { + content.WriteString(buttonHTML(loginURL, "Войти в игру")) + } + content.WriteString("Никому не сообщай пароль и не пересылай это письмо.
\n") + content.WriteString("Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.
") + return wrapHTML(content.String()) +} + +// greeting возвращает приветствие с именем пользователя; при пустом имени — +// без обращения, чтобы не было «Привет, !». +func greeting(username string) string { + username = sanitizeLine(strings.TrimSpace(username)) + if username == "" { + return "Привет!" + } + return fmt.Sprintf("Привет, %s!", username) +} + +// credentialsBlockHTML — блок «Логин / Пароль» в табличной вёрстке, +// совместимой с Gmail. +func credentialsBlockHTML(email, password string) string { + return `| Логин | +` + html.EscapeString(email) + ` | +
| Пароль | +` + html.EscapeString(password) + ` | +
| +` + html.EscapeString(label) + ` + | +
| + + |