veza/veza-backend-api/internal/services/email_service.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

541 lines
16 KiB
Go

package services
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"html/template"
"net/smtp"
"os"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/database"
"veza-backend-api/internal/email"
"go.uber.org/zap"
)
// EmailService handles email operations
type EmailService struct {
db *database.Database
logger *zap.Logger
smtpHost string
smtpPort string
smtpUser string
smtpPass string
fromEmail string
fromName string
}
// NewEmailService creates a new email service.
// v1.0.6: config loaded via email.LoadSMTPConfigFromEnvWithLogger, the single
// source of truth for the canonical SMTP_* schema (with deprecation fallback
// to SMTP_USER / FROM_EMAIL / FROM_NAME).
func NewEmailService(db *database.Database, logger *zap.Logger) *EmailService {
cfg := email.LoadSMTPConfigFromEnvWithLogger(logger)
return &EmailService{
db: db,
logger: logger,
smtpHost: cfg.Host,
smtpPort: cfg.Port,
smtpUser: cfg.Username,
smtpPass: cfg.Password,
fromEmail: cfg.From,
fromName: cfg.FromName,
}
}
// EmailVerificationToken represents an email verification token
type EmailVerificationToken struct {
ID int64 `db:"id"`
UserID uuid.UUID `db:"user_id"`
Token string `db:"token"`
ExpiresAt time.Time `db:"expires_at"`
Used bool `db:"used"`
CreatedAt time.Time `db:"created_at"`
}
// SendVerificationEmail sends a verification email to the user
// T0184: Accepte email et token (le token est généré et stocké par EmailVerificationService)
func (es *EmailService) SendVerificationEmail(email, token string) error {
// T0184: Étape 3 - Générer URL de vérification avec token
baseURL := os.Getenv("FRONTEND_URL")
if baseURL == "" {
baseURL = "http://localhost:5173"
}
verifyURL := fmt.Sprintf("%s/verify-email?token=%s", baseURL, token)
// T0184: Étape 4 - Construire email HTML avec lien
subject := "Verify your Veza account"
body := es.buildVerificationEmailHTML(verifyURL)
// T0184: Étape 5 - Envoyer email via SMTP (gestion erreurs sans faire échouer registration)
err := es.sendEmail(email, subject, body)
if err != nil {
return fmt.Errorf("failed to send verification email: %w", err)
}
es.logger.Info("Verification email sent",
zap.String("email", email),
)
return nil
}
// SendVerificationEmailWithUserID sends a verification email to the user (legacy method for backward compatibility)
// This method generates and stores the token itself
func (es *EmailService) SendVerificationEmailWithUserID(userID uuid.UUID, email string) error {
// Generate verification token
token, err := es.generateVerificationToken()
if err != nil {
return fmt.Errorf("failed to generate verification token: %w", err)
}
// Store token in database
err = es.storeVerificationToken(userID, token)
if err != nil {
return fmt.Errorf("failed to store verification token: %w", err)
}
// Use the new method to send the email
return es.SendVerificationEmail(email, token)
}
// VerifyEmailToken verifies an email verification token
func (es *EmailService) VerifyEmailToken(token string) (uuid.UUID, error) {
var vt EmailVerificationToken
ctx := context.Background()
err := es.db.QueryRowContext(ctx, `
SELECT id, user_id, token, expires_at, used, created_at
FROM email_verification_tokens
WHERE token = $1 AND used = FALSE
`, token).Scan(
&vt.ID,
&vt.UserID,
&vt.Token,
&vt.ExpiresAt,
&vt.Used,
&vt.CreatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return uuid.Nil, fmt.Errorf("invalid or expired verification token")
}
return uuid.Nil, fmt.Errorf("failed to verify token: %w", err)
}
// Check if token has expired
if time.Now().After(vt.ExpiresAt) {
return uuid.Nil, fmt.Errorf("failed to update user email verification: %w", err)
}
// Mark token as used
_, err = es.db.ExecContext(ctx, `
UPDATE email_verification_tokens
SET used = TRUE
WHERE id = $1
`, vt.ID)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to mark token as used: %w", err)
}
// Update user's email verification status
_, err = es.db.ExecContext(ctx, `
UPDATE users
SET email_verified = TRUE, email_verified_at = NOW()
WHERE id = $1
`, vt.UserID)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to update user email verification: %w", err)
}
es.logger.Info("Email verified",
zap.String("user_id", vt.UserID.String()),
)
return vt.UserID, nil
}
// ResendVerificationEmail resends a verification email
func (es *EmailService) ResendVerificationEmail(userID uuid.UUID, email string) error {
ctx := context.Background()
// Check if already verified
var verified bool
err := es.db.QueryRowContext(ctx, `
SELECT email_verified
FROM users
WHERE id = $1
`, userID).Scan(&verified)
if err != nil {
return fmt.Errorf("failed to check verification status: %w", err)
}
if verified {
return fmt.Errorf("email already verified")
}
// Invalidate old tokens for this user
_, err = es.db.ExecContext(ctx, `
UPDATE email_verification_tokens
SET used = TRUE
WHERE user_id = $1 AND used = FALSE
`, userID)
if err != nil {
es.logger.Warn("Failed to invalidate old tokens",
zap.Error(err),
zap.String("user_id", userID.String()),
)
}
// Send new verification email (use legacy method that generates token)
return es.SendVerificationEmailWithUserID(userID, email)
}
// generateVerificationToken generates a secure random token
func (es *EmailService) generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
// storeVerificationToken stores a verification token in the database
func (es *EmailService) storeVerificationToken(userID uuid.UUID, token string) error {
ctx := context.Background()
expiresAt := time.Now().Add(24 * time.Hour) // Token expires in 24 hours
_, err := es.db.ExecContext(ctx, `
INSERT INTO email_verification_tokens (user_id, token, expires_at, used)
VALUES ($1, $2, $3, FALSE)
`, userID, token, expiresAt)
return err
}
// sendEmail sends an email using SMTP
func (es *EmailService) sendEmail(to, subject, body string) error {
// If no SMTP configured, just log (for development)
if es.smtpHost == "" {
es.logger.Info("Email not configured, logging instead",
zap.String("to", to),
zap.String("subject", subject),
)
return nil
}
// SMTP auth
auth := smtp.PlainAuth("", es.smtpUser, es.smtpPass, es.smtpHost)
// Email headers
msg := []byte(fmt.Sprintf("From: %s <%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", es.fromName, es.fromEmail, to, subject, body))
// Send email
addr := fmt.Sprintf("%s:%s", es.smtpHost, es.smtpPort)
err := smtp.SendMail(addr, auth, es.fromEmail, []string{to}, msg)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
return nil
}
// buildVerificationEmailHTML builds the HTML email template
// T0184: Construit l'email HTML avec lien de vérification
func (es *EmailService) buildVerificationEmailHTML(url string) string {
tmpl := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Verify your Veza account</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #4CAF50;">Welcome to Veza!</h1>
<p>Thank you for signing up. Please verify your email address to complete your registration.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{.VerifyURL}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Verify Email Address
</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666;">{{.VerifyURL}}</p>
<p style="margin-top: 30px; color: #666; font-size: 12px;">
This link will expire in 24 hours.
</p>
</div>
</body>
</html>
`
t, err := template.New("verification").Parse(tmpl)
if err != nil {
return fmt.Sprintf("Click here to verify your email: %s", url)
}
var buf bytes.Buffer
err = t.Execute(&buf, map[string]string{
"VerifyURL": url,
})
if err != nil {
return fmt.Sprintf("Click here to verify your email: %s", url)
}
return buf.String()
}
// SendPasswordResetEmail sends a password reset email
func (es *EmailService) SendPasswordResetEmail(userID uuid.UUID, email string, token string) error {
// Build reset URL
baseURL := os.Getenv("FRONTEND_URL")
if baseURL == "" {
baseURL = "http://localhost:5173"
}
resetURL := fmt.Sprintf("%s/reset-password?token=%s", baseURL, token)
// Prepare email content
subject := "Reset your Veza password"
body := es.buildPasswordResetEmail(resetURL)
// Send email
err := es.sendEmail(email, subject, body)
if err != nil {
return fmt.Errorf("failed to send password reset email: %w", err)
}
es.logger.Info("Password reset email sent",
zap.String("user_id", userID.String()),
zap.String("email", email),
)
return nil
}
// buildPasswordResetEmail builds the HTML password reset email template
func (es *EmailService) buildPasswordResetEmail(url string) string {
tmpl := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Reset your Veza password</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #4CAF50;">Reset your password</h1>
<p>You requested to reset your Veza account password. Click the button below to continue.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{.ResetURL}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Reset Password
</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666;">{{.ResetURL}}</p>
<p style="margin-top: 30px; color: #666; font-size: 12px;">
This link will expire in 1 hour. If you didn't request this, please ignore this email.
</p>
</div>
</body>
</html>
`
t, err := template.New("password_reset").Parse(tmpl)
if err != nil {
return fmt.Sprintf("Click here to reset your password: %s", url)
}
var buf bytes.Buffer
err = t.Execute(&buf, map[string]string{
"ResetURL": url,
})
if err != nil {
return fmt.Sprintf("Click here to reset your password: %s", url)
}
return buf.String()
}
// SendWelcomeEmail sends a welcome email to a new user
// BE-SVC-004: Implement email service for notifications
func (es *EmailService) SendWelcomeEmail(email, username string) error {
subject := "Welcome to Veza!"
body := es.buildWelcomeEmailHTML(username)
err := es.sendEmail(email, subject, body)
if err != nil {
return fmt.Errorf("failed to send welcome email: %w", err)
}
es.logger.Info("Welcome email sent",
zap.String("email", email),
zap.String("username", username),
)
return nil
}
// buildWelcomeEmailHTML builds the HTML welcome email template
// BE-SVC-004: Implement email service for notifications
func (es *EmailService) buildWelcomeEmailHTML(username string) string {
baseURL := os.Getenv("FRONTEND_URL")
if baseURL == "" {
baseURL = "http://localhost:5173"
}
tmpl := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Welcome to Veza</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h1 style="color: #4CAF50;">Welcome to Veza, {{.Username}}!</h1>
<p>Thank you for joining Veza. We're excited to have you on board!</p>
<p>Get started by:</p>
<ul>
<li>Uploading your first track</li>
<li>Creating playlists</li>
<li>Discovering new music</li>
</ul>
<div style="text-align: center; margin: 30px 0;">
<a href="{{.BaseURL}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Get Started
</a>
</div>
<p style="margin-top: 30px; color: #666; font-size: 12px;">
If you have any questions, feel free to reach out to our support team.
</p>
</div>
</body>
</html>
`
t, err := template.New("welcome").Parse(tmpl)
if err != nil {
return fmt.Sprintf("Welcome to Veza, %s! Get started at %s", username, baseURL)
}
var buf bytes.Buffer
err = t.Execute(&buf, map[string]string{
"Username": username,
"BaseURL": baseURL,
})
if err != nil {
return fmt.Sprintf("Welcome to Veza, %s! Get started at %s", username, baseURL)
}
return buf.String()
}
// SendNotificationEmail sends a notification email to a user
// BE-SVC-004: Implement email service for notifications
func (es *EmailService) SendNotificationEmail(email, subject, message string, notificationType string) error {
body := es.buildNotificationEmailHTML(message, notificationType)
err := es.sendEmail(email, subject, body)
if err != nil {
return fmt.Errorf("failed to send notification email: %w", err)
}
es.logger.Info("Notification email sent",
zap.String("email", email),
zap.String("type", notificationType),
)
return nil
}
// buildNotificationEmailHTML builds the HTML notification email template
// BE-SVC-004: Implement email service for notifications
func (es *EmailService) buildNotificationEmailHTML(message, notificationType string) string {
baseURL := os.Getenv("FRONTEND_URL")
if baseURL == "" {
baseURL = "http://localhost:5173"
}
// Determine icon/color based on notification type
var iconColor string
var iconText string
switch notificationType {
case "track_like":
iconColor = "#FF6B6B"
iconText = "❤️"
case "new_follower":
iconColor = "#4ECDC4"
iconText = "👤"
case "playlist_update":
iconColor = "#45B7D1"
iconText = "🎵"
case "comment_reply":
iconColor = "#FFA07A"
iconText = "💬"
default:
iconColor = "#4CAF50"
iconText = "🔔"
}
tmpl := `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Notification</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="text-align: center; margin-bottom: 20px;">
<span style="font-size: 48px;">{{.IconText}}</span>
</div>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;">{{.Message}}</p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{.BaseURL}}" style="background-color: {{.IconColor}}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
View on Veza
</a>
</div>
<p style="margin-top: 30px; color: #666; font-size: 12px;">
You can manage your notification preferences in your account settings.
</p>
</div>
</body>
</html>
`
t, err := template.New("notification").Parse(tmpl)
if err != nil {
return fmt.Sprintf("%s\n\nView on Veza: %s", message, baseURL)
}
var buf bytes.Buffer
err = t.Execute(&buf, map[string]string{
"Message": message,
"BaseURL": baseURL,
"IconColor": iconColor,
"IconText": iconText,
})
if err != nil {
return fmt.Sprintf("%s\n\nView on Veza: %s", message, baseURL)
}
return buf.String()
}