445 lines
22 KiB
Go
445 lines
22 KiB
Go
|
|
// 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
|
|||
|
|
}
|