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 := ` Verify your Veza account

Welcome to Veza!

Thank you for signing up. Please verify your email address to complete your registration.

Verify Email Address

Or copy and paste this link into your browser:

{{.VerifyURL}}

This link will expire in 24 hours.

` 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 := ` Reset your Veza password

Reset your password

You requested to reset your Veza account password. Click the button below to continue.

Reset Password

Or copy and paste this link into your browser:

{{.ResetURL}}

This link will expire in 1 hour. If you didn't request this, please ignore this email.

` 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 := ` Welcome to Veza

Welcome to Veza, {{.Username}}!

Thank you for joining Veza. We're excited to have you on board!

Get started by:

Get Started

If you have any questions, feel free to reach out to our support team.

` 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 := ` Notification
{{.IconText}}

{{.Message}}

View on Veza

You can manage your notification preferences in your account settings.

` 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() }