veza/veza-backend-api/internal/email/sender.go
senke 9002e91d91 refactor(backend,infra): unify SMTP env schema on canonical SMTP_* names
Third item of the v1.0.6 backlog. The v1.0.5.1 hotfix surfaced that two
email paths in-tree read *different* env vars for the same configuration:

    internal/email/sender.go         internal/services/email_service.go
    SMTP_USERNAME                    SMTP_USER
    SMTP_FROM                        FROM_EMAIL
    SMTP_FROM_NAME                   FROM_NAME

The hotfix worked around it by exporting both sets in `.env.template`.
This commit reconciles them onto a single schema so the workaround can
go away.

Changes
  * `internal/email/sender.go` is now the single loader. The canonical
    names (`SMTP_USERNAME`, `SMTP_FROM`, `SMTP_FROM_NAME`) are read
    first; the legacy names (`SMTP_USER`, `FROM_EMAIL`, `FROM_NAME`)
    stay supported as a migration fallback that logs a structured
    deprecation warning ("remove_in: v1.1.0"). Canonical always wins
    over deprecated — no silent precedence flip.
  * `NewSMTPEmailSender` callers keep working unchanged; a new
    `LoadSMTPConfigFromEnvWithLogger(*zap.Logger)` variant lets callers
    opt into the warning stream.
  * `internal/services/email_service.go` drops its six inline
    `os.Getenv` reads and delegates to the shared loader, so
    `AuthService.Register` and `RequestPasswordReset` now see exactly
    the same config as the async job worker.
  * `.env.template`: the duplicate (SMTP_USER + FROM_EMAIL + FROM_NAME)
    block added in v1.0.5.1 is removed — only the canonical SMTP_*
    names ship for new contributors.
  * `docker-compose.yml` (backend-api service): FROM_EMAIL / FROM_NAME
    renamed to SMTP_FROM / SMTP_FROM_NAME to match the canonical schema.
  * No Host/Port default injected in the loader. If SMTP_HOST is
    empty, callers see Host=="" and log-only (historic dev behavior).
    Dev defaults (MailHog localhost:1025) live in `.env.template`, so
    a fresh clone still works; a misconfigured prod pod fails loud
    instead of silently dialing localhost.

Tests
  * 5 new Go tests in `internal/email/smtp_env_test.go`: empty-env
    returns empty config; canonical names read directly; deprecated
    names fall back (one warning per var); canonical wins over
    deprecated silently; nil logger is allowed.
  * Existing `TestLoadSMTPConfigFromEnv`, `TestSMTPEmailSender_Send`,
    and every auth/services package remained green (40+ packages).

Import-cycle note: the loader deliberately lives in `internal/email`,
not `internal/config`, because `internal/config` already depends on
`internal/email` (wiring `EmailSender` at boot). Putting the loader in
`email` keeps the dependency flow one-way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 20:44:09 +02:00

148 lines
4.6 KiB
Go

package email
import (
"fmt"
"net/smtp"
"os"
"go.uber.org/zap"
)
// EmailSender interface pour l'envoi d'emails
type EmailSender interface {
Send(to, subject, body string) error
SendTemplate(to, template string, data map[string]interface{}) error
}
// SMTPConfig contient la configuration SMTP
type SMTPConfig struct {
Host string
Port string
Username string
Password string
From string
FromName string
}
// SMTPEmailSender implémente EmailSender avec SMTP réel
type SMTPEmailSender struct {
config SMTPConfig
logger *zap.Logger
}
// NewSMTPEmailSender crée un nouveau sender SMTP
func NewSMTPEmailSender(config SMTPConfig, logger *zap.Logger) *SMTPEmailSender {
return &SMTPEmailSender{
config: config,
logger: logger,
}
}
// Send envoie un email via SMTP
func (s *SMTPEmailSender) Send(to, subject, body string) error {
// Si pas de config SMTP, log seulement (dev mode)
if s.config.Host == "" {
s.logger.Info("SMTP not configured, email would be sent",
zap.String("to", to),
zap.String("subject", subject),
)
return nil
}
// SMTP auth
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
// Email headers avec format correct
fromHeader := s.config.From
if s.config.FromName != "" {
fromHeader = fmt.Sprintf("%s <%s>", s.config.FromName, s.config.From)
}
msg := []byte(fmt.Sprintf("From: %s\r\n"+
"To: %s\r\n"+
"Subject: %s\r\n"+
"MIME-Version: 1.0\r\n"+
"Content-Type: text/html; charset=UTF-8\r\n"+
"\r\n"+
"%s", fromHeader, to, subject, body))
// Send email
addr := fmt.Sprintf("%s:%s", s.config.Host, s.config.Port)
err := smtp.SendMail(addr, auth, s.config.From, []string{to}, msg)
if err != nil {
return fmt.Errorf("failed to send email via SMTP: %w", err)
}
s.logger.Info("Email sent successfully",
zap.String("to", to),
zap.String("subject", subject),
)
return nil
}
// SendTemplate envoie un email avec un template
// Pour l'instant, cette méthode appelle Send avec le body généré
// L'implémentation complète avec template engine sera dans email_job.go
func (s *SMTPEmailSender) SendTemplate(to, template string, data map[string]interface{}) error {
// Cette méthode sera utilisée par EmailJob qui gère le rendu des templates
// Pour l'instant, on délègue au template renderer
return fmt.Errorf("SendTemplate not implemented directly, use EmailJob instead")
}
// v1.0.6 — single SMTP env schema. Before this commit, two services read
// different env names for the same fields:
//
// internal/email/sender.go internal/services/email_service.go
// SMTP_USERNAME SMTP_USER
// SMTP_FROM FROM_EMAIL
// SMTP_FROM_NAME FROM_NAME
//
// Both now consume LoadSMTPConfigFromEnv. The canonical names are SMTP_*
// everywhere. The old names are still read as a deprecation fallback with
// a one-line warning so existing deployments don't break mid-rollout — to
// be removed in v1.1.0.
// LoadSMTPConfigFromEnv reads the canonical SMTP_* env vars.
// No defaults are injected: Host/Port come straight from env. A caller
// seeing Host=="" must treat SMTP as unconfigured and log-only (matches
// the historic dev behavior). Dev defaults (MailHog on localhost:1025)
// live in `veza-backend-api/.env.template` — this keeps the runtime
// honest and lets prod fail fast on a missing env.
func LoadSMTPConfigFromEnv() SMTPConfig {
return LoadSMTPConfigFromEnvWithLogger(nil)
}
// LoadSMTPConfigFromEnvWithLogger is the logger-aware variant — pass your
// package logger so deprecation warnings land in structured logs.
func LoadSMTPConfigFromEnvWithLogger(logger *zap.Logger) SMTPConfig {
return SMTPConfig{
Host: os.Getenv("SMTP_HOST"),
Port: os.Getenv("SMTP_PORT"),
Username: resolveDeprecated(logger, "SMTP_USERNAME", "SMTP_USER"),
Password: os.Getenv("SMTP_PASSWORD"),
From: resolveDeprecated(logger, "SMTP_FROM", "FROM_EMAIL"),
FromName: resolveDeprecated(logger, "SMTP_FROM_NAME", "FROM_NAME"),
}
}
// resolveDeprecated returns the value of `canonical` if set, otherwise
// falls back to `deprecated` (emitting a warning). Empty string if neither
// is set. Exported at package scope so it's unit-testable in isolation.
func resolveDeprecated(logger *zap.Logger, canonical, deprecated string) string {
if v := os.Getenv(canonical); v != "" {
return v
}
if v := os.Getenv(deprecated); v != "" {
if logger != nil {
logger.Warn(
"Deprecated SMTP env var in use — migrate to the canonical name",
zap.String("deprecated", deprecated),
zap.String("canonical", canonical),
zap.String("remove_in", "v1.1.0"),
)
}
return v
}
return ""
}