generated from VLADIMIR/template
update mail
This commit is contained in:
@@ -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
|
||||
processorJWT processor_jwt.IProcessorJWT
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo
|
||||
// appBaseURL — публичный адрес фронтенда для ссылки «Войти в игру» в
|
||||
// письмах; пустое значение — ссылка не добавляется.
|
||||
appBaseURL string
|
||||
}
|
||||
|
||||
func NewUsersService(
|
||||
@@ -66,6 +69,7 @@ func NewUsersService(
|
||||
emailSender email_sender.IEmailSender,
|
||||
processorJWT processor_jwt.IProcessorJWT,
|
||||
refreshTokensRepo *refresh_tokens_repo.RefreshTokensRepo,
|
||||
appBaseURL string,
|
||||
) *UsersService {
|
||||
return &UsersService{
|
||||
usersRepo: usersRepo,
|
||||
@@ -73,6 +77,7 @@ func NewUsersService(
|
||||
emailSender: emailSender,
|
||||
processorJWT: processorJWT,
|
||||
refreshTokensRepo: refreshTokensRepo,
|
||||
appBaseURL: loginURL(appBaseURL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +145,9 @@ func (s *UsersService) AddUser(
|
||||
// и тогда тот же ctx не дал бы выполнить удаление.
|
||||
err = s.emailSender.Send(ctx, email_sender.Message{
|
||||
To: email,
|
||||
Subject: "Приветствую тебя, детектив!",
|
||||
Body: fmt.Sprintf("Вот твой пароль для входа в систему: %s", password),
|
||||
Subject: welcomeSubject,
|
||||
Body: welcomePlain(username, email, password, s.appBaseURL),
|
||||
HTML: welcomeHTML(username, email, password, s.appBaseURL),
|
||||
})
|
||||
if err != 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{
|
||||
To: user.Email,
|
||||
Subject: "Сброс пароля",
|
||||
Body: fmt.Sprintf("Вот твой новый пароль для входа в систему, не теряй: %s", password),
|
||||
Subject: resetSubject,
|
||||
Body: resetPlain(user.Username, user.Email, password, s.appBaseURL),
|
||||
HTML: resetHTML(user.Username, user.Email, password, s.appBaseURL),
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
return newTestServiceWithBaseURL(repo, sender, "")
|
||||
}
|
||||
|
||||
func newTestServiceWithBaseURL(repo usersRepository, sender email_sender.IEmailSender, baseURL string) *UsersService {
|
||||
// Пул создаётся лениво (pgxpool.New не контактирует с БД) и в этих
|
||||
// тестах не используется.
|
||||
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,
|
||||
processor_jwt.NewProcessor("test-secret"),
|
||||
refresh_tokens_repo.NewRefreshTokensRepo(pool),
|
||||
baseURL,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -182,8 +187,56 @@ func TestAddUserSuccess(t *testing.T) {
|
||||
if msgs[0].To != "user@example.com" {
|
||||
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, "пароль") {
|
||||
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