email_validation_service.rs 496 lignes · 17063 octets
//! Email validation service
//!
//! This service handles email validation functionality:
//! - Generating secure validation tokens
//! - Sending validation emails via queue
//! - Validating tokens
//! - Resending validation emails

use crate::config::email::EmailConfig;
use crate::error::AppError;
use crate::models::{email_validation_token, user};
use crate::services::email_queue_service::EmailQueueService;
use crate::services::rate_limit_service::RateLimitService;
use crate::services::user_service::UserService;
use chrono::{Duration, Utc};
use sea_orm::{
    ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
};
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
use std::time::Duration as StdDuration;
use uuid::Uuid;

/// Default: 3 verification email requests per address per hour.
const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3;
const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);

/// Process-wide sliding-window limiter for email-validation resends.
///
/// Distinct from the password-reset limiter so the two flows keep separate
/// budgets. Handlers construct [`EmailValidationService::new`] per request.
static EMAIL_VALIDATION_LIMITER: OnceLock<RateLimitService> = OnceLock::new();

/// Return the process-wide email-validation limiter, initializing it once.
fn shared_email_validation_limiter() -> RateLimitService {
    EMAIL_VALIDATION_LIMITER
        .get_or_init(|| {
            let limiter = RateLimitService::new();
            if let Ok(handle) = tokio::runtime::Handle::try_current() {
                handle.spawn(
                    limiter
                        .clone()
                        .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)),
                );
            } else {
                tracing::warn!(
                    "email-validation limiter: no tokio runtime, cleanup_loop not started"
                );
            }
            limiter
        })
        .clone()
}

/// Email validation service
pub struct EmailValidationService {
    email_config: EmailConfig,
    /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
    rate_limiter: RateLimitService,
}

impl EmailValidationService {
    /// Create a new EmailValidationService using the process-wide rate limiter.
    pub fn new(email_config: EmailConfig) -> Self {
        Self {
            email_config,
            rate_limiter: shared_email_validation_limiter(),
        }
    }

    /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]).
    #[cfg(test)]
    pub(crate) fn new_with_limiter(
        email_config: EmailConfig,
        rate_limiter: RateLimitService,
    ) -> Self {
        Self {
            email_config,
            rate_limiter,
        }
    }

    /// Send validation email to a user
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `user_id` - User ID to send validation email to
    ///
    /// # Returns
    ///
    /// Ok(()) if email was queued successfully
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - User not found
    /// - Database operation fails
    /// - Email queue operation fails
    pub async fn send_validation_email(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<(), AppError> {
        // Find user
        let user = UserService::get_user_by_id(db, user_id)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

        // Invalidate previous validation tokens for this user
        self.invalidate_previous_tokens(db, user_id).await?;

        // Generate secure token (UUID v4)
        let token = Uuid::new_v4().to_string();

        // Hash token with SHA-256
        let token_hash = Self::hash_token(&token);

        let expires_at =
            Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours());

        let validation_token = email_validation_token::ActiveModel {
            id: Set(Uuid::new_v4()),
            user_id: Set(user_id),
            token_hash: Set(token_hash),
            expires_at: Set(expires_at),
            used_at: NotSet,
            created_at: Set(Utc::now()),
        };

        validation_token.insert(db).await?;

        // Generate validation URL
        let validation_url = format!(
            "{}/api/v1/auth/verify-email/{}",
            self.email_config.email_base_url, token
        );

        // Render email template using Askama
        let locale = Some("fr".to_string()); // TODO: Get from user preferences or Accept-Language header
        let template = crate::templates::email_templates::EmailValidationTemplate::new(
            locale,
            user.username.clone(),
            validation_url.clone(),
            Some(
                self.email_config
                    .smtp_from_name
                    .clone()
                    .unwrap_or_else(crate::config::app::default_app_name),
            ),
        );

        // Render HTML and text versions
        let body_html = template
            .render_html()
            .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?;
        let body_text = template.render_text();

        let subject = match template.locale.as_str() {
            "fr" => "Vérification de votre adresse email",
            "de" => "Bestätigung Ihrer E-Mail-Adresse",
            "es" => "Verifique su dirección de correo electrónico",
            _ => "Verify Your Email Address",
        }
        .to_string();

        // Enqueue email
        EmailQueueService::enqueue(
            db,
            user.email.clone(),
            Some(user.username.clone()),
            self.email_config.smtp_from.clone(),
            self.email_config.smtp_from_name.clone(),
            subject,
            body_html,
            body_text,
            "email_validation".to_string(),
        )
        .await?;

        Ok(())
    }

    /// Verify email using a validation token
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `token` - Validation token from email
    ///
    /// # Returns
    ///
    /// User ID if token is valid and email was verified
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Token is invalid
    /// - Token is expired
    /// - Database operation fails
    ///
    /// # Note
    ///
    /// Unlike password reset tokens, validation tokens are idempotent
    /// (can be used multiple times, but we track first use)
    pub async fn verify_email(
        &self,
        db: &DatabaseConnection,
        token: &str,
    ) -> Result<Uuid, AppError> {
        // Hash token
        let token_hash = Self::hash_token(token);

        // Find token in database
        let validation_token = email_validation_token::Entity::find()
            .filter(email_validation_token::Column::TokenHash.eq(&token_hash))
            .one(db)
            .await?
            .ok_or_else(|| AppError::Validation("Invalid verification token".to_string()))?;

        // Check if token is expired
        if validation_token.is_expired() {
            return Err(AppError::Validation(
                "Verification token has expired".to_string(),
            ));
        }

        // Mark token as used (idempotent: can be used multiple times, but we track first use)
        if validation_token.used_at.is_none() {
            let mut token_active: email_validation_token::ActiveModel =
                validation_token.clone().into();
            token_active.used_at = Set(Some(Utc::now()));
            token_active.update(db).await?;
        }

        // Update user email_verified status
        let user = UserService::get_user_by_id(db, validation_token.user_id)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

        if !user.email_verified {
            let mut user_active: user::ActiveModel = user.clone().into();
            user_active.email_verified = Set(true);
            user_active.email_verified_at = Set(Some(Utc::now()));
            user_active.updated_at = Set(Utc::now());
            user_active.update(db).await?;

            // Feature 011 - US5: Send welcome email when account is activated (FR-024)
            // Only send if welcome emails are enabled and this is the first verification
            let welcome_email_enabled = std::env::var("EMAIL_WELCOME_ENABLED")
                .unwrap_or_else(|_| "true".to_string())
                .parse::<bool>()
                .unwrap_or(true);

            if welcome_email_enabled {
                // Generate login URL
                let login_url = format!("{}/login", self.email_config.email_base_url);

                // Render welcome email template
                let locale = Some("fr".to_string()); // TODO: Get from user preferences
                let template = crate::templates::email_templates::WelcomeEmailTemplate::new(
                    locale.clone(),
                    user.username.clone(),
                    login_url,
                    Some(
                        self.email_config
                            .smtp_from_name
                            .clone()
                            .unwrap_or_else(crate::config::app::default_app_name),
                    ),
                );

                // Render HTML and text versions
                if let (Ok(body_html), body_text) = (template.render_html(), template.render_text())
                {
                    // Subject based on locale
                    let subject = match locale.as_deref() {
                        Some("fr") => "Bienvenue sur RustWarden !",
                        Some("de") => "Willkommen bei RustWarden!",
                        Some("es") => "¡Bienvenido a RustWarden!",
                        _ => "Welcome to RustWarden!",
                    }
                    .to_string();

                    // Enqueue welcome email (non-blocking - errors are logged but don't fail verification)
                    if let Err(e) =
                        crate::services::email_queue_service::EmailQueueService::enqueue(
                            db,
                            user.email.clone(),
                            Some(user.username.clone()),
                            self.email_config.smtp_from.clone(),
                            self.email_config.smtp_from_name.clone(),
                            subject,
                            body_html,
                            body_text,
                            "welcome".to_string(),
                        )
                        .await
                    {
                        tracing::warn!(
                            "Failed to enqueue welcome email for user {}: {}",
                            user.username,
                            e
                        );
                    } else {
                        tracing::info!(
                            "Welcome email queued for user {} (account activated)",
                            user.username
                        );
                    }
                }
            }
        }

        Ok(validation_token.user_id)
    }

    /// Resend validation email to a user
    ///
    /// # Arguments
    ///
    /// * `db` - Database connection
    /// * `email` - User email address
    ///
    /// # Returns
    ///
    /// `Ok(true)` if email was sent, `Ok(false)` if user doesn't exist or is already verified (security: don't reveal)
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - Rate limit exceeded
    /// - Database operation fails
    /// - Email queue operation fails
    pub async fn resend_verification(
        &self,
        db: &DatabaseConnection,
        email: &str,
    ) -> Result<bool, AppError> {
        // Check rate limiting (3 requests per hour per email). Atomic check+record.
        self.rate_limiter.check_sliding_window(
            &format!("email_verify:{email}"),
            EMAIL_VALIDATION_RATE_LIMIT_MAX,
            EMAIL_VALIDATION_RATE_LIMIT_WINDOW,
        )?;

        // Find user by email
        let user = match UserService::get_user_by_email(db, email).await? {
            Some(u) => u,
            None => {
                // User doesn't exist - return Ok(false) but don't reveal this (security)
                return Ok(false);
            }
        };

        // If user is already verified, don't send email (but don't reveal this)
        if user.email_verified {
            return Ok(false);
        }

        // Send validation email
        self.send_validation_email(db, user.id).await?;

        Ok(true)
    }

    /// Hash a token using SHA-256
    fn hash_token(token: &str) -> String {
        let mut hasher = Sha256::new();
        hasher.update(token.as_bytes());
        format!("{:x}", hasher.finalize())
    }

    /// Invalidate previous validation tokens for a user
    async fn invalidate_previous_tokens(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<(), AppError> {
        // Find all unused tokens for this user
        let tokens = email_validation_token::Entity::find()
            .filter(email_validation_token::Column::UserId.eq(user_id))
            .filter(email_validation_token::Column::UsedAt.is_null())
            .all(db)
            .await?;

        // Mark them as used (invalidate)
        for token in tokens {
            let mut token_active: email_validation_token::ActiveModel = token.into();
            token_active.used_at = Set(Some(Utc::now()));
            token_active.update(db).await?;
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::email::EmailConfig;

    fn dummy_email_config() -> EmailConfig {
        EmailConfig {
            smtp_host: "localhost".into(),
            smtp_port: 587,
            smtp_user: "u".into(),
            smtp_password: "p".into(),
            smtp_from: "from@example.test".into(),
            smtp_from_name: None,
            smtp_use_tls: false,
            smtp_use_starttls: false,
            smtp_accept_invalid_certs: true,
            smtp_ca_cert_path: None,
            queue_batch_size: 10,
            queue_retry_attempts: 5,
            queue_retry_delay_seconds: 300,
            email_base_url: "http://localhost".into(),
            request_dsn: false,
            imap_host: None,
            imap_port: 993,
            imap_user: None,
            imap_password: None,
            imap_use_tls: true,
            imap_use_starttls: false,
            imap_accept_invalid_certs: false,
            imap_ca_cert_path: None,
            imap_mailbox: "INBOX".into(),
            imap_poll_interval_seconds: 300,
        }
    }

    #[test]
    fn new_with_limiter_uses_injected_instance() {
        let limiter = RateLimitService::new();
        let svc = EmailValidationService::new_with_limiter(dummy_email_config(), limiter.clone());
        for _ in 0..3 {
            limiter
                .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600))
                .unwrap();
        }
        assert!(
            svc.rate_limiter
                .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600))
                .is_err()
        );
    }

    #[test]
    fn new_shares_process_wide_limiter() {
        let a = EmailValidationService::new(dummy_email_config());
        let b = EmailValidationService::new(dummy_email_config());
        for _ in 0..3 {
            a.rate_limiter
                .check_sliding_window(
                    "email_verify:share-oncelock@test",
                    3,
                    StdDuration::from_secs(3600),
                )
                .unwrap();
        }
        assert!(
            b.rate_limiter
                .check_sliding_window(
                    "email_verify:share-oncelock@test",
                    3,
                    StdDuration::from_secs(3600),
                )
                .is_err()
        );
    }

    #[test]
    fn new_with_limiter_is_independent_of_process_lock() {
        let private = RateLimitService::new();
        let svc = EmailValidationService::new_with_limiter(dummy_email_config(), private.clone());
        for _ in 0..3 {
            private
                .check_sliding_window("k", 3, StdDuration::from_secs(3600))
                .unwrap();
        }
        assert!(
            svc.rate_limiter
                .check_sliding_window("k", 3, StdDuration::from_secs(3600))
                .is_err()
        );

        let process = EmailValidationService::new(dummy_email_config());
        assert!(
            process
                .rate_limiter
                .check_sliding_window("k", 3, StdDuration::from_secs(3600))
                .is_ok()
        );
    }
}