axe/internal/mailer/mailer.go
2026-10-18 22:07:25 +03:00

445 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package mailer — отправка писем по SMTP: библиотека
// github.com/emersion/go-smtp (MIT) вместо замороженного net/smtp.
//
// Письма сейчас два — «аккаунт подтверждён» (POST /users/:id/confirm)
// и «сброс пароля» (POST /auth/forgot-password): multipart/alternative
// (текст + HTML), тема в encoded-word (RFC 2047), тела в base64
// (RFC 2045). Отправка fail-open: подтверждение и сброс не
// откатываются из-за недоступной почты — ошибка уходит в warn-лог.
//
// Настройки читает cmd/api из env:
//
// SMTP_HOST — хост SMTP; пусто — письма не шлются (dev без почты)
// SMTP_PORT — порт; пусто — 587
// SMTP_USER — пользователь; пусто — без аутентификации (Mailpit)
// SMTP_PASSWORD — пароль
// SMTP_FROM — адрес отправителя; пусто — берётся SMTP_USER
// SMTP_TLS — tls | starttls | none; пусто — tls для 465, иначе starttls
// APP_BASE_URL — база ссылок письма; пусто — http://localhost:3000
package mailer
import (
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"html"
"log/slog"
"mime"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
)
// Режимы SMTP_TLS (и режимы по умолчанию, когда переменная пуста).
const (
tlsImplicit = "tls" // порт 465: TLS с самого начала соединения
tlsStart = "starttls" // порт 587: обычное соединение + STARTTLS
tlsNone = "none" // без шифрования: Mailpit в локальной разработке
)
// sendTimeout — общий дедлайн на письмо: соединение, аутентификация
// и отправка должны уложиться в него, иначе ответ админу (.fail-open)
// не должен ждать зависший SMTP дольше.
const sendTimeout = 10 * time.Second
// altBoundary — разделитель частей multipart/alternative. В base64
// символов «_» нет, поэтому граница не может встретиться в телах.
const altBoundary = "_=_BuildingMgmt_alternative_=_"
// Mailer — то, что видят handlers: письма и точка отказа в тестах
// (fake реализует тот же интерфейс, сеть не нужна).
type Mailer interface {
// SendAccountConfirmed — письмо «аккаунт подтверждён, можно войти».
SendAccountConfirmed(ctx context.Context, a AccountConfirmed) error
// SendPasswordReset — письмо «сброс пароля» со ссылкой на форму.
SendPasswordReset(ctx context.Context, p PasswordReset) error
}
// AccountConfirmed — данные письма после подтверждения: handlers
// передаёт профиль, тему/шаблоны собирает mailer.
type AccountConfirmed struct {
To string // адрес получателя
Name string // имя аккаунта — им и обращаемся
Role string // client | worker | admin
ConfirmedAt time.Time // момент подтверждения (zero — строку опускаем)
}
// PasswordReset — данные письма «забыли пароль»: токен handlers уже
// выпустил в базе, mailer только строит ссылку (у него BaseURL) и шлёт.
type PasswordReset struct {
To string // адрес получателя
Name string // имя аккаунта
Token string // одноразовый токен — в ссылке, в базе только sha256
ExpiresAt time.Time // срок жизни ссылки (из CreateResetToken)
}
// Config — настройки SMTP из env (см. комментарий пакета).
type Config struct {
Host string
Port string
User string
Password string
From string
TLS string
BaseURL string
}
// New собирает mailer: пустой Host — заглушка без отправки (dev),
// иначе SMTP-клиент. Некорректные порт/режим/адрес — ошибка на старте,
// а не сюрприз на первом подтверждении.
func New(cfg Config, log *slog.Logger) (Mailer, error) {
if cfg.Host == "" {
log.Info("smtp disabled: SMTP_HOST is empty, confirmation emails are skipped")
return disabledMailer{log: log}, nil
}
if cfg.Port == "" {
cfg.Port = "587"
}
if _, err := strconv.Atoi(cfg.Port); err != nil {
return nil, fmt.Errorf("smtp: invalid SMTP_PORT %q", cfg.Port)
}
if cfg.From == "" {
cfg.From = cfg.User
}
if cfg.From == "" {
return nil, fmt.Errorf("smtp: SMTP_FROM and SMTP_USER are both empty")
}
if cfg.BaseURL == "" {
cfg.BaseURL = "http://localhost:3000"
}
mode := cfg.TLS
if mode == "" {
// Дефолт по порту: 465 — implicit TLS, остальные — STARTTLS.
if cfg.Port == "465" {
mode = tlsImplicit
} else {
mode = tlsStart
}
}
if mode != tlsImplicit && mode != tlsStart && mode != tlsNone {
return nil, fmt.Errorf("smtp: invalid SMTP_TLS %q (want tls, starttls or none)", cfg.TLS)
}
// Пароль намеренно не логируем.
log.Info("smtp configured", "host", cfg.Host, "port", cfg.Port, "tls", mode, "user", cfg.User)
return &smtpMailer{cfg: cfg, mode: mode, timeout: sendTimeout}, nil
}
type smtpMailer struct {
cfg Config
mode string
timeout time.Duration
}
// SendAccountConfirmed — письмо после подтверждения: тема и обе версии
// тела (текст для почтовиков без HTML, HTML — для людей) собираются
// здесь, handlers передаёт только данные аккаунта.
func (m *smtpMailer) SendAccountConfirmed(ctx context.Context, a AccountConfirmed) error {
const subject = "Аккаунт подтверждён"
return m.deliver(ctx, a.To, subject,
confirmedText(a, m.cfg.BaseURL), confirmedHTML(a, m.cfg.BaseURL))
}
// SendPasswordReset — письмо «забыли пароль»: ссылка на форму сброса
// (токен в query) собирается здесь же — BaseURL живёт в конфиге mailer.
func (m *smtpMailer) SendPasswordReset(ctx context.Context, p PasswordReset) error {
const subject = "Сброс пароля"
return m.deliver(ctx, p.To, subject,
resetText(p, m.cfg.BaseURL), resetHTML(p, m.cfg.BaseURL))
}
// deliver — общий дедлайн вокруг сетевой части: go-smtp работает без
// ctx, поэтому ждём результат select'ом — упавший SMTP не подвешивает
// ответ хендлера дольше sendTimeout.
func (m *smtpMailer) deliver(ctx context.Context, to, subject, textBody, htmlBody string) error {
done := make(chan error, 1)
go func() { done <- m.deliverOnce(to, subject, textBody, htmlBody) }()
select {
case err := <-done:
return err
case <-ctx.Done():
return ctx.Err()
case <-time.After(m.timeout):
return fmt.Errorf("smtp: no result in %s", m.timeout)
}
}
// deliverOnce — одно соединение: dial (по режиму TLS), опциональная
// аутентификация (пустой User — сервер без неё, как Mailpit),
// отправка, выход. Сертификат проверяется по имени хоста (ServerName).
func (m *smtpMailer) deliverOnce(to, subject, textBody, htmlBody string) error {
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
tlsCfg := &tls.Config{ServerName: m.cfg.Host}
var (
c *smtp.Client
err error
)
switch m.mode {
case tlsImplicit:
c, err = smtp.DialTLS(addr, tlsCfg)
case tlsStart:
c, err = smtp.DialStartTLS(addr, tlsCfg)
default: // tlsNone
c, err = smtp.Dial(addr)
}
if err != nil {
return fmt.Errorf("smtp dial %s: %w", addr, err)
}
// Закрытие — best-effort: после Quit ошибка на Close ничего не значит.
defer func() { _ = c.Close() }()
c.CommandTimeout = m.timeout
c.SubmissionTimeout = m.timeout
if m.cfg.User != "" {
if err := c.Auth(sasl.NewPlainClient("", m.cfg.User, m.cfg.Password)); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
msg := buildMessage(m.cfg.From, to, subject, textBody, htmlBody)
if err := c.SendMail(m.cfg.From, []string{to}, strings.NewReader(msg)); err != nil {
return fmt.Errorf("smtp send: %w", err)
}
if err := c.Quit(); err != nil {
return fmt.Errorf("smtp quit: %w", err)
}
return nil
}
// loginURL — ссылка на вход письма: база из APP_BASE_URL + хеш-роут.
func loginURL(baseURL string) string {
return strings.TrimRight(baseURL, "/") + "/#/login"
}
// roleLabel — роль по-человечески для письма; неизвестная роль
// остаётся как есть (не валим технические значения в текст письма).
func roleLabel(role string) string {
switch role {
case "client":
return "Клиент"
case "worker":
return "Мастер"
case "admin":
return "Администратор"
default:
return role
}
}
// confirmedText — текстовая версия письма: тот же набор данных, что
// и в HTML, для почтовиков без поддержки разметки.
func confirmedText(a AccountConfirmed, baseURL string) string {
var b strings.Builder
fmt.Fprintf(&b, "Здравствуйте, %s!\n\n", a.Name)
b.WriteString("Аккаунт подтверждён — теперь можно войти в систему.\n\n")
fmt.Fprintf(&b, "Email аккаунта: %s\n", a.To)
fmt.Fprintf(&b, "Роль: %s\n", roleLabel(a.Role))
if !a.ConfirmedAt.IsZero() {
fmt.Fprintf(&b, "Подтверждено: %s\n", a.ConfirmedAt.Format("02.01.2006 15:04"))
}
fmt.Fprintf(&b, "Вход: %s\n", loginURL(baseURL))
b.WriteString("\nЕсли это письмо пришло вам по ошибке, просто проигнорируйте его —\n")
b.WriteString("пароль в письмах мы никогда не указываем.\n\n")
b.WriteString("— Заявки в здании. Автоматическое письмо, отвечать на него не нужно.\n")
return b.String()
}
// confirmedHTML — HTML-версия: карточка с заголовком, таблицей данных
// и кнопкой входа. Только инлайн-стили и table-вёрстка — почтовики
// (в т.ч. мобильные) отрезают <style> и не всегда понимают flex.
// Всё пользовательское (имя, email, роль) прогоняется через
// html.EscapeString — имя в аккаунте вводит сам пользователь.
func confirmedHTML(a AccountConfirmed, baseURL string) string {
var rows strings.Builder
details := [][2]string{
{"Email аккаунта", a.To},
{"Роль", roleLabel(a.Role)},
}
if !a.ConfirmedAt.IsZero() {
details = append(details, [2]string{"Подтверждено", a.ConfirmedAt.Format("02.01.2006 15:04")})
}
for i, kv := range details {
border := "border-bottom:1px solid #e9edf5;"
if i == len(details)-1 {
border = ""
}
fmt.Fprintf(&rows,
"<tr>"+
"<td style=\"padding:10px 16px;%s color:#8a94a6;font-size:14px;\">%s</td>"+
"<td style=\"padding:10px 16px;%s color:#16233a;font-size:14px;text-align:right;\">%s</td>"+
"</tr>\n",
border, html.EscapeString(kv[0]), border, html.EscapeString(kv[1]))
}
return "<!DOCTYPE html>\n" +
"<html lang=\"ru\"><body style=\"margin:0;padding:0;background:#eef1f6;\">" +
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#eef1f6;\"><tr>" +
"<td align=\"center\" style=\"padding:28px 12px;\">" +
"<table role=\"presentation\" width=\"560\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:100%;max-width:560px;background:#ffffff;border-radius:14px;border:1px solid #e4e9f2;\">" +
"<tr><td style=\"padding:30px 32px 6px;font-family:Arial,Helvetica,sans-serif;\">" +
"<div style=\"font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:#8a94a6;\">Заявки в здании</div>" +
"<div style=\"height:3px;width:44px;background:#2f6bff;border-radius:2px;margin:12px 0 18px;\"></div>" +
"<div style=\"font-size:24px;font-weight:bold;color:#16233a;margin:0 0 18px;\">Аккаунт подтверждён</div>" +
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 12px;\">Здравствуйте, " +
html.EscapeString(a.Name) + "!</div>" +
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 22px;\">" +
"Ваш аккаунт подтверждён — теперь можно войти в систему.</div>" +
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" " +
"style=\"border-collapse:collapse;margin:0 0 24px;background:#f7f9fc;border-radius:8px;\">" +
rows.String() +
"</table>" +
"<a href=\"" + html.EscapeString(loginURL(baseURL)) + "\" style=\"display:inline-block;" +
"background:#2f6bff;color:#ffffff;text-decoration:none;font-size:15px;font-weight:bold;" +
"padding:13px 28px;border-radius:8px;font-family:Arial,Helvetica,sans-serif;\">" +
"Войти в систему</a>" +
"</td></tr>" +
"<tr><td style=\"padding:14px 32px 28px;font-family:Arial,Helvetica,sans-serif;\">" +
"<div style=\"height:1px;background:#e9edf5;margin:0 0 14px;\"></div>" +
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0 0 6px;\">" +
"Если это письмо пришло вам по ошибке, просто проигнорируйте его — " +
"пароль в письмах мы никогда не указываем.</div>" +
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0;\">" +
"Заявки в здании · " + html.EscapeString(strings.TrimRight(baseURL, "/")) +
" · Автоматическое письмо, отвечать на него не нужно.</div>" +
"</td></tr>" +
"</table>" +
"</td></tr></table>" +
"</body></html>\n"
}
// resetURL — ссылка на форму сброса: база из APP_BASE_URL + хеш-роут
// с токеном. Токен hex (URL-безопасен), но экранируем на будущее —
// формат токена может измениться.
func resetURL(baseURL, token string) string {
return strings.TrimRight(baseURL, "/") + "/#/reset-password?token=" + url.QueryEscape(token)
}
// resetText — текстовая версия письма сброса.
func resetText(p PasswordReset, baseURL string) string {
var b strings.Builder
fmt.Fprintf(&b, "Здравствуйте, %s!\n\n", p.Name)
b.WriteString("Кто-то (надеемся, вы) запросил сброс пароля для вашего\n")
b.WriteString("аккаунта в системе «Заявки в здании».\n\n")
fmt.Fprintf(&b, "Email аккаунта: %s\n", p.To)
fmt.Fprintf(&b, "Ссылка для смены пароля: %s\n", resetURL(baseURL, p.Token))
fmt.Fprintf(&b, "Ссылка одноразовая и действует до %s.\n",
p.ExpiresAt.Format("02.01.2006 15:04"))
b.WriteString("\nЕсли вы не просили сброс — просто проигнорируйте это письмо:\n")
b.WriteString("пароль останется прежним, а ссылка перестанет работать по истечении\n")
b.WriteString("срока. Пароль в письмах мы никогда не указываем.\n\n")
b.WriteString("— Заявки в здании. Автоматическое письмо, отвечать на него не нужно.\n")
return b.String()
}
// resetHTML — HTML-версия письма сброса: та же карточка, что и в
// письме подтверждения, с кнопкой «Задать новый пароль». Инлайн-стили
// и table-вёрстка — почтовики отрезают <style> и не всегда понимают
// flex; имя/email экранируются (html.EscapeString).
func resetHTML(p PasswordReset, baseURL string) string {
return "<!DOCTYPE html>\n" +
"<html lang=\"ru\"><body style=\"margin:0;padding:0;background:#eef1f6;\">" +
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background:#eef1f6;\"><tr>" +
"<td align=\"center\" style=\"padding:28px 12px;\">" +
"<table role=\"presentation\" width=\"560\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width:100%;max-width:560px;background:#ffffff;border-radius:14px;border:1px solid #e4e9f2;\">" +
"<tr><td style=\"padding:30px 32px 6px;font-family:Arial,Helvetica,sans-serif;\">" +
"<div style=\"font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:#8a94a6;\">Заявки в здании</div>" +
"<div style=\"height:3px;width:44px;background:#2f6bff;border-radius:2px;margin:12px 0 18px;\"></div>" +
"<div style=\"font-size:24px;font-weight:bold;color:#16233a;margin:0 0 18px;\">Сброс пароля</div>" +
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 12px;\">Здравствуйте, " +
html.EscapeString(p.Name) + "!</div>" +
"<div style=\"font-size:15px;line-height:1.6;color:#33405a;margin:0 0 22px;\">" +
"Кто-то (надеемся, вы) запросил сброс пароля для аккаунта <b>" +
html.EscapeString(p.To) + "</b>. Задайте новый пароль по кнопке ниже.</div>" +
"<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" " +
"style=\"border-collapse:collapse;margin:0 0 24px;background:#f7f9fc;border-radius:8px;\">" +
"<tr>" +
"<td style=\"padding:10px 16px;border-bottom:1px solid #e9edf5;color:#8a94a6;font-size:14px;\">Действует до</td>" +
"<td style=\"padding:10px 16px;border-bottom:1px solid #e9edf5;color:#16233a;font-size:14px;text-align:right;\">" +
html.EscapeString(p.ExpiresAt.Format("02.01.2006 15:04")) + "</td>" +
"</tr>" +
"<tr>" +
"<td style=\"padding:10px 16px;color:#8a94a6;font-size:14px;\">Ссылка</td>" +
"<td style=\"padding:10px 16px;color:#16233a;font-size:14px;text-align:right;\">одноразовая</td>" +
"</tr>" +
"</table>" +
"<a href=\"" + html.EscapeString(resetURL(baseURL, p.Token)) + "\" style=\"display:inline-block;" +
"background:#2f6bff;color:#ffffff;text-decoration:none;font-size:15px;font-weight:bold;" +
"padding:13px 28px;border-radius:8px;font-family:Arial,Helvetica,sans-serif;\">" +
"Задать новый пароль</a>" +
"</td></tr>" +
"<tr><td style=\"padding:14px 32px 28px;font-family:Arial,Helvetica,sans-serif;\">" +
"<div style=\"height:1px;background:#e9edf5;margin:0 0 14px;\"></div>" +
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0 0 6px;\">" +
"Если вы не просили сброс — проигнорируйте письмо: пароль останется прежним, " +
"а ссылка перестанет работать по истечении срока. Пароль в письмах мы никогда не указываем.</div>" +
"<div style=\"font-size:12px;line-height:1.6;color:#8a94a6;margin:0;\">" +
"Заявки в здании · " + html.EscapeString(strings.TrimRight(baseURL, "/")) +
" · Автоматическое письмо, отвечать на него не нужно.</div>" +
"</td></tr>" +
"</table>" +
"</td></tr></table>" +
"</body></html>\n"
}
// buildMessage — письмо RFC 5322 c multipart/alternative: текст и HTML
// идут отдельными частями — почтовик выбирает, что показать. Тема —
// MIME encoded-word (RFC 2047): кириллицу без кодировки почтовики
// понимают по-разному, а SMTPUTF8 поддерживают не все серверы. Тела —
// base64 с переносом каждые 76 символов (RFC 2045).
func buildMessage(from, to, subject, textBody, htmlBody string) string {
var b strings.Builder
b.WriteString("From: " + from + "\r\n")
b.WriteString("To: " + to + "\r\n")
b.WriteString("Subject: " + mime.BEncoding.Encode("UTF-8", subject) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: multipart/alternative; boundary=\"" + altBoundary + "\"\r\n")
b.WriteString("\r\n")
b.WriteString("--" + altBoundary + "\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: base64\r\n")
b.WriteString("\r\n")
b.WriteString(wrapBase64(textBody))
b.WriteString("--" + altBoundary + "\r\n")
b.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
b.WriteString("Content-Transfer-Encoding: base64\r\n")
b.WriteString("\r\n")
b.WriteString(wrapBase64(htmlBody))
b.WriteString("--" + altBoundary + "--\r\n")
return b.String()
}
// wrapBase64 — тело в base64 с длиной строки 76 (RFC 2045).
func wrapBase64(s string) string {
enc := base64.StdEncoding.EncodeToString([]byte(s))
var lines []string
for len(enc) > 76 {
lines = append(lines, enc[:76])
enc = enc[76:]
}
if enc != "" {
lines = append(lines, enc)
}
return strings.Join(lines, "\r\n") + "\r\n"
}
// disabledMailer — SMTP не настроен: письма не шлются, вызов успешен,
// в лог уходит debug — подтверждение работает и без почты.
type disabledMailer struct {
log *slog.Logger
}
func (d disabledMailer) SendAccountConfirmed(_ context.Context, a AccountConfirmed) error {
d.log.Debug("smtp disabled: confirmation email skipped", "to", a.To)
return nil
}
func (d disabledMailer) SendPasswordReset(_ context.Context, p PasswordReset) error {
d.log.Debug("smtp disabled: password reset email skipped", "to", p.To)
return nil
}