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>
541 lines
16 KiB
Go
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()
|
|
}
|