generated from VLADIMIR/template
update mail
This commit is contained in:
@@ -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.
@@ -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,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user