// 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-вёрстка — почтовики // (в т.ч. мобильные) отрезают