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
+9
View File
@@ -4,11 +4,20 @@ SMTP_HOST=mail.crabs-games.art
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER=evening_detective@crabs-games.art SMTP_USER=evening_detective@crabs-games.art
SMTP_PASSWORD=your_password SMTP_PASSWORD=your_password
# Отображаемое имя отправителя: Gmail показывает его вместо голого адреса,
# письма от «имени» выглядят человечнее и реже попадают в спам
SMTP_FROM_NAME=Вечерний детектив
# Адрес, на который пользователь сможет ответить на письмо
SMTP_REPLY_TO=evening_detective@crabs-games.art
# Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s # Общий таймаут SMTP-диалога (dial + auth + отправка), дефолт 10s
SMTP_TIMEOUT=10s SMTP_TIMEOUT=10s
JWT_SECRET=your_secret JWT_SECRET=your_secret
# Публичный адрес фронтенда: используется для ссылки «Войти в игру» в
# письмах. Если не задан или не http(s) — ссылка в письма не добавляется.
APP_BASE_URL=https://evening-detective.crabs-games.art
S3_HOST=http://0.0.0.0:9000 S3_HOST=http://0.0.0.0:9000
S3_USER=rustfs S3_USER=rustfs
S3_PASSWORD=your_password S3_PASSWORD=your_password
Binary file not shown.
+3 -1
View File
@@ -71,11 +71,13 @@ func main() {
os.Getenv("SMTP_PORT"), os.Getenv("SMTP_PORT"),
os.Getenv("SMTP_USER"), os.Getenv("SMTP_USER"),
os.Getenv("SMTP_PASSWORD"), os.Getenv("SMTP_PASSWORD"),
os.Getenv("SMTP_FROM_NAME"),
os.Getenv("SMTP_REPLY_TO"),
smtpTimeout, smtpTimeout,
) )
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET")) processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool) 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() uiService := ui_service.NewUiService()
fileStorage, err := file_storage.NewRustFSStorage( fileStorage, err := file_storage.NewRustFSStorage(
os.Getenv("S3_HOST"), os.Getenv("S3_HOST"),
+5 -1
View File
@@ -5,7 +5,11 @@ import "context"
type Message struct { type Message struct {
To string To string
Subject string Subject string
Body string // Body — текстовая версия письма (text/plain).
Body string
// HTML — версия письма для почтовых клиентов (text/html).
// Если пусто, письмо уходит только в text/plain.
HTML string
} }
type IEmailSender interface { type IEmailSender interface {
+82 -9
View File
@@ -2,6 +2,7 @@ package email_sender
import ( import (
"context" "context"
"crypto/rand"
"crypto/tls" "crypto/tls"
"errors" "errors"
"fmt" "fmt"
@@ -19,6 +20,8 @@ type sender struct {
smtpUser string smtpUser string
smtpPassword string smtpPassword string
from string from string
fromName string
replyTo string
timeout time.Duration timeout time.Duration
tlsConfig *tls.Config tlsConfig *tls.Config
} }
@@ -28,6 +31,8 @@ func NewSender(
smtpPort string, smtpPort string,
smtpUser string, smtpUser string,
smtpPassword string, smtpPassword string,
fromName string,
replyTo string,
timeout time.Duration, timeout time.Duration,
) IEmailSender { ) IEmailSender {
// From заголовка письма по умолчанию совпадает с учётной записью SMTP. // From заголовка письма по умолчанию совпадает с учётной записью SMTP.
@@ -41,6 +46,8 @@ func NewSender(
smtpUser: smtpUser, smtpUser: smtpUser,
smtpPassword: smtpPassword, smtpPassword: smtpPassword,
from: from, from: from,
fromName: sanitizeHeader(fromName),
replyTo: sanitizeHeader(replyTo),
timeout: timeout, timeout: timeout,
// Проверка имени сервера включена всегда; поле переопределяется // Проверка имени сервера включена всегда; поле переопределяется
// только в тестах (свой RootCAs для самоподписанного сертификата). // только в тестах (свой RootCAs для самоподписанного сертификата).
@@ -59,7 +66,7 @@ func (s *sender) Send(ctx context.Context, message Message) error {
if err := validateMessage(to, subject); err != nil { if err := validateMessage(to, subject); err != nil {
return err 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) ctx, cancel := context.WithTimeout(ctx, s.timeout)
defer cancel() defer cancel()
@@ -115,29 +122,95 @@ func (s *sender) Send(ctx context.Context, message Message) error {
return nil return nil
} }
// buildMessage собирает тело письма: заголовки (From/To/Subject) и текст. // buildMessage собирает тело письма. Заголовки обязаны быть ASCII, поэтому
// Subject кодируется по RFC 2047 (заголовки обязаны быть ASCII, кириллица // не-ASCII значения (имя отправителя, тема) кодируются по RFC 2047, иначе
// иначе может быть испорчена промежуточными серверами). // кириллица может быть испорчена промежуточными серверами. Заголовки
func buildMessage(from, to, subject, body string) []byte { // 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 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(from) if fromName != "" {
b.WriteString("\r\n") 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(to) b.WriteString(to)
b.WriteString("\r\n") b.WriteString("\r\n")
b.WriteString("Subject: ") b.WriteString("Subject: ")
b.WriteString(mime.QEncoding.Encode("utf-8", subject)) b.WriteString(mime.QEncoding.Encode("utf-8", subject))
b.WriteString("\r\n") 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") 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-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(body)
b.WriteString("\r\n--")
b.WriteString(boundary)
b.WriteString("\r\n") 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()) 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 удаляет символы, ломающие структуру заголовков письма // sanitizeHeader удаляет символы, ломающие структуру заголовков письма
// (CRLF-инъекция заголовков, NUL). // (CRLF-инъекция заголовков, NUL).
func sanitizeHeader(s string) string { func sanitizeHeader(s string) string {
+72 -5
View File
@@ -147,6 +147,8 @@ func testSender(t *testing.T, addr string, timeout time.Duration, cert tls.Certi
smtpUser: "sender@example.com", smtpUser: "sender@example.com",
smtpPassword: "secret", smtpPassword: "secret",
from: "sender@example.com", from: "sender@example.com",
fromName: "Вечерний детектив",
replyTo: "support@example.com",
timeout: timeout, timeout: timeout,
tlsConfig: &tls.Config{ServerName: host, RootCAs: pool}, tlsConfig: &tls.Config{ServerName: host, RootCAs: pool},
} }
@@ -170,6 +172,7 @@ func TestSendSuccess(t *testing.T) {
To: "user@example.com", To: "user@example.com",
Subject: "Привет, детектив!", Subject: "Привет, детектив!",
Body: "Текст письма", Body: "Текст письма",
HTML: "<html><body>Текст письма</body></html>",
}) })
if err != nil { if err != nil {
t.Fatalf("Send: %v", err) t.Fatalf("Send: %v", err)
@@ -179,13 +182,21 @@ func TestSendSuccess(t *testing.T) {
case data := <-messages: case data := <-messages:
raw := string(data) raw := string(data)
for _, want := range []string{ for _, want := range []string{
"From: sender@example.com", // From с отображаемым именем (RFC 2047).
"From: =?utf-8?q?",
"<sender@example.com>",
"To: user@example.com", "To: user@example.com",
"Subject: =?utf-8?q?", "Subject: =?utf-8?q?",
"Date: ",
"Message-ID: <",
"@example.com>",
"Reply-To: support@example.com",
"MIME-Version: 1.0", "MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=",
"Content-Type: text/plain; charset=utf-8", "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) { if !strings.Contains(raw, want) {
t.Errorf("письмо не содержит %q:\n%s", want, raw) t.Errorf("письмо не содержит %q:\n%s", want, raw)
@@ -252,15 +263,71 @@ func TestSendTimeout(t *testing.T) {
} }
func TestBuildMessage(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{ for _, want := range []string{
"From: sender@example.com", "From: =?utf-8?q?",
"<sender@example.com>",
"To: user@example.com", "To: user@example.com",
"Subject: =?utf-8?q?", "Subject: =?utf-8?q?",
"Date: ",
"Message-ID: <",
"Reply-To: support@example.com",
"MIME-Version: 1.0", "MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=",
"Content-Type: text/plain; charset=utf-8", "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) { if !strings.Contains(raw, want) {
t.Errorf("письмо не содержит %q:\n%s", want, raw) t.Errorf("письмо не содержит %q:\n%s", want, raw)
+197
View File
@@ -0,0 +1,197 @@
package users_service
import (
"fmt"
"html"
"strings"
)
// Шаблоны писем сервиса. Структура по базе знаний копирайтера (email.md):
// приветствие → контекст («почему это письмо») → ценность (данные для входа)
// → CTA → подпись → P.S. Письма транзакционные, но по форме — «человек
// человеку»: Gmail и другие почтовики агрессивнее фильтруют короткие
// «роботные» письма без структуры.
//
// Каждое письмо формируется в двух версиях: text/plain (для старых клиентов
// и фильтров) и text/html (для отображения); sender собирает их в
// multipart/alternative.
const (
// welcomeSubject — тема письма о регистрации: конкретика без КАПСА и
// кликбейта, до 50 символов (видно на мобильных целиком).
welcomeSubject = "Твой доступ к «Вечернему детективу»"
// resetSubject — тема письма о сбросе пароля.
resetSubject = "Новый пароль для «Вечернего детектива»"
)
// welcomePlain возвращает текстовую версию письма о регистрации.
func welcomePlain(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\n")
b.WriteString("— Команда «Вечернего детектива»\n")
b.WriteString("P.S. Сохрани это письмо: пароль понадобится для входа.")
return sanitizeLine(b.String())
}
// welcomeHTML возвращает HTML-версию письма о регистрации.
func welcomeHTML(username, email, password, loginURL string) string {
var content strings.Builder
content.WriteString("<p>Привет, <strong>")
content.WriteString(html.EscapeString(username))
content.WriteString("</strong>!</p>\n")
content.WriteString("<p>Твоя регистрация в «Вечернем детективе» прошла. Данные для входа:</p>\n")
content.WriteString(credentialsBlockHTML(email, password))
if loginURL != "" {
content.WriteString(buttonHTML(loginURL, "Войти в игру"))
}
content.WriteString("<p style=\"color:#777777; font-size:13px;\">Если ты не регистрировался — просто проигнорируй это письмо.</p>")
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("<p>Привет, <strong>")
content.WriteString(html.EscapeString(username))
content.WriteString("</strong>!</p>\n")
content.WriteString("<p>Ты запросил сброс пароля в «Вечернем детективе». Новый пароль для входа:</p>\n")
content.WriteString(credentialsBlockHTML(email, password))
if loginURL != "" {
content.WriteString(buttonHTML(loginURL, "Войти в игру"))
}
content.WriteString("<p>Никому не сообщай пароль и не пересылай это письмо.</p>\n")
content.WriteString("<p style=\"color:#777777; font-size:13px;\">Если это был не ты — ответь на это письмо: мы поможем защитить аккаунт.</p>")
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 `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:16px 0; border:1px solid #e3ddd2; border-radius:6px; background-color:#faf8f5; width:100%;">
<tr>
<td style="padding:12px 16px; font-size:14px; color:#555555; width:120px;">Логин</td>
<td style="padding:12px 16px; font-size:14px; color:#222222;"><strong>` + html.EscapeString(email) + `</strong></td>
</tr>
<tr>
<td style="padding:12px 16px; font-size:14px; color:#555555; width:120px; border-top:1px solid #e3ddd2;">Пароль</td>
<td style="padding:12px 16px; font-size:14px; color:#1a1a2e; border-top:1px solid #e3ddd2;"><strong>` + html.EscapeString(password) + `</strong></td>
</tr>
</table>`
}
// buttonHTML — CTA-кнопка табличной вёрсткой (Gmail не поддерживает
// margin/padding на <a>, поэтому отступы — на ячейке <td>).
func buttonHTML(link, label string) string {
return `<table role="presentation" cellpadding="0" cellspacing="0" style="margin:20px 0;">
<tr>
<td style="border-radius:6px; background-color:#c9a227;">
<a href="` + html.EscapeString(link) + `" style="display:inline-block; padding:12px 28px; color:#1a1a2e; font-weight:bold; text-decoration:none; font-size:15px; border-radius:6px;">` + html.EscapeString(label) + `</a>
</td>
</tr>
</table>`
}
// wrapHTML — каркас письма: тёмная шапка с названием сервиса, контент,
// подвал с дисклеймером. Только табличная вёрстка и inline-стили: внешние
// таблицы стилей Gmail вырезает.
func wrapHTML(content string) string {
return `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0; padding:0; background-color:#f4f1ec; font-family:Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f1ec; padding:24px 0;">
<tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; overflow:hidden;">
<tr>
<td style="background-color:#1a1a2e; padding:20px 28px;">
<span style="color:#ffffff; font-size:20px; font-weight:bold; letter-spacing:1px;">ВЕЧЕРНИЙ ДЕТЕКТИВ</span>
</td>
</tr>
<tr>
<td style="padding:28px; color:#222222; font-size:15px; line-height:1.6;">
` + content + `
</td>
</tr>
<tr>
<td style="padding:20px 28px; background-color:#faf8f5; color:#777777; font-size:12px; line-height:1.5;">
Это автоматическое письмо сервиса «Вечерний детектив».<br>
Если ты получил его по ошибке — просто ответь на это письмо.
</td>
</tr>
</table>
</td></tr>
</table>
</body>
</html>`
}
// sanitizeLine убирает переносы строк из пользовательского ввода,
// вставляемого в тело письма (защита от инъекции строк в DATA-фазу SMTP).
func sanitizeLine(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
// loginURL нормализует адрес фронтенда для ссылки в письме: срезает
// завершающий слэш и допускает только http(s). Пустое или некорректное
// значение — ссылка в письма не добавляется.
func loginURL(baseURL string) string {
baseURL = strings.TrimSpace(baseURL)
baseURL = strings.TrimRight(baseURL, "/")
if baseURL == "" {
return ""
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return ""
}
return baseURL
}
+11 -4
View File
@@ -58,6 +58,9 @@ type UsersService struct {
emailSender email_sender.IEmailSender emailSender email_sender.IEmailSender
processorJWT processor_jwt.IProcessorJWT processorJWT processor_jwt.IProcessorJWT
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
// письмах; пустое значение — ссылка не добавляется.
appBaseURL string
} }
func NewUsersService( func NewUsersService(
@@ -66,6 +69,7 @@ func NewUsersService(
emailSender email_sender.IEmailSender, emailSender email_sender.IEmailSender,
processorJWT processor_jwt.IProcessorJWT, processorJWT processor_jwt.IProcessorJWT,
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo, refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo,
appBaseURL string,
) *UsersService { ) *UsersService {
return &UsersService{ return &UsersService{
usersRepo: usersRepo, usersRepo: usersRepo,
@@ -73,6 +77,7 @@ func NewUsersService(
emailSender: emailSender, emailSender: emailSender,
processorJWT: processorJWT, processorJWT: processorJWT,
refreshTokensRepo: refreshTokensRepo, refreshTokensRepo: refreshTokensRepo,
appBaseURL: loginURL(appBaseURL),
} }
} }
@@ -140,8 +145,9 @@ func (s *UsersService) AddUser(
// и тогда тот же ctx не дал бы выполнить удаление. // и тогда тот же ctx не дал бы выполнить удаление.
err = s.emailSender.Send(ctx, email_sender.Message{ err = s.emailSender.Send(ctx, email_sender.Message{
To: email, To: email,
Subject: "Приветствую тебя, детектив!", Subject: welcomeSubject,
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password), Body: welcomePlain(username, email, password, s.appBaseURL),
HTML: welcomeHTML(username, email, password, s.appBaseURL),
}) })
if err != nil { if err != nil {
if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil { if delErr := s.usersRepo.DeleteUser(context.WithoutCancel(ctx), id); delErr != nil {
@@ -204,8 +210,9 @@ func (s *UsersService) RefreshPassword(
// не работает, а новый пользователь так и не узнал). // не работает, а новый пользователь так и не узнал).
err = s.emailSender.Send(ctx, email_sender.Message{ err = s.emailSender.Send(ctx, email_sender.Message{
To: user.Email, To: user.Email,
Subject: "Сброс пароля", Subject: resetSubject,
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password), Body: resetPlain(user.Username, user.Email, password, s.appBaseURL),
HTML: resetHTML(user.Username, user.Email, password, s.appBaseURL),
}) })
if err != nil { if err != nil {
return fmt.Errorf("email send: %w", err) return fmt.Errorf("email send: %w", err)
@@ -145,6 +145,10 @@ func (r *fakeRepo) seed(email, passwordHash string) {
} }
func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService { func newTestService(repo usersRepository, sender email_sender.IEmailSender) *UsersService {
return newTestServiceWithBaseURL(repo, sender, "")
}
func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService {
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих // Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
// тестах не используется. // тестах не используется.
pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none") pool, err := pgxpool.New(context.Background(), "postgres://postgres:postgres@localhost:5432/none")
@@ -157,6 +161,7 @@ func newTestService(repo usersRepository, sender email_sender.IEmailSender) *Use
sender, sender,
processor_jwt.NewProcessor("test-secret"), processor_jwt.NewProcessor("test-secret"),
refresh_tokens_repo.NewRefreshTokensRepo(pool), refresh_tokens_repo.NewRefreshTokensRepo(pool),
baseURL,
) )
} }
@@ -182,8 +187,56 @@ func TestAddUserSuccess(t *testing.T) {
if msgs[0].To != "user@example.com" { if msgs[0].To != "user@example.com" {
t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To) t.Errorf("письмо ушло на %q, want user@example.com", msgs[0].To)
} }
if !strings.Contains(msgs[0].Subject, "Вечернему детективу") {
t.Errorf("неожиданная тема письма: %q", msgs[0].Subject)
}
if !strings.Contains(msgs[0].Body, "пароль") { if !strings.Contains(msgs[0].Body, "пароль") {
t.Error("в письме не упоминается пароль") t.Error( текстовой версии письма не упоминается пароль")
}
if msgs[0].HTML == "" {
t.Error("у письма нет HTML-версии — Gmail хуже рендерит такие письма")
}
// APP_BASE_URL в тесте пуст — кнопка «Войти в игру» не добавляется,
// но каркас письма (шапка сервиса) обязан присутствовать.
if !strings.Contains(msgs[0].HTML, "ВЕЧЕРНИЙ ДЕТЕКТИВ") {
t.Error("HTML-версия письма не содержит шапку сервиса")
}
if strings.Contains(msgs[0].HTML, "Войти в игру") {
t.Error("кнопка входа не должна добавляться без APP_BASE_URL")
}
}
func TestAddUserWithBaseURLAddsLoginLink(t *testing.T) {
repo := newFakeRepo()
sender := &fakeSender{}
svc := newTestServiceWithBaseURL(repo, sender, "https://evening-detective.crabs-games.art/")
err := svc.AddUser(context.Background(), "detective", "user@example.com", true, true, "", "")
if err != nil {
t.Fatalf("AddUser: %v", err)
}
msgs := sender.sent()
if len(msgs) != 1 {
t.Fatalf("ожидалось 1 письмо, отправлено %d", len(msgs))
}
// Завершающий слэш срезается, ссылка есть и в plain, и в HTML-версии.
if !strings.Contains(msgs[0].Body, "https://evening-detective.crabs-games.art") {
t.Errorf("plain-версия не содержит ссылку на вход:\n%s", msgs[0].Body)
}
if !strings.Contains(msgs[0].HTML, "https://evening-detective.crabs-games.art") {
t.Error("HTML-версия не содержит ссылку на вход")
}
if !strings.Contains(msgs[0].HTML, "Войти в игру") {
t.Error("HTML-версия не содержит кнопку «Войти в игру»")
}
}
func TestLoginURLInvalid(t *testing.T) {
for _, in := range []string{"", " ", "ftp://example.com", "not-a-url", "javascript:alert(1)"} {
if got := loginURL(in); got != "" {
t.Errorf("loginURL(%q) = %q, want пусто", in, got)
}
} }
} }