email_templates.rs 1676 lignes · 53216 octets
//! Email templates for Askama
//!
//! This module contains Askama templates for email generation.
//! Templates support i18n (French/English) as specified in FR-034.

use askama::Template;
use chrono::Datelike;

/// Password reset email template
///
/// This template generates both HTML and plain text versions of the password reset email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/password_reset.html", escape = "html")]
pub struct PasswordResetEmailTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the user requesting password reset
    pub username: String,
    /// Password reset URL with token
    pub reset_url: String,
    /// Current year for footer
    pub current_year: i32,
}

impl PasswordResetEmailTemplate {
    /// Create a new password reset email template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the user
    /// * `reset_url` - Password reset URL with token
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        reset_url: String,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            reset_url,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        if self.locale == "fr" {
            format!(
                r#"
Réinitialisation de mot de passe

Bonjour {},

Vous avez demandé à réinitialiser votre mot de passe pour votre compte {}.

Cliquez sur le lien suivant pour réinitialiser votre mot de passe :
{}

⚠️ Important :
- Ce lien est valide pendant 1 heure uniquement
- Si vous n'avez pas demandé cette réinitialisation, ignorez cet email
- Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien

Cet email a été envoyé automatiquement, merci de ne pas y répondre.
Si vous avez des questions, contactez le support technique.

© {} {}. Tous droits réservés.
                "#,
                self.username, self.app_name, self.reset_url, self.current_year, self.app_name
            )
        } else {
            format!(
                r#"
Password Reset

Hello {},

You have requested to reset your password for your {} account.

Click the following link to reset your password:
{}

⚠️ Important:
- This link is valid for 1 hour only
- If you did not request this reset, please ignore this email
- Your current password will remain unchanged if you don't use this link

This email was sent automatically, please do not reply.
If you have any questions, please contact technical support.

© {} {}. All rights reserved.
                "#,
                self.username, self.app_name, self.reset_url, self.current_year, self.app_name
            )
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_password_reset_template_creation() {
        let template = PasswordResetEmailTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/reset?token=abc123".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "testuser");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
    }

    #[test]
    fn test_password_reset_template_default_locale() {
        let template = PasswordResetEmailTemplate::new(
            None,
            "testuser".to_string(),
            "https://example.com/reset?token=abc123".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr"); // Default locale
    }

    #[test]
    fn test_password_reset_template_render_text_fr() {
        let template = PasswordResetEmailTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/reset?token=abc123".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Réinitialisation de mot de passe"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/reset?token=abc123"));
    }

    #[test]
    fn test_password_reset_template_render_text_en() {
        let template = PasswordResetEmailTemplate::new(
            Some("en".to_string()),
            "testuser".to_string(),
            "https://example.com/reset?token=abc123".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Password Reset"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/reset?token=abc123"));
    }
}

/// Password change confirmation email template
///
/// This template generates both HTML and plain text versions of the password change confirmation email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/password_change_confirmation.html", escape = "html")]
pub struct PasswordChangeConfirmationEmailTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the user requesting password change
    pub username: String,
    /// Password change confirmation URL with token
    pub confirmation_url: String,
    /// Expiration time in hours (24)
    pub expires_in_hours: i64,
    /// Current year for footer
    pub current_year: i32,
}

impl PasswordChangeConfirmationEmailTemplate {
    /// Create a new password change confirmation email template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the user
    /// * `confirmation_url` - Password change confirmation URL with token
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        confirmation_url: String,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            confirmation_url,
            expires_in_hours: 24,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        if self.locale == "fr" {
            format!(
                r#"
Confirmation de changement de mot de passe

Bonjour {},

Vous avez demandé à changer votre mot de passe pour votre compte {}.

Pour confirmer ce changement, cliquez sur le lien suivant :
{}

⚠️ Important :
- Ce lien est valide pendant {} heures uniquement
- Si vous n'avez pas demandé ce changement, ignorez cet email
- Votre mot de passe actuel restera inchangé si vous n'utilisez pas ce lien
- Ce lien est à usage unique et ne peut être utilisé qu'une seule fois

Cet email a été envoyé automatiquement, merci de ne pas y répondre.
Si vous avez des questions, contactez le support technique.

© {} {}. Tous droits réservés.
                "#,
                self.username,
                self.app_name,
                self.confirmation_url,
                self.expires_in_hours,
                self.current_year,
                self.app_name
            )
        } else {
            format!(
                r#"
Password Change Confirmation

Hello {},

You have requested to change your password for your {} account.

To confirm this change, click the following link:
{}

⚠️ Important:
- This link is valid for {} hours only
- If you did not request this change, please ignore this email
- Your current password will remain unchanged if you don't use this link
- This link is single-use and can only be used once

This email was sent automatically, please do not reply.
If you have any questions, please contact technical support.

© {} {}. All rights reserved.
                "#,
                self.username,
                self.app_name,
                self.confirmation_url,
                self.expires_in_hours,
                self.current_year,
                self.app_name
            )
        }
    }
}

#[cfg(test)]
mod password_change_confirmation_template_tests {
    use super::*;

    #[test]
    fn test_password_change_confirmation_template_creation() {
        let template = PasswordChangeConfirmationEmailTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/confirm-password-change?token=abc123".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "testuser");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
        assert_eq!(template.expires_in_hours, 24);
    }

    #[test]
    fn test_password_change_confirmation_template_render_text_fr() {
        let template = PasswordChangeConfirmationEmailTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/confirm-password-change?token=abc123".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Confirmation de changement de mot de passe"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/confirm-password-change?token=abc123"));
    }

    #[test]
    fn test_password_change_confirmation_template_render_text_en() {
        let template = PasswordChangeConfirmationEmailTemplate::new(
            Some("en".to_string()),
            "testuser".to_string(),
            "https://example.com/confirm-password-change?token=abc123".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Password Change Confirmation"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/confirm-password-change?token=abc123"));
    }
}

/// Email validation template
///
/// This template generates both HTML and plain text versions of the email validation email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/email_validation.html", escape = "html")]
pub struct EmailValidationTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the user
    pub username: String,
    /// Email validation URL with token
    pub validation_url: String,
    /// Current year for footer
    pub current_year: i32,
}

impl EmailValidationTemplate {
    /// Create a new email validation template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the user
    /// * `validation_url` - Email validation URL with token
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        validation_url: String,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            validation_url,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        if self.locale == "fr" {
            format!(
                r#"
Vérification de votre adresse email

Bonjour {},

Merci de vous être inscrit sur {}.

Pour activer votre compte, veuillez vérifier votre adresse email en cliquant sur le lien suivant :
{}

⚠️ Important :
- Ce lien est valide pendant 24 heures
- Si vous n'avez pas créé de compte, ignorez cet email
- Vous devez vérifier votre email avant de pouvoir vous connecter

Cet email a été envoyé automatiquement, merci de ne pas y répondre.
Si vous avez des questions, contactez le support technique.

© {} {}. Tous droits réservés.
                "#,
                self.username, self.app_name, self.validation_url, self.current_year, self.app_name
            )
        } else {
            format!(
                r#"
Verify Your Email Address

Hello {},

Thank you for signing up for {}.

To activate your account, please verify your email address by clicking the following link:
{}

⚠️ Important:
- This link is valid for 24 hours
- If you did not create an account, please ignore this email
- You must verify your email before you can log in

This email was sent automatically, please do not reply.
If you have any questions, please contact technical support.

© {} {}. All rights reserved.
                "#,
                self.username, self.app_name, self.validation_url, self.current_year, self.app_name
            )
        }
    }
}

#[cfg(test)]
mod email_validation_template_tests {
    use super::*;

    #[test]
    fn test_email_validation_template_creation() {
        let template = EmailValidationTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/verify?token=abc123".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "testuser");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
    }

    #[test]
    fn test_email_validation_template_render_text_fr() {
        let template = EmailValidationTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/verify?token=abc123".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Vérification de votre adresse email"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/verify?token=abc123"));
    }

    #[test]
    fn test_email_validation_template_render_text_en() {
        let template = EmailValidationTemplate::new(
            Some("en".to_string()),
            "testuser".to_string(),
            "https://example.com/verify?token=abc123".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Verify Your Email Address"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/verify?token=abc123"));
    }
}

/// Password changed notification template
///
/// This template generates both HTML and plain text versions of the password changed notification email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/password_changed.html", escape = "html")]
pub struct PasswordChangedTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the user
    pub username: String,
    /// Date and time when password was changed (formatted)
    pub changed_at: String,
    /// Current year for footer
    pub current_year: i32,
}

impl PasswordChangedTemplate {
    /// Create a new password changed notification template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the user
    /// * `changed_at` - Date and time when password was changed (formatted string)
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        changed_at: String,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            changed_at,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        if self.locale == "fr" {
            format!(
                r#"
Mot de passe modifié

Bonjour {},

Nous vous informons que le mot de passe de votre compte {} a été modifié avec succès.

Date et heure de modification : {}

ℹ️ Information :
Si vous n'avez pas effectué cette modification, veuillez contacter immédiatement le support technique et réinitialiser votre mot de passe.

⚠️ Sécurité :
- Ne partagez jamais votre mot de passe avec qui que ce soit
- Utilisez un mot de passe unique et fort
- Si vous suspectez une activité suspecte, changez votre mot de passe immédiatement

Cet email a été envoyé automatiquement pour votre sécurité.
Si vous avez des questions, contactez le support technique.

© {} {}. Tous droits réservés.
                "#,
                self.username, self.app_name, self.changed_at, self.current_year, self.app_name
            )
        } else {
            format!(
                r#"
Password Changed

Hello {},

We are informing you that the password for your {} account has been successfully changed.

Date and time of change: {}

ℹ️ Information:
If you did not make this change, please contact technical support immediately and reset your password.

⚠️ Security:
- Never share your password with anyone
- Use a unique and strong password
- If you suspect suspicious activity, change your password immediately

This email was sent automatically for your security.
If you have any questions, please contact technical support.

© {} {}. All rights reserved.
                "#,
                self.username, self.app_name, self.changed_at, self.current_year, self.app_name
            )
        }
    }
}

#[cfg(test)]
mod password_changed_template_tests {
    use super::*;

    #[test]
    fn test_password_changed_template_creation() {
        let template = PasswordChangedTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "2024-01-15 14:30:00".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "testuser");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
    }

    #[test]
    fn test_password_changed_template_render_text_fr() {
        let template = PasswordChangedTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "2024-01-15 14:30:00".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Mot de passe modifié"));
        assert!(text.contains("testuser"));
        assert!(text.contains("2024-01-15 14:30:00"));
    }

    #[test]
    fn test_password_changed_template_render_text_en() {
        let template = PasswordChangedTemplate::new(
            Some("en".to_string()),
            "testuser".to_string(),
            "2024-01-15 14:30:00".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Password Changed"));
        assert!(text.contains("testuser"));
        assert!(text.contains("2024-01-15 14:30:00"));
    }
}

/// Account change information for email template
#[derive(Clone)]
pub struct AccountChange {
    /// Field name that was changed
    pub field: String,
    /// Old value (if applicable)
    pub old_value: Option<String>,
    /// New value
    pub new_value: String,
}

/// Account modified notification template
///
/// This template generates both HTML and plain text versions of the account modified notification email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/account_modified.html", escape = "html")]
pub struct AccountModifiedTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the user
    pub username: String,
    /// Date and time when account was modified (formatted)
    pub modified_at: String,
    /// List of changes made
    pub changes: Vec<AccountChange>,
    /// Current year for footer
    pub current_year: i32,
}

impl AccountModifiedTemplate {
    /// Create a new account modified notification template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the user
    /// * `modified_at` - Date and time when account was modified (formatted string)
    /// * `changes` - List of changes made to the account
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        modified_at: String,
        changes: Vec<AccountChange>,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            modified_at,
            changes,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        let changes_text = if self.changes.is_empty() {
            String::new()
        } else {
            let changes_list: Vec<String> = self
                .changes
                .iter()
                .map(|c| {
                    if let Some(ref old) = c.old_value {
                        format!("  - {}: {} → {}", c.field, old, c.new_value)
                    } else {
                        format!("  - {}: {}", c.field, c.new_value)
                    }
                })
                .collect();
            changes_list.join("\n")
        };

        if self.locale == "fr" {
            format!(
                r#"
Compte modifié

Bonjour {},

Nous vous informons que les informations de votre compte {} ont été modifiées.

Date et heure de modification : {}

{}

⚠️ Sécurité :
- Si vous n'avez pas effectué ces modifications, contactez immédiatement le support technique
- Vérifiez que vous pouvez toujours accéder à votre compte
- Si votre email a été modifié, vérifiez que vous recevez bien les emails à la nouvelle adresse

Cet email a été envoyé automatiquement pour votre sécurité.
Si vous avez des questions, contactez le support technique.

© {} {}. Tous droits réservés.
                "#,
                self.username,
                self.app_name,
                self.modified_at,
                if changes_text.is_empty() {
                    "".to_string()
                } else {
                    format!("Modifications effectuées :\n{}", changes_text)
                },
                self.current_year,
                self.app_name
            )
        } else {
            format!(
                r#"
Account Modified

Hello {},

We are informing you that your {} account information has been modified.

Date and time of modification: {}

{}

⚠️ Security:
- If you did not make these changes, contact technical support immediately
- Verify that you can still access your account
- If your email was changed, verify that you receive emails at the new address

This email was sent automatically for your security.
If you have any questions, please contact technical support.

© {} {}. All rights reserved.
                "#,
                self.username,
                self.app_name,
                self.modified_at,
                if changes_text.is_empty() {
                    "".to_string()
                } else {
                    format!("Changes made:\n{}", changes_text)
                },
                self.current_year,
                self.app_name
            )
        }
    }
}

#[cfg(test)]
mod account_modified_template_tests {
    use super::*;

    #[test]
    fn test_account_modified_template_creation() {
        let template = AccountModifiedTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "2024-01-15 14:30:00".to_string(),
            vec![],
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "testuser");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
    }

    #[test]
    fn test_account_modified_template_with_changes() {
        let changes = vec![
            AccountChange {
                field: "Email".to_string(),
                old_value: Some("old@example.com".to_string()),
                new_value: "new@example.com".to_string(),
            },
            AccountChange {
                field: "Username".to_string(),
                old_value: Some("olduser".to_string()),
                new_value: "newuser".to_string(),
            },
        ];

        let template = AccountModifiedTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "2024-01-15 14:30:00".to_string(),
            changes,
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Compte modifié"));
        assert!(text.contains("testuser"));
        assert!(text.contains("old@example.com"));
        assert!(text.contains("new@example.com"));
    }
}

/// Admin new user notification template
///
/// This template generates both HTML and plain text versions of the admin new user notification email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/admin_new_user.html", escape = "html")]
pub struct AdminNewUserTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the newly registered user
    pub username: String,
    /// Email of the newly registered user
    pub email: String,
    /// Date and time when user registered (formatted)
    pub registered_at: String,
    /// Current year for footer
    pub current_year: i32,
}

impl AdminNewUserTemplate {
    /// Create a new admin new user notification template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the newly registered user
    /// * `email` - Email of the newly registered user
    /// * `registered_at` - Date and time when user registered (formatted string)
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        email: String,
        registered_at: String,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            email,
            registered_at,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        if self.locale == "fr" {
            format!(
                r#"
Nouvel utilisateur inscrit

Bonjour,

Un nouvel utilisateur s'est inscrit sur {}.

Informations de l'utilisateur :
  - Nom d'utilisateur: {}
  - Email: {}
  - Date d'inscription: {}

ℹ️ Action requise :
Vous pouvez consulter et gérer cet utilisateur depuis le panneau d'administration.

Cet email a été envoyé automatiquement aux administrateurs.
Si vous avez des questions, contactez le support technique.

© {} {}. Tous droits réservés.
                "#,
                self.app_name,
                self.username,
                self.email,
                self.registered_at,
                self.current_year,
                self.app_name
            )
        } else {
            format!(
                r#"
New User Registered

Hello,

A new user has registered on {}.

User Information:
  - Username: {}
  - Email: {}
  - Registration Date: {}

ℹ️ Action Required:
You can view and manage this user from the admin panel.

This email was sent automatically to administrators.
If you have any questions, please contact technical support.

© {} {}. All rights reserved.
                "#,
                self.app_name,
                self.username,
                self.email,
                self.registered_at,
                self.current_year,
                self.app_name
            )
        }
    }
}

#[cfg(test)]
mod admin_new_user_template_tests {
    use super::*;

    #[test]
    fn test_admin_new_user_template_creation() {
        let template = AdminNewUserTemplate::new(
            Some("fr".to_string()),
            "newuser".to_string(),
            "newuser@example.com".to_string(),
            "2024-01-15 14:30:00".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "newuser");
        assert_eq!(template.email, "newuser@example.com");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
    }

    #[test]
    fn test_admin_new_user_template_render_text_fr() {
        let template = AdminNewUserTemplate::new(
            Some("fr".to_string()),
            "newuser".to_string(),
            "newuser@example.com".to_string(),
            "2024-01-15 14:30:00".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Nouvel utilisateur inscrit"));
        assert!(text.contains("newuser"));
        assert!(text.contains("newuser@example.com"));
    }

    #[test]
    fn test_admin_new_user_template_render_text_en() {
        let template = AdminNewUserTemplate::new(
            Some("en".to_string()),
            "newuser".to_string(),
            "newuser@example.com".to_string(),
            "2024-01-15 14:30:00".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("New User Registered"));
        assert!(text.contains("newuser"));
        assert!(text.contains("newuser@example.com"));
    }
}

/// Welcome email template
///
/// This template generates both HTML and plain text versions of the welcome email.
/// Supports i18n (French/English) based on the locale parameter.
#[derive(Template)]
#[template(path = "emails/welcome.html", escape = "html")]
pub struct WelcomeEmailTemplate {
    /// Locale for i18n (default: "fr", supported: "fr", "en")
    pub locale: String,
    /// Application name
    pub app_name: String,
    /// Username of the user
    pub username: String,
    /// Login URL for the user
    pub login_url: String,
    /// Current year for footer
    pub current_year: i32,
}

impl WelcomeEmailTemplate {
    /// Create a new welcome email template
    ///
    /// # Arguments
    ///
    /// * `locale` - Locale for i18n ("fr" or "en", defaults to "fr")
    /// * `username` - Username of the user
    /// * `login_url` - Login URL for the user
    /// * `app_name` - Application name (defaults to "RustWarden")
    ///
    /// # Returns
    ///
    /// New template instance
    pub fn new(
        locale: Option<String>,
        username: String,
        login_url: String,
        app_name: Option<String>,
    ) -> Self {
        Self {
            locale: locale.unwrap_or_else(|| "fr".to_string()),
            app_name: app_name.unwrap_or_else(crate::config::app::default_app_name),
            username,
            login_url,
            current_year: chrono::Utc::now().year(),
        }
    }

    /// Render HTML version of the email
    ///
    /// # Returns
    ///
    /// HTML content as String
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render plain text version of the email
    ///
    /// # Returns
    ///
    /// Plain text content as String
    pub fn render_text(&self) -> String {
        // Generate plain text version from template data
        if self.locale == "fr" {
            format!(
                r#"
Bienvenue sur {} !

Bonjour {},

Nous sommes ravis de vous accueillir sur {} ! Votre compte a été activé avec succès.

Vous pouvez maintenant profiter de toutes les fonctionnalités de la plateforme.

Pour commencer, connectez-vous ici : {}

Ce que vous pouvez faire :
✓ Accéder à votre tableau de bord personnalisé
✓ Gérer votre profil et vos préférences
✓ Profiter de toutes les fonctionnalités disponibles

ℹ️ Besoin d'aide ?
Si vous avez des questions ou besoin d'assistance, n'hésitez pas à consulter notre documentation ou à contacter le support technique.

Merci de nous faire confiance et bonne utilisation de {} !

© {} {}. Tous droits réservés.
                "#,
                self.app_name,
                self.username,
                self.app_name,
                self.login_url,
                self.app_name,
                self.current_year,
                self.app_name
            )
        } else {
            format!(
                r#"
Welcome to {}!

Hello {},

We are delighted to welcome you to {}! Your account has been successfully activated.

You can now enjoy all the platform's features.

To get started, log in here: {}

What you can do:
✓ Access your personalized dashboard
✓ Manage your profile and preferences
✓ Enjoy all available features

ℹ️ Need Help?
If you have any questions or need assistance, feel free to check our documentation or contact technical support.

Thank you for trusting us and enjoy using {}!

© {} {}. All rights reserved.
                "#,
                self.app_name,
                self.username,
                self.app_name,
                self.login_url,
                self.app_name,
                self.current_year,
                self.app_name
            )
        }
    }
}

#[cfg(test)]
mod welcome_email_template_tests {
    use super::*;

    #[test]
    fn test_welcome_email_template_creation() {
        let template = WelcomeEmailTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/login".to_string(),
            None,
        );

        assert_eq!(template.locale, "fr");
        assert_eq!(template.username, "testuser");
        assert_eq!(template.app_name, crate::config::app::default_app_name());
    }

    #[test]
    fn test_welcome_email_template_render_text_fr() {
        let template = WelcomeEmailTemplate::new(
            Some("fr".to_string()),
            "testuser".to_string(),
            "https://example.com/login".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Bienvenue"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/login"));
    }

    #[test]
    fn test_welcome_email_template_render_text_en() {
        let template = WelcomeEmailTemplate::new(
            Some("en".to_string()),
            "testuser".to_string(),
            "https://example.com/login".to_string(),
            None,
        );

        let text = template.render_text();
        assert!(text.contains("Welcome"));
        assert!(text.contains("testuser"));
        assert!(text.contains("https://example.com/login"));
    }
}

// ── Email change (RFC 0041) ─────────────────────────────────────────────────
//
// Unlike the older templates above, these four carry **already translated**
// fields: the service resolves every string through `I18nService` (or its
// embedded fallbacks) before building the struct. Six locales are therefore
// supported without a single `{% if locale == … %}` branch, and a headless
// consumer without any locale file still gets a complete email.

/// Confirmation email sent to the **candidate** address (RFC 0041).
///
/// Carries the only link that can apply the change. All textual fields are
/// pre-translated by [`EmailChangeService`](crate::services::email_change_service::EmailChangeService).
#[derive(Template)]
#[template(path = "emails/email_change_validation.html", escape = "html")]
pub struct EmailChangeValidationTemplate {
    /// Recipient locale, emitted as the document `lang` attribute.
    pub locale: String,
    /// Application name shown in the header and footer.
    pub app_name: String,
    /// Translated subject/title.
    pub title: String,
    /// Translated greeting line.
    pub greeting: String,
    /// Translated introduction explaining the pending request.
    pub intro: String,
    /// Translated validity notice.
    pub expires: String,
    /// Translated "ignore this message" notice.
    pub ignore_notice: String,
    /// Translated call-to-action label.
    pub button_label: String,
    /// Absolute URL of the confirmation page (`GET` interstitial).
    pub confirm_url: String,
    /// Translated "button not working?" label.
    pub fallback_label: String,
    /// Translated footer line.
    pub footer: String,
    /// Current year for the copyright line.
    pub current_year: i32,
}

impl EmailChangeValidationTemplate {
    /// Render the HTML body.
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails.
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render the plain-text body from the same pre-translated fields.
    pub fn render_text(&self) -> String {
        format!(
            "{}\n\n{}\n\n{}\n\n{}\n{}\n\n{}\n{}\n\n{}\n© {} {}\n",
            self.title,
            self.greeting,
            self.intro,
            self.button_label,
            self.confirm_url,
            self.expires,
            self.ignore_notice,
            self.footer,
            self.current_year,
            self.app_name,
        )
    }
}

/// Notice sent to the **current** address when a change is requested (RFC 0041).
///
/// Never carries the confirmation link: the only link here opens the report
/// page. The candidate address appears masked.
#[derive(Template)]
#[template(path = "emails/email_change_notice.html", escape = "html")]
pub struct EmailChangeNoticeTemplate {
    /// Recipient locale, emitted as the document `lang` attribute.
    pub locale: String,
    /// Application name shown in the header and footer.
    pub app_name: String,
    /// Translated subject/title.
    pub title: String,
    /// Translated greeting line.
    pub greeting: String,
    /// Translated introduction, carrying the **masked** candidate address.
    pub intro: String,
    /// Translated reminder that the account itself has not changed.
    pub unchanged: String,
    /// Translated invitation to report the request.
    pub report_intro: String,
    /// Translated call-to-action label.
    pub button_label: String,
    /// Absolute URL of the report page (`GET` interstitial).
    pub report_url: String,
    /// Translated "button not working?" label.
    pub fallback_label: String,
    /// Translated footer line.
    pub footer: String,
    /// Current year for the copyright line.
    pub current_year: i32,
}

impl EmailChangeNoticeTemplate {
    /// Render the HTML body.
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails.
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render the plain-text body from the same pre-translated fields.
    pub fn render_text(&self) -> String {
        format!(
            "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n{}\n\n{}\n© {} {}\n",
            self.title,
            self.greeting,
            self.intro,
            self.unchanged,
            self.report_intro,
            self.button_label,
            self.report_url,
            self.footer,
            self.current_year,
            self.app_name,
        )
    }
}

/// Alert sent to every administrator when a user reports a request (RFC 0041).
///
/// Both addresses are masked: the durable trail lives in `audit_logs`, an
/// operator mailbox does not need the clear-text values.
#[derive(Template)]
#[template(path = "emails/email_change_reported_admin.html", escape = "html")]
pub struct EmailChangeReportedAdminTemplate {
    /// Recipient locale, emitted as the document `lang` attribute.
    pub locale: String,
    /// Application name shown in the header and footer.
    pub app_name: String,
    /// Translated subject/title.
    pub title: String,
    /// Translated introduction naming the reporting user.
    pub intro: String,
    /// Translated details line: masked addresses and report timestamp.
    pub details: String,
    /// Translated reminder that the change is **not** blocked by the report.
    pub not_blocked: String,
    /// Translated footer line.
    pub footer: String,
    /// Current year for the copyright line.
    pub current_year: i32,
}

impl EmailChangeReportedAdminTemplate {
    /// Render the HTML body.
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails.
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render the plain-text body from the same pre-translated fields.
    pub fn render_text(&self) -> String {
        format!(
            "{}\n\n{}\n\n{}\n\n{}\n\n{}\n© {} {}\n",
            self.title,
            self.intro,
            self.details,
            self.not_blocked,
            self.footer,
            self.current_year,
            self.app_name,
        )
    }
}

/// Notification of an operator-forced change (RFC 0041).
///
/// Sent to **both** the previous and the new address by
/// [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change).
/// Carries no action link: there is nothing left to confirm or report.
#[derive(Template)]
#[template(path = "emails/email_change_by_admin.html", escape = "html")]
pub struct EmailChangeByAdminTemplate {
    /// Recipient locale, emitted as the document `lang` attribute.
    pub locale: String,
    /// Application name shown in the header and footer.
    pub app_name: String,
    /// Translated subject/title.
    pub title: String,
    /// Translated greeting line.
    pub greeting: String,
    /// Translated introduction stating an administrator made the change.
    pub intro: String,
    /// Translated details line with both **masked** addresses.
    pub details: String,
    /// Translated support notice.
    pub support: String,
    /// Translated footer line.
    pub footer: String,
    /// Current year for the copyright line.
    pub current_year: i32,
}

impl EmailChangeByAdminTemplate {
    /// Render the HTML body.
    ///
    /// # Errors
    ///
    /// Returns an error if template rendering fails.
    pub fn render_html(&self) -> Result<String, askama::Error> {
        self.render()
    }

    /// Render the plain-text body from the same pre-translated fields.
    pub fn render_text(&self) -> String {
        format!(
            "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n© {} {}\n",
            self.title,
            self.greeting,
            self.intro,
            self.details,
            self.support,
            self.footer,
            self.current_year,
            self.app_name,
        )
    }
}

#[cfg(test)]
mod email_change_template_tests {
    use super::*;

    /// The six locales shipped with the framework.
    const LOCALES: [&str; 6] = ["de", "en", "es", "fr", "it", "pt"];

    fn validation(locale: &str) -> EmailChangeValidationTemplate {
        EmailChangeValidationTemplate {
            locale: locale.to_string(),
            app_name: "Rustwarden".to_string(),
            title: "Confirm your new email address".to_string(),
            greeting: "Hello alice,".to_string(),
            intro: "A request was opened.".to_string(),
            expires: "Valid for 24 hour(s).".to_string(),
            ignore_notice: "Ignore this message if you did not ask.".to_string(),
            button_label: "Open the confirmation page".to_string(),
            confirm_url: "https://example.test/api/v1/auth/email-change/confirm/abc".to_string(),
            fallback_label: "Button not working?".to_string(),
            footer: "Sent automatically.".to_string(),
            current_year: 2026,
        }
    }

    fn notice(locale: &str) -> EmailChangeNoticeTemplate {
        EmailChangeNoticeTemplate {
            locale: locale.to_string(),
            app_name: "Rustwarden".to_string(),
            title: "Email-change request".to_string(),
            greeting: "Hello alice,".to_string(),
            intro: "A request to move your account to ne***@ex***.test was opened.".to_string(),
            unchanged: "Your account has NOT changed.".to_string(),
            report_intro: "Alert the administrators if this is not you.".to_string(),
            button_label: "Open the report page".to_string(),
            report_url: "https://example.test/api/v1/auth/email-change/report/xyz".to_string(),
            fallback_label: "Button not working?".to_string(),
            footer: "Sent automatically.".to_string(),
            current_year: 2026,
        }
    }

    fn reported_admin(locale: &str) -> EmailChangeReportedAdminTemplate {
        EmailChangeReportedAdminTemplate {
            locale: locale.to_string(),
            app_name: "Rustwarden".to_string(),
            title: "Email change reported".to_string(),
            intro: "User alice reported a request.".to_string(),
            details: "Current: al***@ex***.test — requested: ne***@ex***.test.".to_string(),
            not_blocked: "The change is NOT blocked.".to_string(),
            footer: "Sent automatically.".to_string(),
            current_year: 2026,
        }
    }

    fn by_admin(locale: &str) -> EmailChangeByAdminTemplate {
        EmailChangeByAdminTemplate {
            locale: locale.to_string(),
            app_name: "Rustwarden".to_string(),
            title: "Email address changed by an administrator".to_string(),
            greeting: "Hello alice,".to_string(),
            intro: "An administrator changed your address.".to_string(),
            details: "Previous: al***@ex***.test — new: ne***@ex***.test.".to_string(),
            support: "Nothing to confirm on your side.".to_string(),
            footer: "Sent automatically.".to_string(),
            current_year: 2026,
        }
    }

    #[test]
    fn the_four_templates_render_in_every_locale() {
        for locale in LOCALES {
            let html = validation(locale).render_html().unwrap();
            assert!(html.contains(&format!("lang=\"{locale}\"")));
            assert!(!validation(locale).render_text().is_empty());

            let html = notice(locale).render_html().unwrap();
            assert!(html.contains(&format!("lang=\"{locale}\"")));
            assert!(!notice(locale).render_text().is_empty());

            let html = reported_admin(locale).render_html().unwrap();
            assert!(html.contains(&format!("lang=\"{locale}\"")));
            assert!(!reported_admin(locale).render_text().is_empty());

            let html = by_admin(locale).render_html().unwrap();
            assert!(html.contains(&format!("lang=\"{locale}\"")));
            assert!(!by_admin(locale).render_text().is_empty());
        }
    }

    /// The whole point of the two-mail design: the address that still owns the
    /// account is told what is happening, but is never handed the secret that
    /// would apply the change.
    #[test]
    fn only_the_candidate_address_ever_receives_the_confirmation_link() {
        let notice_html = notice("en").render_html().unwrap();
        let notice_text = notice("en").render_text();
        for body in [&notice_html, &notice_text] {
            assert!(
                !body.contains("email-change/confirm/"),
                "the notice sent to the current address must not carry the confirmation link"
            );
            assert!(body.contains("email-change/report/"));
        }

        let validation_html = validation("en").render_html().unwrap();
        assert!(validation_html.contains("email-change/confirm/"));
        assert!(!validation_html.contains("email-change/report/"));
    }

    /// An operator-driven change is already done when the mail goes out: a link
    /// in it could only mislead its reader into thinking something is pending.
    #[test]
    fn the_admin_notification_carries_no_actionable_link() {
        let html = by_admin("en").render_html().unwrap();
        assert!(!html.contains("email-change/confirm/"));
        assert!(!html.contains("email-change/report/"));
        assert!(!by_admin("en").render_text().contains("email-change/"));
    }

    /// Fields are pre-translated by the service, so a hostile display name
    /// reaching a translation placeholder must not become markup.
    #[test]
    fn pre_translated_fields_are_escaped() {
        let mut template = validation("en");
        template.greeting = "Hello <script>alert(1)</script>,".to_string();
        let html = template.render_html().unwrap();
        assert!(!html.contains("<script>alert(1)</script>"));
        // Askama emits numeric entities (`&#60;`), not named ones.
        assert!(html.contains("&#60;script&#62;"));
    }
}