veza/veza-backend-api/internal/services/email_service.go

535 lines
15 KiB
Go

package services
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"github.com/google/uuid"
"html/template"
"net/smtp"
"os"
"time"
"veza-backend-api/internal/database"
"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
func NewEmailService(db *database.Database, logger *zap.Logger) *EmailService {
return &EmailService{
db: db,
logger: logger,
smtpHost: os.Getenv("SMTP_HOST"),
smtpPort: os.Getenv("SMTP_PORT"),
smtpUser: os.Getenv("SMTP_USER"),
smtpPass: os.Getenv("SMTP_PASSWORD"),
fromEmail: os.Getenv("FROM_EMAIL"),
fromName: os.Getenv("FROM_NAME"),
}
}
// 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()
}