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>
148 lines
4.6 KiB
Go
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 ""
|
|
}
|