ADR 0096 — SMTP real no MailSender, atrás de um toggle explícito

  • Status: aceito
  • Data: 2026-08-18
  • Contexto anterior: ADR 0031 (o MailSender log-only nasceu aqui), ADR 0032 (registrou "SMTP real continua sendo config futura" como consequência aceita), ADR 0059 (o padrão de validação de segredo em produção que este ADR reusa)

Contexto

Desde a Fase 7a o MailSender (apps/api/src/application/ports/mail-sender.port.ts) tem uma única implementação, LogMailSender: verificação de e-mail, reset de senha, definição inicial de senha (contas migradas do Keycloak) e aviso de registro duplicado nunca saem da api — só vão para o log. Isso bastou para provar o fluxo de auth de ponta a ponta, mas deixou um item aberto desde então em docs/explanation/backlog.md: "SMTP real no MailSender".

O item não é uma feature nova — é fechar uma lacuna já registrada, com a porta já pronta desde o corte do Keycloak: MailSender.enviar(email) não carrega opinião nenhuma sobre transporte, só sobre payload (para/tipo/token?/ expiraEm?). O trabalho de verdade é a implementação SMTP e a decisão de COMO ligar uma sem quebrar quem já roda o produto hoje.

Decisão

nodemailer, via transporte SMTP puro

createTransport({ host, port, secure, auth }). Diferente das APIs JSON sobre HTTP que o resto do produto integra (providers de LLM, sobre node:http puro — ADR 0041), SMTP é protocolo de LINHA, com estado, MIME, STARTTLS e múltiplos mecanismos de AUTH. Reimplementar isso à mão seria reinventar uma roda sensível a segurança sem ganho nenhum. nodemailer é o padrão de fato do ecossistema Node, sem SDK de provider (SES/SendGrid/ Mailgun) e sem árvore de dependência pesada — zero dependências próprias.

MAIL_TRANSPORT: toggle explícito, nunca inferência

log (default, inclusive em produção) ou smtp. Enviar e-mail de verdade é opt-in do operador — sem MAIL_TRANSPORT=smtp explícito o comportamento continua exatamente o de hoje, mesmo em produção, e quem já roda o produto não quebra ao atualizar. docker-compose.prod.yml/ docker-compose.yml não têm fallback público nenhum para as cinco variáveis SMTP_*: cada uma resolve para string vazia quando ausente (${SMTP_HOST:-}), o mesmo padrão que AUTH_JWT_SECRET/BRABO_SERVICE_TOKEN já usam ali.

Validação no padrão da RN-114, com uma diferença

As RN-114 originais (AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY, SECRET_KEY_BASE) derrubam o boot em produção porque a variável TEM um default de desenvolvimento público, e "não vazia" não pegaria o defeito. Aqui não existe esse default: SMTP_HOST fica em branco se ninguém setar. A régua (ausente/só espaços/valor de exemplo do repositório/formato inválido) só é aplicada quando NODE_ENV=production — fora de produção, MAIL_TRANSPORT=smtp sem as variáveis não derruba o boot, porque é um caminho opt-in que um desenvolvedor pode estar testando contra um SMTP local (MailHog, por exemplo) sem valores ainda definidos.

apps/api/src/infrastructure/mail/smtp-config.ts (resolverConfigSmtp) segue o mesmo formato de apps/api/src/infrastructure/security/ auth-key-material.ts/service-token.ts: SMTP_HOST/SMTP_USER/ SMTP_PASSWORD/SMTP_FROM são obrigatórias em produção quando o modo é smtp; SMTP_HOST é adicionalmente recusado se igual ao literal publicado (comentado) em .env.example; SMTP_FROM precisa casar "Nome <email@dominio>" ou email@dominio. SMTP_PORT (default 587) e SMTP_SECURE (default false) não são segredo — têm default de PRODUTO, não de desenvolvimento, e não passam pela régua de "obrigatória em produção".

A validação roda dentro do construtor de SmtpMailSender, exercitada no useFactory de AuthUseCasesModule — não numa chamada eager em main.ts como os quatro segredos da RN-114. A diferença é deliberada: AuthUseCasesModule é importado incondicionalmente (via AuthHttpModule) e o useFactory resolve o MailSender durante NestFactory.create(), então a validação ainda acontece no BOOT — só não antes dele, porque ela só importa quando o operador optou por smtp. É o mesmo desenho que CREDENTIALS_MASTER_KEY já usa (validada no construtor de EnvelopeEncryptionService, exercitado pela montagem do grafo de providers).

Seleção via useFactory

AuthUseCasesModule troca { provide: MailSender, useClass: LogMailSender } por um useFactory que lê resolverModoDeTransporte() e instancia SmtpMailSender ou LogMailSender. Nenhum caso de uso (RegisterUseCase, RequestPasswordResetUseCase, LoginUseCase, o script migrate-keycloak-users.ts) muda — todos continuam injetando MailSender e chamando .enviar().

Corpo em texto puro, nunca HTML

A porta não carrega estrutura para corpo rico, e um template engine seria superfície de injeção/XSS por um ganho que ninguém pediu. Cada tipo tem um texto fixo em pt-BR, com o link quando fizer sentido, montado a partir de WEB_ORIGIN (mesma leitura crua que auth.controller.ts/git.controller.ts já fazem para redirects) + a rota web certa + ?token=.

O token bruto e o corpo nunca vão para o log

Mesma régua do LogMailSender: sucesso e falha de envio citam tipo e destinatário, nunca o token nem o texto do e-mail.

Investigando os call sites antes de implementar: a rota web /definir-senha já existe e atende password_reset/set_initial_password (SetPasswordPage.tsx), mas não havia rota nem tela para email_verification — a api já expunha POST /auth/verify-email e o cliente web (apps/web/src/lib/auth.ts, verificarEmail) já existia, mas sem chamador nenhum. Com LogMailSender, essa lacuna era invisível: o link nunca saía do log e ninguém clicava nele. Com SMTP real, o e-mail chegaria com um link morto.

/verificar-email?token=... (VerifyEmailPage.tsx) fecha isso, espelhando SetPasswordPage.tsx/setPasswordRoute: mesmo padrão de validateSearch no router.tsx, mesma resposta única para link inexistente/expirado/já usado, e o mesmo desfecho de não logar ninguém — só que aqui não há formulário: a confirmação dispara sozinha ao montar (não há dado nenhum para o usuário preencher), e por isso a tela precisa dos três estados da RN-088 (carregando/erro/sucesso), não só dois.

Consequências

Quebra deliberadamente nenhuma. MAIL_TRANSPORT não setado — em qualquer ambiente, inclusive produção — mantém o comportamento de hoje (log-only). Quem quer e-mail de verdade faz um opt-in explícito e passa a ter as cinco variáveis validadas no boot quando roda em produção.

Nova dependência de produção: nodemailer (+ @types/nodemailer como dev dependency). Zero dependências transitivas próprias.

Novo segredo de infraestrutura: SMTP_PASSWORD — mesma família de AUTH_JWT_SECRET/GIT_OAUTH_STATE_SECRET (variável de ambiente simples, validada no boot), não segredo de usuário (não passa por EncryptionService/envelope encryption, que é só para credencial de LLM/git por REGISTRO no banco).

Novo caminho de rede externo num fluxo de autenticação sensível — RN-030 a RN-033 (anti-enumeração) continuam intocadas: a implementação SMTP não muda NENHUM call site nem o payload que os casos de uso decidem mandar, só a entrega.

Fica registrado, não fechado aqui: não existe mecanismo de retentativa para e-mail que falha no envio SMTP (timeout, credencial recusada pelo provedor). LoginUseCase.enviarDefinicaoDeSenha já engolia a falha de propósito (não muda a resposta HTTP); RegisterUseCase/ RequestPasswordResetUseCase propagam a exceção, e um provedor SMTP fora do ar vira 500 na rota de auth. Isso já era verdade com qualquer implementação síncrona de MailSender e não piora com esta — outbox/fila de envio, se um dia for preciso, é decisão de produto separada, fora do escopo deste ADR.