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 := `
Thank you for signing up. Please verify your email address to complete your registration.
Or copy and paste this link into your browser:
{{.VerifyURL}}
This link will expire in 24 hours.
You requested to reset your Veza account password. Click the button below to continue.
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.
Thank you for joining Veza. We're excited to have you on board!
Get started by:
If you have any questions, feel free to reach out to our support team.
{{.Message}}
You can manage your notification preferences in your account settings.