Перейти к основному содержимому

ADR 0096 — Настоящий SMTP в MailSender, за явным переключателем

  • Статус: принят
  • Дата: 2026-08-18
  • Предшествующий контекст: ADR 0031 (здесь родился MailSender, только пишущий в лог), ADR 0032 (зафиксировал «настоящий SMTP остаётся будущей конфигурацией» как принятое следствие), ADR 0059 (шаблон проверки секрета в production, который этот ADR повторно использует)

Контекст​

С Фазы 7a у MailSender (apps/api/src/application/ports/mail-sender.port.ts) одна-единственная реализация, LogMailSender: подтверждение e-mail, сброс пароля, первоначальная установка пароля (учётные записи, мигрированные из Keycloak) и уведомление о дублирующейся регистрации никогда не покидают api — они идут только в лог. Этого хватило, чтобы доказать поток auth от начала до конца, но оставило открытым с тех пор пункт в docs/explanation/backlog.md: «Настоящий SMTP в MailSender».

Этот пункт — не новая функция, а закрытие уже зафиксированного пробела, с портом, готовым со времён отказа от Keycloak: MailSender.enviar(email) не несёт никакого мнения о транспорте, только о payload (para/tipo/token?/ expiraEm?). Настоящая работа — реализация SMTP и решение о том, КАК её включить, не сломав тех, кто уже сегодня запускает продукт.

Решение​

nodemailer, через чистый SMTP-транспорт​

createTransport({ host, port, secure, auth }). В отличие от JSON API поверх HTTP, которые продукт интегрирует в остальном (LLM-провайдеры, поверх чистого node:http — ADR 0041), SMTP — это СТРОЧНЫЙ протокол, с состоянием, MIME, STARTTLS и несколькими механизмами AUTH. Реализовывать это вручную значило бы заново изобретать чувствительное к безопасности колесо без какой-либо выгоды. nodemailer — фактический стандарт экосистемы Node, без SDK провайдера (SES/SendGrid/ Mailgun) и без тяжёлого дерева зависимостей — ноль собственных зависимостей.

MAIL_TRANSPORT: явный переключатель, никогда не вывод​

log (по умолчанию, включая production) или smtp. Отправлять e-mail по-настоящему — это opt-in оператора: без явного MAIL_TRANSPORT=smtp поведение остаётся ровно сегодняшним, даже в production, и тот, кто уже запускает продукт, не ломается при обновлении. У docker-compose.prod.yml/ docker-compose.yml нет никакого публичного fallback для пяти переменных SMTP_*: каждая разрешается в пустую строку при отсутствии (${SMTP_HOST:-}), тот же шаблон, который AUTH_JWT_SECRET/BRABO_SERVICE_TOKEN уже используют там.

Проверка по шаблону RN-114, с одним отличием​

Исходные RN-114 (AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY, SECRET_KEY_BASE) обрушивают boot в production, потому что у переменной ЕСТЬ публичное значение по умолчанию для разработки, и «не пусто» не поймало бы дефект. Здесь такого значения по умолчанию нет: SMTP_HOST остаётся пустым, если никто его не задал. Линейка (отсутствует/только пробелы/значение-пример из репозитория/неверный формат) применяется только при NODE_ENV=production — вне production MAIL_TRANSPORT=smtp без переменных не обрушивает boot, потому что это путь opt-in, который разработчик может тестировать против локального SMTP (MailHog, например), ещё не определив значения.

apps/api/src/infrastructure/mail/smtp-config.ts (resolverConfigSmtp) следует тому же формату, что apps/api/src/infrastructure/security/ auth-key-material.ts/service-token.ts: SMTP_HOST/SMTP_USER/ SMTP_PASSWORD/SMTP_FROM обязательны в production, когда режим — smtp; SMTP_HOST дополнительно отклоняется, если равен опубликованному литералу (закомментированному) в .env.example; SMTP_FROM должен соответствовать "Nome <email@dominio>" или email@dominio. SMTP_PORT (по умолчанию 587) и SMTP_SECURE (по умолчанию false) — не секреты: у них значение по умолчанию ПРОДУКТА, а не разработки, и они не проходят через линейку «обязательно в production».

Проверка выполняется внутри конструктора SmtpMailSender, проверяемого в useFactory AuthUseCasesModule, — а не в нетерпеливом вызове в main.ts, как четыре секрета RN-114. Отличие намеренное: AuthUseCasesModule импортируется безусловно (через AuthHttpModule), а useFactory разрешает MailSender во время NestFactory.create(), так что проверка всё равно происходит при BOOT — просто не до него, потому что она важна лишь тогда, когда оператор выбрал smtp. Это тот же дизайн, который уже использует CREDENTIALS_MASTER_KEY (проверяется в конструкторе EnvelopeEncryptionService, проверяемого сборкой графа providers).

Выбор через useFactory​

AuthUseCasesModule заменяет { provide: MailSender, useClass: LogMailSender } на useFactory, который читает resolverModoDeTransporte() и создаёт SmtpMailSender или LogMailSender. Ни один сценарий использования (RegisterUseCase, RequestPasswordResetUseCase, LoginUseCase, скрипт migrate-keycloak-users.ts) не меняется — все продолжают внедрять MailSender и вызывать .enviar().

Тело простым текстом, никогда HTML​

Порт не несёт структуры для богатого тела, а шаблонизатор был бы поверхностью для инъекций/XSS ради выгоды, которую никто не просил. У каждого tipo есть фиксированный текст на pt-BR, со ссылкой, когда это уместно, собранной из WEB_ORIGIN (то же сырое чтение, которое auth.controller.ts/git.controller.ts уже делают для redirects) + правильный веб-маршрут + ?token=.

Сырой токен и тело никогда не попадают в лог​

Та же линейка, что у LogMailSender: успех и сбой отправки упоминают tipo и получателя, никогда токен или текст письма.

При исследовании мест вызова перед реализацией: веб-маршрут /definir-senha уже существует и обслуживает password_reset/set_initial_password (SetPasswordPage.tsx), но не было ни маршрута, ни экрана для email_verification — api уже предоставляла POST /auth/verify-email, и веб-клиент (apps/web/src/lib/auth.ts, verificarEmail) уже существовал, но без единого вызывающего. С LogMailSender этот пробел был невидим: ссылка никогда не покидала лог, и никто по ней не кликал. С настоящим SMTP письмо пришло бы с мёртвой ссылкой.

/verificar-email?token=... (VerifyEmailPage.tsx) закрывает это, повторяя SetPasswordPage.tsx/setPasswordRoute: тот же шаблон validateSearch в router.tsx, тот же единый ответ для несуществующей/истёкшей/уже использованной ссылки и тот же исход — никого не авторизовывать, только здесь нет формы: подтверждение запускается само при монтировании (пользователю нечего заполнять), и поэтому экрану нужны три состояния RN-088 (загрузка/ошибка/успех), а не только два.

Последствия​

Намеренно ничего не ломает. Незаданный MAIL_TRANSPORT — в любом окружении, включая production, — сохраняет сегодняшнее поведение (только лог). Кто хочет настоящий e-mail, делает явный opt-in и получает пять переменных, проверяемых при boot, когда работает в production.

Новая production-зависимость: nodemailer (+ @types/nodemailer как dev dependency). Ноль собственных транзитивных зависимостей.

Новый инфраструктурный секрет: SMTP_PASSWORD — того же семейства, что AUTH_JWT_SECRET/GIT_OAUTH_STATE_SECRET (простая переменная окружения, проверяемая при boot), а не пользовательский секрет (не проходит через EncryptionService/envelope encryption, который только для учётных данных LLM/git по ЗАПИСИ в базе).

Новый внешний сетевой путь в чувствительном потоке аутентификации — RN-030 по RN-033 (защита от перечисления) остаются нетронутыми: реализация SMTP не меняет НИ ОДНОГО места вызова и ни payload, который решают отправлять сценарии использования, — только доставку.

Зафиксировано, а не закрыто здесь: нет механизма повторных попыток для e-mail, который не удалось отправить по SMTP (таймаут, учётные данные, отклонённые провайдером). LoginUseCase.enviarDefinicaoDeSenha уже намеренно проглатывал сбой (не меняет HTTP-ответ); RegisterUseCase/ RequestPasswordResetUseCase распространяют исключение, и недоступный SMTP-провайдер становится 500 на маршруте auth. Это уже было верно для любой синхронной реализации MailSender и с этой не становится хуже — outbox/очередь отправки, если когда-нибудь понадобится, — это отдельное продуктовое решение, вне охвата этого ADR.