email_change_service.rs 2988 lignes · 114783 octets
//! Two-step email change service (RFC 0041).
//!
//! Nothing is ever written to `users.email` before possession of the candidate
//! address is proven. A request lands in `pending_email_changes` (hashed
//! tokens + expiration), the account keeps its current — verified — address,
//! and the previous mailbox is informed with a report link.
//!
//! The only path that writes an address without that proof is
//! [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change),
//! restricted to the `admin` role
//! and traced in `audit_logs`.

use crate::config::email::EmailConfig;
use crate::error::AppError;
use crate::hooks::{AuditEvent, RustwardenHooks};
use crate::i18n::service::I18nService;
use crate::models::{audit_log, pending_email_change, user};
use crate::services::auth_service::AuthService;
use crate::services::email_queue_service::EmailQueueService;
use crate::services::email_sanitization::{mask_email, sanitize_email_content};
use crate::services::password_change_service::PasswordChangeService;
use crate::services::rate_limit_service::RateLimitService;
use crate::services::refresh_token_service::RefreshTokenService;
use crate::services::user_preferences_service::UserPreferencesService;
use crate::services::user_service::UserService;
use crate::templates::email_templates::{
    EmailChangeByAdminTemplate, EmailChangeNoticeTemplate, EmailChangeReportedAdminTemplate,
    EmailChangeValidationTemplate,
};
use chrono::{Datelike, Duration, Utc};
use sea_orm::{
    ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, NotSet, QueryFilter,
    Set,
};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration as StdDuration;
use uuid::Uuid;

/// Default: 3 email-change requests per user per hour.
const EMAIL_CHANGE_RATE_LIMIT_MAX: u32 = 3;
/// Sliding window backing [`EMAIL_CHANGE_RATE_LIMIT_MAX`].
const EMAIL_CHANGE_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);

/// Retention of `pending_email_changes` rows, in days after expiration.
///
/// Compile-time constant on purpose: RFC 0041 adds **no** environment
/// variable. Past this delay a row carries no operational value — the durable
/// trail lives in `audit_logs` with masked addresses — while `new_email` is
/// personal data kept in clear text.
pub const PENDING_EMAIL_CHANGE_RETENTION_DAYS: i64 = 30;

/// Error surfaced when the candidate address was claimed by another account
/// between the request and its confirmation.
const EMAIL_NO_LONGER_AVAILABLE: &str =
    "This email address is no longer available. Request the change again with a different address.";

/// Process-wide sliding-window limiter for email-change requests.
///
/// Distinct from the password-reset and email-validation limiters so the three
/// flows keep separate budgets.
static EMAIL_CHANGE_LIMITER: OnceLock<RateLimitService> = OnceLock::new();

/// Return the process-wide email-change limiter, initializing it once.
fn shared_email_change_limiter() -> RateLimitService {
    EMAIL_CHANGE_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-change limiter: no tokio runtime, cleanup_loop not started");
            }
            limiter
        })
        .clone()
}

/// Read-only state of a link token, as reported to an interstitial page.
///
/// Returned by [`EmailChangeService::inspect_confirmation_token`] and
/// [`EmailChangeService::inspect_report_token`] so a `GET` can display a
/// useful message **without any side effect**: a consumer replacing the
/// built-in pages with its own reuses the same read-only check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailChangeLinkState {
    /// The token matches a pending request: the form may be displayed.
    Usable,
    /// No request carries this token.
    Unknown,
    /// The request exists but its expiration has passed.
    Expired,
    /// The request exists but has already been applied.
    Used,
}

/// Two-step email change service.
///
/// Nothing is written to `users.email` until the confirmation link mailed to
/// the candidate address is followed. A typo therefore expires harmlessly and
/// the account never loses access.
///
/// ```rust,ignore
/// use rustwarden_core::config::email::EmailConfig;
/// use rustwarden_core::services::email_change_service::EmailChangeService;
///
/// let email_config = EmailConfig::from_env().map_err(AppError::Config)?;
/// EmailChangeService::new(email_config)
///     .request_change(&db, user_id, "new@example.com", current_password)
///     .await?;
/// // `users.email` is unchanged; two emails are queued.
/// ```
pub struct EmailChangeService {
    /// SMTP / queue configuration used to build the outgoing messages.
    email_config: EmailConfig,
    /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
    rate_limiter: RateLimitService,
    /// Optional translation backend. When `None`, emails fall back to the
    /// strings embedded in this module, so the service stays usable headless
    /// without any i18n plumbing.
    i18n: Option<Arc<I18nService>>,
}

impl EmailChangeService {
    /// Create a service using the process-wide rate limiter.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// let service = EmailChangeService::new(EmailConfig::from_env().map_err(AppError::Config)?);
    /// ```
    pub fn new(email_config: EmailConfig) -> Self {
        Self {
            email_config,
            rate_limiter: shared_email_change_limiter(),
            i18n: None,
        }
    }

    /// Attach the translation service so emails honour the recipient locale.
    ///
    /// The recipient locale is read from `user_preferences.language` and falls
    /// back to [`config::tasks::default_locale`](crate::config::tasks::default_locale).
    /// Missing keys fall back to the strings embedded in this module.
    ///
    /// ```rust,ignore
    /// use std::sync::Arc;
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// let service = EmailChangeService::new(email_config).with_i18n(Arc::clone(&i18n_service));
    /// ```
    pub fn with_i18n(mut self, i18n: Arc<I18nService>) -> Self {
        self.i18n = Some(i18n);
        self
    }

    /// 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,
            i18n: None,
        }
    }

    /// Open an email-change request for an authenticated user.
    ///
    /// Re-authentication is mandatory: a stolen session alone cannot move the
    /// account's identity. `current_password` is checked through
    /// [`AuthService::verify_credentials`](crate::services::auth_service::AuthService::verify_credentials),
    /// which runs bcrypt in constant time.
    ///
    /// On success two emails are queued:
    /// - a confirmation link to `new_email`;
    /// - a notice plus a report link to the account's **current** address.
    ///
    /// `users.email`, `email_verified` and `email_verified_at` are left
    /// untouched. Unlike the v1 flow, queueing is **not** best-effort: without
    /// the emails the request is unusable, so the error is propagated. This is
    /// safe precisely because nothing was written to `users`.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// EmailChangeService::new(email_config)
    ///     .request_change(&db, user_id, "new@example.com", "current-password")
    ///     .await?;
    /// ```
    ///
    /// # Errors
    ///
    /// - [`AppError::TooManyRequests`] — more than 3 requests for this user
    ///   within the hour.
    /// - [`AppError::InvalidCredentials`] — wrong `current_password`.
    /// - [`AppError::Validation`] — malformed address, address identical to the
    ///   current one, or address already taken by another account.
    /// - [`AppError::NotFound`] — unknown `user_id`.
    pub async fn request_change(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
        new_email: &str,
        current_password: &str,
    ) -> Result<(), AppError> {
        // 1. Per-user budget: the abuse to bound is "one account spamming
        //    third-party mailboxes", hence the user id as key.
        self.rate_limiter.check_sliding_window(
            &format!("email_change:{user_id}"),
            EMAIL_CHANGE_RATE_LIMIT_MAX,
            EMAIL_CHANGE_RATE_LIMIT_WINDOW,
        )?;

        // 2. Target account.
        let user = UserService::get_user_by_id(db, user_id)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

        // 3. Re-authentication. The current address always contains '@', so the
        //    email branch of `verify_credentials` is taken deterministically,
        //    even for legacy usernames containing '@'. `verify_credentials` —
        //    not `authenticate_user` — is used on purpose: the RFC 0040
        //    `validation_email_required` policy does not apply here.
        AuthService::verify_credentials(db, &user.email, current_password).await?;

        // 4. Normalisation and checks.
        let trimmed = new_email.trim();
        if !trimmed.contains('@') || !trimmed.contains('.') {
            return Err(AppError::Validation("Invalid email format".to_string()));
        }
        if trimmed == user.email.as_str() {
            return Err(AppError::Validation(
                "New email address is identical to the current one".to_string(),
            ));
        }
        if let Some(existing) = UserService::get_user_by_email(db, trimmed).await? {
            if existing.id != user_id {
                return Err(AppError::Validation(format!(
                    "Email '{trimmed}' already exists"
                )));
            }
        }

        // 5. One active request at a time, plus an opportunistic retention
        //    purge so a consumer that never starts the cleanup processor still
        //    honours the retention window for active accounts.
        self.invalidate_previous_requests(db, user_id).await?;
        Self::purge_stale_requests_for_user(db, user_id).await?;

        // 6. Two independent secrets: confirming and reporting are two
        //    different capabilities held by two different mailboxes.
        let token = Uuid::new_v4().to_string();
        let report_token = Uuid::new_v4().to_string();
        let now = Utc::now();
        let expires_at =
            now + Duration::hours(crate::config::tasks::email_validation_expiration_hours());

        // 7. Persist the request.
        pending_email_change::ActiveModel {
            id: Set(Uuid::new_v4()),
            user_id: Set(user_id),
            new_email: Set(trimmed.to_string()),
            token_hash: Set(Self::hash_token(&token)),
            report_token_hash: Set(Self::hash_token(&report_token)),
            expires_at: Set(expires_at),
            used_at: NotSet,
            reported_at: NotSet,
            created_at: Set(now),
        }
        .insert(db)
        .await?;

        // 8. Both notifications. Errors propagate: a request nobody can
        //    confirm is worthless, and the orphan row expires harmlessly.
        let locale = Self::locale_for(db, user_id).await;
        let confirm_url = format!(
            "{}/api/v1/auth/email-change/confirm/{}",
            self.email_config.email_base_url, token
        );
        let report_url = format!(
            "{}/api/v1/auth/email-change/report/{}",
            self.email_config.email_base_url, report_token
        );
        let hours = crate::config::tasks::email_validation_expiration_hours();

        // Confirmation to the CANDIDATE address.
        let (subject, body_html, body_text) = self
            .build_validation_email(&locale, &user.username, &confirm_url, hours)
            .await;
        EmailQueueService::enqueue(
            db,
            trimmed.to_string(),
            Some(user.username.clone()),
            self.email_config.smtp_from.clone(),
            self.email_config.smtp_from_name.clone(),
            subject,
            body_html,
            body_text,
            "email_change_validation".to_string(),
        )
        .await?;

        // Notice + report link to the CURRENT address. It never carries the
        // confirmation link, and the candidate address is masked.
        let (subject, body_html, body_text) = self
            .build_notice_email(&locale, &user.username, &report_url, trimmed)
            .await;
        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_change_notice".to_string(),
        )
        .await?;

        tracing::info!(
            user_id = %user_id,
            candidate = %mask_email(trimmed),
            "Email-change request opened; users.email left untouched"
        );

        Ok(())
    }

    /// Apply a pending change from its confirmation token.
    ///
    /// Confirming from the page the emailed link opens **is** the proof of
    /// possession, so the address lands already verified: `users.email =
    /// new_email`, `email_verified = true`, `email_verified_at = now()`.
    /// Uniqueness is re-checked here because another account may have claimed
    /// the address in the meantime.
    ///
    /// All refresh tokens are revoked (same contract as
    /// [`UserService::change_password_direct`](crate::services::user_service::UserService::change_password_direct)),
    /// and any pending password-change request is dropped through
    /// [`PasswordChangeService::invalidate_on_email_change`](crate::services::password_change_service::PasswordChangeService::invalidate_on_email_change):
    /// a confirmation link mailed to the previous address must never be usable
    /// once that address no longer belongs to the account.
    ///
    /// Hooks fired here, best-effort:
    /// [`RustwardenHooks::on_email_verified`] (the click *is* the proof of
    /// possession) and [`RustwardenHooks::on_audit_event`] with kind
    /// `user.email_changed`.
    ///
    /// Returns the user id whose address changed.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// let user_id = EmailChangeService::new(email_config)
    ///     .confirm_change(&db, token, hooks.as_ref())
    ///     .await?;
    /// ```
    ///
    /// # Errors
    ///
    /// - [`AppError::Validation`] — unknown, expired or already-used token, or
    ///   the candidate address is no longer available.
    /// - [`AppError::NotFound`] — the user was deleted since the request.
    pub async fn confirm_change(
        &self,
        db: &DatabaseConnection,
        token: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<Uuid, AppError> {
        // 1. Lookup by hash — the plain token is never stored.
        let token_hash = Self::hash_token(token);
        let row = pending_email_change::Entity::find()
            .filter(pending_email_change::Column::TokenHash.eq(&token_hash))
            .one(db)
            .await?
            .ok_or_else(|| AppError::Validation("Invalid email-change token".to_string()))?;

        // 2 & 3. Single use, then expiration.
        if row.is_used() {
            return Err(AppError::Validation(
                "This email-change link has already been used".to_string(),
            ));
        }
        if row.is_expired() {
            return Err(AppError::Validation(
                "This email-change link has expired".to_string(),
            ));
        }

        // 4. `reported_at` is deliberately NOT a gate: a report alerts, it
        //    never refuses (see the RFC's dedicated section).

        // 5. Uniqueness may have been lost since the request was opened. The
        //    row is NOT consumed, so the link keeps working if the address is
        //    released before expiration.
        if let Some(existing) = UserService::get_user_by_email(db, &row.new_email).await? {
            if existing.id != row.user_id {
                return Err(AppError::Validation(EMAIL_NO_LONGER_AVAILABLE.to_string()));
            }
        }

        let user = UserService::get_user_by_id(db, row.user_id)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
        let previous_email = user.email.clone();
        let username = user.username.clone();
        let now = Utc::now();

        // 6. Write the proven address, already verified.
        let mut user_active: user::ActiveModel = user.into();
        user_active.email = Set(row.new_email.clone());
        user_active.email_verified = Set(true);
        user_active.email_verified_at = Set(Some(now));
        user_active.updated_at = Set(now);
        user_active
            .update(db)
            .await
            .map_err(Self::map_email_write_error)?;

        // 7. Consume the request.
        let mut row_active: pending_email_change::ActiveModel = row.clone().into();
        row_active.used_at = Set(Some(now));
        row_active.update(db).await?;

        // 8. The login identity just moved: every session re-authenticates.
        RefreshTokenService::revoke_all_tokens_for_user(db, row.user_id).await?;

        // 9. A password-change link mailed to the previous address must not
        //    survive the address it was sent to. Logged, never fatal: the
        //    address change is already written.
        if let Err(e) = PasswordChangeService::new()
            .invalidate_on_email_change(db, row.user_id)
            .await
        {
            tracing::warn!(
                user_id = %row.user_id,
                "Failed to invalidate pending password-change requests after email change: {}",
                e
            );
        }

        // 10. Durable audit trail, masked addresses only.
        let message = format!(
            "user={} old={} new={}",
            username,
            mask_email(&previous_email),
            mask_email(&row.new_email)
        );
        Self::write_audit_log(
            db,
            "email_changed",
            "INFO",
            Some(row.user_id),
            None,
            message,
        )
        .await;

        // 11. Hooks, dispatched from the service so headless consumers mounting
        //     their own confirmation route get the same events.
        if let Some(hooks) = hooks {
            if let Err(e) = hooks.on_email_verified(db, row.user_id).await {
                tracing::warn!("RustwardenHooks::on_email_verified failed: {}", e);
            }
            let metadata = serde_json::json!({
                "previous_email": mask_email(&previous_email),
                "new_email": mask_email(&row.new_email),
            });
            let event = AuditEvent {
                // Actor and target are the same account here — this is a
                // self-service change — but the target is still named, so a
                // consumer routing events on `target` sees the self-service
                // path alongside `user.email_changed_by_admin`.
                kind: "user.email_changed",
                actor_id: Some(row.user_id),
                target: Some(("user", row.user_id)),
                metadata: &metadata,
            };
            if let Err(e) = hooks.on_audit_event(db, event).await {
                tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
            }
        }

        Ok(row.user_id)
    }

    /// Record that the current address owner flagged a request as suspicious.
    ///
    /// Writes an `audit_logs` row, emits
    /// [`RustwardenHooks::on_audit_event`] with kind `user.email_change_reported`
    /// and queues an alert to every administrator.
    ///
    /// **This never blocks the change.** A report is a signal for operators,
    /// not an authorisation gate: turning it into one would hand any reader of
    /// the old mailbox a denial-of-service lever over the account owner.
    ///
    /// Idempotent: a request already reported returns `Ok` without re-notifying.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// let user_id = EmailChangeService::new(email_config)
    ///     .report_suspicious(&db, report_token, hooks.as_ref())
    ///     .await?;
    /// ```
    ///
    /// # Errors
    ///
    /// - [`AppError::Validation`] — unknown report token.
    pub async fn report_suspicious(
        &self,
        db: &DatabaseConnection,
        report_token: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<Uuid, AppError> {
        // 1. Lookup by the *report* hash: a different secret than the one
        //    mailed to the candidate address.
        let report_token_hash = Self::hash_token(report_token);
        let row = pending_email_change::Entity::find()
            .filter(pending_email_change::Column::ReportTokenHash.eq(&report_token_hash))
            .one(db)
            .await?
            .ok_or_else(|| AppError::Validation("Invalid report token".to_string()))?;

        // 2. Idempotence — double click, page refresh or browser form resend.
        if row.is_reported() {
            return Ok(row.user_id);
        }

        // 3. Flag it, even when expired or already consumed: the signal keeps
        //    its value for the operator.
        let now = Utc::now();
        let user_id = row.user_id;
        let candidate = row.new_email.clone();
        let mut row_active: pending_email_change::ActiveModel = row.into();
        row_active.reported_at = Set(Some(now));
        row_active.update(db).await?;

        // 4. Audit trail.
        let message = format!("user_id={} candidate={}", user_id, mask_email(&candidate));
        Self::write_audit_log(
            db,
            "email_change_reported",
            "WARN",
            None,
            Some(user_id),
            message,
        )
        .await;

        // 5. Hook fan-out.
        if let Some(hooks) = hooks {
            let metadata = serde_json::json!({
                "user_id": user_id.to_string(),
                "candidate_email": mask_email(&candidate),
            });
            let event = AuditEvent {
                kind: "user.email_change_reported",
                actor_id: None,
                target: Some(("user", user_id)),
                metadata: &metadata,
            };
            if let Err(e) = hooks.on_audit_event(db, event).await {
                tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
            }
        }

        // 6. Alert every administrator. Best-effort: the report is already
        //    recorded, a mail failure must not turn it into an error.
        self.notify_admins_of_report(db, user_id, &candidate).await;

        Ok(user_id)
    }

    /// Read-only state of a **confirmation** token.
    ///
    /// Performs a single indexed `SELECT` and writes nothing: this is what
    /// makes `GET /api/v1/auth/email-change/confirm/{token}` immune to the
    /// link prefetching done by anti-phishing gateways and mail clients.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::{
    ///     EmailChangeLinkState, EmailChangeService,
    /// };
    ///
    /// match EmailChangeService::inspect_confirmation_token(&db, &token).await? {
    ///     EmailChangeLinkState::Usable => { /* render the confirm form */ }
    ///     _ => { /* render an explanation, no form */ }
    /// }
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`AppError::Database`] if the lookup fails.
    pub async fn inspect_confirmation_token(
        db: &DatabaseConnection,
        token: &str,
    ) -> Result<EmailChangeLinkState, AppError> {
        let token_hash = Self::hash_token(token);
        let row = pending_email_change::Entity::find()
            .filter(pending_email_change::Column::TokenHash.eq(&token_hash))
            .one(db)
            .await?;
        Ok(Self::state_of(row.as_ref()))
    }

    /// Read-only state of a **report** token. Same no-side-effect contract as
    /// [`EmailChangeService::inspect_confirmation_token`].
    ///
    /// ```rust,ignore
    /// let state = EmailChangeService::inspect_report_token(&db, &report_token).await?;
    /// ```
    ///
    /// # Errors
    ///
    /// Returns [`AppError::Database`] if the lookup fails.
    pub async fn inspect_report_token(
        db: &DatabaseConnection,
        report_token: &str,
    ) -> Result<EmailChangeLinkState, AppError> {
        let token_hash = Self::hash_token(report_token);
        let row = pending_email_change::Entity::find()
            .filter(pending_email_change::Column::ReportTokenHash.eq(&token_hash))
            .one(db)
            .await?;
        Ok(Self::state_of(row.as_ref()))
    }

    /// Map a looked-up row to its link state. `Used` wins over `Expired`:
    /// "already done" is the more actionable message of the two.
    fn state_of(row: Option<&pending_email_change::Model>) -> EmailChangeLinkState {
        match row {
            None => EmailChangeLinkState::Unknown,
            Some(row) if row.is_used() => EmailChangeLinkState::Used,
            Some(row) if row.is_expired() => EmailChangeLinkState::Expired,
            Some(_) => EmailChangeLinkState::Usable,
        }
    }

    /// Administrative override: write `new_email` without confirmation.
    ///
    /// Operational recourse for the one dead end the two-step flow cannot
    /// solve on its own: a user who lost **both** the password and the access
    /// to the account's current mailbox. The v1 capability — an admin fixing
    /// an address through `PUT /admin/users/{id}` — is preserved, but it
    /// becomes an **explicit, audited operation** instead of a side effect of
    /// [`UserService::update_profile`](crate::services::user_service::UserService::update_profile).
    ///
    /// Contract:
    /// - `actor_id` **must** carry the `admin` role
    ///   ([`UserService::get_user_roles`](crate::services::user_service::UserService::get_user_roles));
    ///   otherwise [`AppError::Forbidden`] and nothing is written.
    /// - `users.email = new_email`, `email_verified = true`,
    ///   `email_verified_at = now()` — the operator vouches for the address
    ///   after an out-of-band identity check.
    /// - Every pending request of the target user is consumed, so a stale
    ///   confirmation link cannot resurrect the previous candidate address.
    /// - All refresh tokens of the **target** account are revoked, exactly as
    ///   in [`Self::confirm_change`]; the acting administrator's own sessions
    ///   are untouched.
    /// - An `audit_logs` row is written (`action = "email_changed_by_admin"`,
    ///   `level = "WARN"`, `user_id = actor_id`, `target_user_id = user_id`,
    ///   masked addresses) and [`RustwardenHooks::on_audit_event`] is emitted
    ///   with kind `user.email_changed_by_admin`.
    /// - [`RustwardenHooks::on_email_verified`] is **not** emitted: there was
    ///   no proof of possession by the address owner.
    /// - Both mailboxes are notified. Neither mail carries an action link —
    ///   there is nothing left to confirm.
    ///
    /// No rate limit and no password check: authorisation is the admin role,
    /// enforced by the caller's route middleware **and** re-checked here.
    /// Echoing the target's current address is a silent no-op, because admin
    /// forms resubmit every field.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// // `admin_id` must hold the `admin` role, or the call fails with Forbidden.
    /// EmailChangeService::new(email_config)
    ///     .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
    ///     .await?;
    /// ```
    ///
    /// # Errors
    ///
    /// - [`AppError::Forbidden`] — `actor_id` is not an administrator.
    /// - [`AppError::Validation`] — malformed address, or address already
    ///   taken by another account.
    /// - [`AppError::NotFound`] — unknown `actor_id` or unknown `user_id`.
    pub async fn admin_force_change(
        &self,
        db: &DatabaseConnection,
        actor_id: Uuid,
        user_id: Uuid,
        new_email: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<(), AppError> {
        // 1. Authorisation is re-checked in the service: a headless call
        //    straight to the service must not bypass the route middleware.
        let actor = UserService::get_user_by_id(db, actor_id)
            .await?
            .ok_or_else(|| AppError::NotFound("Actor not found".to_string()))?;
        let roles = UserService::get_user_roles(db, actor_id).await?;
        if !roles.iter().any(|role| role == "admin") {
            return Err(AppError::Forbidden);
        }

        // 2. Self-targeting is allowed: an admin changing their own address is
        //    still an admin acting as an admin, and the trail shows it.
        let target = UserService::get_user_by_id(db, user_id)
            .await?
            .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;

        // 3. Same checks as `request_change`, minus the "identical address"
        //    error which becomes a silent no-op here.
        let trimmed = new_email.trim();
        if !trimmed.contains('@') || !trimmed.contains('.') {
            return Err(AppError::Validation("Invalid email format".to_string()));
        }
        if trimmed == target.email.as_str() {
            return Ok(());
        }
        if let Some(existing) = UserService::get_user_by_email(db, trimmed).await? {
            if existing.id != user_id {
                return Err(AppError::Validation(format!(
                    "Email '{trimmed}' already exists"
                )));
            }
        }

        // 4. A still-valid confirmation link must not overwrite the address
        //    the operator just set.
        self.invalidate_previous_requests(db, user_id).await?;

        let previous_email = target.email.clone();
        let username = target.username.clone();
        let now = Utc::now();

        // 5. The verified flag is set because the operator vouches out of band,
        //    exactly like `admin_reset_password` does for the password.
        let mut target_active: user::ActiveModel = target.into();
        target_active.email = Set(trimmed.to_string());
        target_active.email_verified = Set(true);
        target_active.email_verified_at = Set(Some(now));
        target_active.updated_at = Set(now);
        target_active
            .update(db)
            .await
            .map_err(Self::map_email_write_error)?;

        // 6. Target sessions only — the acting admin keeps theirs.
        RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?;

        // 7. Same reason as in `confirm_change`.
        if let Err(e) = PasswordChangeService::new()
            .invalidate_on_email_change(db, user_id)
            .await
        {
            tracing::warn!(
                user_id = %user_id,
                "Failed to invalidate pending password-change requests after admin email change: {}",
                e
            );
        }

        // 8. WARN, not INFO: an identity write without proof of possession
        //    deserves to surface.
        let message = format!(
            "actor={} user={} old={} new={}",
            actor.username,
            username,
            mask_email(&previous_email),
            mask_email(trimmed)
        );
        Self::write_audit_log(
            db,
            "email_changed_by_admin",
            "WARN",
            Some(actor_id),
            Some(user_id),
            message,
        )
        .await;

        // 9. `on_email_verified` is deliberately NOT emitted here.
        if let Some(hooks) = hooks {
            let metadata = serde_json::json!({
                "previous_email": mask_email(&previous_email),
                "new_email": mask_email(trimmed),
            });
            let event = AuditEvent {
                kind: "user.email_changed_by_admin",
                actor_id: Some(actor_id),
                target: Some(("user", user_id)),
                metadata: &metadata,
            };
            if let Err(e) = hooks.on_audit_event(db, event).await {
                tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
            }
        }

        // 10. Both mailboxes, no action link. Best-effort: the write is done
        //     and the operator already has a synchronous return value.
        self.notify_admin_forced_change(db, user_id, &username, &previous_email, trimmed)
            .await;

        Ok(())
    }

    /// Hard-delete rows expired for longer than
    /// [`PENDING_EMAIL_CHANGE_RETENTION_DAYS`] (30 days, a compile-time
    /// constant — no environment variable).
    ///
    /// The predicate is `expires_at < now() - retention`, whatever the row
    /// state (consumed, reported, or simply stale): past that delay the row has
    /// no operational value left, the durable trail being in `audit_logs`.
    ///
    /// Returns the number of deleted rows. Called by
    /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor),
    /// and public so a consumer can drive it from its own cron instead. **The
    /// framework starts nothing by itself**: the consumer must call `.start()`
    /// on the processor.
    ///
    /// ```rust,ignore
    /// use rustwarden_core::services::email_change_service::EmailChangeService;
    ///
    /// let deleted = EmailChangeService::cleanup_expired(&db).await?;
    /// println!("{deleted} stale email-change request(s) purged");
    /// ```
    ///
    /// # Errors
    ///
    /// - [`AppError::Database`] — the delete statement failed.
    pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> {
        let cutoff = Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS);
        let result = pending_email_change::Entity::delete_many()
            .filter(pending_email_change::Column::ExpiresAt.lt(cutoff))
            .exec(db)
            .await?;
        Ok(result.rows_affected)
    }

    // ── Internals ───────────────────────────────────────────────────────────

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

    /// Mark every pending request of this user as consumed.
    ///
    /// Functional replacement for the partial unique index `sea-query` cannot
    /// express portably — same approach as
    /// `EmailValidationService::invalidate_previous_tokens`.
    async fn invalidate_previous_requests(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<(), AppError> {
        let rows = pending_email_change::Entity::find()
            .filter(pending_email_change::Column::UserId.eq(user_id))
            .filter(pending_email_change::Column::UsedAt.is_null())
            .all(db)
            .await?;

        let now = Utc::now();
        for row in rows {
            let mut active: pending_email_change::ActiveModel = row.into();
            active.used_at = Set(Some(now));
            active.update(db).await?;
        }

        Ok(())
    }

    /// Opportunistic retention purge, scoped to a single user.
    ///
    /// Belt and braces for consumers that never start
    /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor):
    /// rows of **this** user older than the retention window disappear on the
    /// next request. Rows of other users are untouched.
    async fn purge_stale_requests_for_user(
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Result<u64, AppError> {
        let cutoff = Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS);
        let result = pending_email_change::Entity::delete_many()
            .filter(
                Condition::all()
                    .add(pending_email_change::Column::UserId.eq(user_id))
                    .add(pending_email_change::Column::ExpiresAt.lt(cutoff)),
            )
            .exec(db)
            .await?;
        Ok(result.rows_affected)
    }

    /// Translate a concurrent `users.email` unique violation into the same
    /// user-facing `Validation` as the explicit uniqueness pre-check.
    ///
    /// The `UNIQUE` constraint on `users.email` closes the residual race
    /// between the `SELECT` and the `UPDATE`; surfacing it as a 500 would be
    /// both wrong and unhelpful.
    fn map_email_write_error(err: sea_orm::DbErr) -> AppError {
        let text = err.to_string().to_lowercase();
        if text.contains("duplicate key")
            || text.contains("unique constraint")
            || text.contains("unique violation")
        {
            AppError::Validation(EMAIL_NO_LONGER_AVAILABLE.to_string())
        } else {
            AppError::Database(err)
        }
    }

    /// Insert an `audit_logs` row. Best-effort: a logging failure must never
    /// undo an identity change that is already committed.
    async fn write_audit_log(
        db: &DatabaseConnection,
        action: &str,
        level: &str,
        user_id: Option<Uuid>,
        target_user_id: Option<Uuid>,
        message: String,
    ) {
        let log = audit_log::ActiveModel {
            id: Set(Uuid::new_v4()),
            user_id: Set(user_id),
            action: Set(action.to_string()),
            target_user_id: Set(target_user_id),
            level: Set(level.to_string()),
            message: Set(message),
            created_at: Set(Utc::now()),
            actor_remote_identity_id: Set(None),
            target_remote_identity_id: Set(None),
        };
        if let Err(e) = log.insert(db).await {
            tracing::warn!("Failed to write email-change audit log ({}): {}", action, e);
        }
    }

    /// Recipient locale: `user_preferences.language`, falling back to
    /// [`config::tasks::default_locale`](crate::config::tasks::default_locale).
    async fn locale_for(db: &DatabaseConnection, user_id: Uuid) -> String {
        match UserPreferencesService::get_preferences(db, user_id).await {
            Ok(Some(prefs)) => prefs.language,
            Ok(None) => crate::config::tasks::default_locale(),
            Err(e) => {
                tracing::warn!(
                    user_id = %user_id,
                    "Failed to read user preferences for email locale: {}",
                    e
                );
                crate::config::tasks::default_locale()
            }
        }
    }

    /// Translate `key` for `locale`, falling back to `fallback` when no
    /// [`I18nService`] is attached or the key is missing.
    async fn tr(&self, key: &str, locale: &str, fallback: &str) -> String {
        self.tr_vars(key, locale, fallback, &[]).await
    }

    /// Translate `key` for `locale` with `{placeholder}` substitution.
    ///
    /// Placeholders are applied to the translation **and** to the fallback, so
    /// a headless deployment without locale files renders the same sentences.
    async fn tr_vars(
        &self,
        key: &str,
        locale: &str,
        fallback: &str,
        vars: &[(&str, &str)],
    ) -> String {
        match &self.i18n {
            Some(i18n) => {
                let map: HashMap<String, String> = vars
                    .iter()
                    .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
                    .collect();
                let value = i18n.translate_with_vars(key, locale, &map).await;
                // `I18nService::translate` echoes the key when it is unknown.
                if value == key {
                    Self::interpolate(fallback, vars)
                } else {
                    value
                }
            }
            None => Self::interpolate(fallback, vars),
        }
    }

    /// Replace every `{name}` occurrence of `vars` in `template`.
    fn interpolate(template: &str, vars: &[(&str, &str)]) -> String {
        let mut out = template.to_string();
        for (name, value) in vars {
            out = out.replace(&format!("{{{name}}}"), value);
        }
        out
    }

    /// Last-resort HTML body when an Askama render fails.
    ///
    /// Keeps the flow usable instead of dropping the email: the text body is
    /// already complete, it is simply wrapped in a minimal document.
    fn html_fallback(text: &str) -> String {
        format!(
            "<!DOCTYPE html><html><body><pre>{}</pre></body></html>",
            sanitize_email_content(text)
        )
    }

    /// Render an email template, falling back to the plain-text body wrapped
    /// in minimal HTML if rendering fails.
    fn render_or_fallback(
        rendered: Result<String, askama::Error>,
        text: &str,
        which: &str,
    ) -> String {
        match rendered {
            Ok(html) => html,
            Err(e) => {
                tracing::warn!("Failed to render {} email template: {}", which, e);
                Self::html_fallback(text)
            }
        }
    }

    /// Build `(subject, html, text)` of the confirmation email sent to the
    /// candidate address.
    async fn build_validation_email(
        &self,
        locale: &str,
        username: &str,
        confirm_url: &str,
        hours: i64,
    ) -> (String, String, String) {
        let app_name = crate::config::app::default_app_name();
        let hours = hours.to_string();
        let subject = self
            .tr(
                "email_change.validation_subject",
                locale,
                "Confirm your new email address",
            )
            .await;
        let template = EmailChangeValidationTemplate {
            locale: locale.to_string(),
            app_name: app_name.clone(),
            title: self
                .tr(
                    "email_change.validation_title",
                    locale,
                    "Confirm your new email address",
                )
                .await,
            greeting: self
                .tr_vars(
                    "email_change.greeting",
                    locale,
                    "Hello {username},",
                    &[("username", username)],
                )
                .await,
            intro: self
                .tr_vars(
                    "email_change.validation_intro",
                    locale,
                    "An email-change request was opened for your {app_name} account. Open the \
                     confirmation page below and confirm the change there to complete it.",
                    &[("app_name", &app_name)],
                )
                .await,
            expires: self
                .tr_vars(
                    "email_change.validation_expires",
                    locale,
                    "This link is valid for {hours} hour(s).",
                    &[("hours", &hours)],
                )
                .await,
            ignore_notice: self
                .tr(
                    "email_change.validation_ignore",
                    locale,
                    "If you did not request this change, simply ignore this message: nothing has \
                     been modified and the link will expire on its own.",
                )
                .await,
            button_label: self
                .tr(
                    "email_change.validation_button",
                    locale,
                    "Open the confirmation page",
                )
                .await,
            confirm_url: confirm_url.to_string(),
            fallback_label: self
                .tr(
                    "email_change.fallback_label",
                    locale,
                    "Button not working? Copy this link into your browser:",
                )
                .await,
            footer: self
                .tr(
                    "email_change.footer",
                    locale,
                    "This email was sent automatically, please do not reply.",
                )
                .await,
            current_year: Utc::now().year(),
        };
        let text = template.render_text();
        let html =
            Self::render_or_fallback(template.render_html(), &text, "email-change validation");
        (subject, html, text)
    }

    /// Build `(subject, html, text)` of the notice sent to the current
    /// address. The candidate address is masked, and no confirmation link is
    /// ever included.
    async fn build_notice_email(
        &self,
        locale: &str,
        username: &str,
        report_url: &str,
        candidate_email: &str,
    ) -> (String, String, String) {
        let app_name = crate::config::app::default_app_name();
        let masked = mask_email(candidate_email);
        let subject = self
            .tr(
                "email_change.notice_subject",
                locale,
                "An email-change request was opened on your account",
            )
            .await;
        let template = EmailChangeNoticeTemplate {
            locale: locale.to_string(),
            app_name: app_name.clone(),
            title: self
                .tr("email_change.notice_title", locale, "Email-change request")
                .await,
            greeting: self
                .tr_vars(
                    "email_change.greeting",
                    locale,
                    "Hello {username},",
                    &[("username", username)],
                )
                .await,
            intro: self
                .tr_vars(
                    "email_change.notice_intro",
                    locale,
                    "A request to move your account to {email} has just been opened.",
                    &[("email", &masked)],
                )
                .await,
            unchanged: self
                .tr(
                    "email_change.notice_unchanged",
                    locale,
                    "Your account has NOT changed: it still uses this address, which stays \
                     verified until the new one is confirmed.",
                )
                .await,
            report_intro: self
                .tr(
                    "email_change.notice_report_intro",
                    locale,
                    "If you are not the origin of this request, open the report page below to \
                     alert the administrators.",
                )
                .await,
            button_label: self
                .tr("email_change.notice_button", locale, "Open the report page")
                .await,
            report_url: report_url.to_string(),
            fallback_label: self
                .tr(
                    "email_change.fallback_label",
                    locale,
                    "Button not working? Copy this link into your browser:",
                )
                .await,
            footer: self
                .tr(
                    "email_change.footer",
                    locale,
                    "This email was sent automatically, please do not reply.",
                )
                .await,
            current_year: Utc::now().year(),
        };
        let text = template.render_text();
        let html = Self::render_or_fallback(template.render_html(), &text, "email-change notice");
        (subject, html, text)
    }

    /// Build `(subject, html, text)` of the alert sent to administrators when
    /// a request is reported. Both addresses are masked.
    async fn build_reported_admin_email(
        &self,
        locale: &str,
        target_username: &str,
        current_email: &str,
        candidate_email: &str,
        reported_at: &str,
    ) -> (String, String, String) {
        let app_name = crate::config::app::default_app_name();
        let masked_old = mask_email(current_email);
        let masked_new = mask_email(candidate_email);
        let subject = self
            .tr(
                "email_change.reported_admin_subject",
                locale,
                "Email change reported by a user",
            )
            .await;
        let template = EmailChangeReportedAdminTemplate {
            locale: locale.to_string(),
            app_name: app_name.clone(),
            title: self
                .tr(
                    "email_change.reported_admin_title",
                    locale,
                    "Email change reported",
                )
                .await,
            intro: self
                .tr_vars(
                    "email_change.reported_admin_intro",
                    locale,
                    "User {username} reported an email-change request they state they did not \
                     initiate.",
                    &[("username", target_username)],
                )
                .await,
            details: self
                .tr_vars(
                    "email_change.reported_admin_details",
                    locale,
                    "Current address: {old_email} — requested address: {new_email} — report \
                     received on {reported_at}.",
                    &[
                        ("old_email", &masked_old),
                        ("new_email", &masked_new),
                        ("reported_at", reported_at),
                    ],
                )
                .await,
            not_blocked: self
                .tr(
                    "email_change.reported_admin_not_blocked",
                    locale,
                    "The change is NOT blocked: if the confirmation link is followed, the address \
                     will be updated. Step in if the situation warrants it.",
                )
                .await,
            footer: self
                .tr(
                    "email_change.footer",
                    locale,
                    "This email was sent automatically, please do not reply.",
                )
                .await,
            current_year: Utc::now().year(),
        };
        let text = template.render_text();
        let html =
            Self::render_or_fallback(template.render_html(), &text, "email-change report alert");
        (subject, html, text)
    }

    /// Build `(subject, html, text)` of the link-less notice sent to both
    /// addresses after an operator-driven change.
    async fn build_by_admin_email(
        &self,
        locale: &str,
        username: &str,
        previous_email: &str,
        new_email: &str,
    ) -> (String, String, String) {
        let app_name = crate::config::app::default_app_name();
        let masked_old = mask_email(previous_email);
        let masked_new = mask_email(new_email);
        let subject = self
            .tr(
                "email_change.by_admin_subject",
                locale,
                "Your email address was changed by an administrator",
            )
            .await;
        let template = EmailChangeByAdminTemplate {
            locale: locale.to_string(),
            app_name: app_name.clone(),
            title: self
                .tr(
                    "email_change.by_admin_title",
                    locale,
                    "Email address changed by an administrator",
                )
                .await,
            greeting: self
                .tr_vars(
                    "email_change.greeting",
                    locale,
                    "Hello {username},",
                    &[("username", username)],
                )
                .await,
            intro: self
                .tr_vars(
                    "email_change.by_admin_intro",
                    locale,
                    "The email address of your {app_name} account was changed by an administrator.",
                    &[("app_name", &app_name)],
                )
                .await,
            details: self
                .tr_vars(
                    "email_change.by_admin_details",
                    locale,
                    "Previous address: {old_email} — new address: {new_email}.",
                    &[("old_email", &masked_old), ("new_email", &masked_new)],
                )
                .await,
            support: self
                .tr(
                    "email_change.by_admin_support",
                    locale,
                    "No action is required on your side and there is nothing to confirm. If in \
                     doubt, contact support.",
                )
                .await,
            footer: self
                .tr(
                    "email_change.footer",
                    locale,
                    "This email was sent automatically, please do not reply.",
                )
                .await,
            current_year: Utc::now().year(),
        };
        let text = template.render_text();
        let html =
            Self::render_or_fallback(template.render_html(), &text, "admin-forced email change");
        (subject, html, text)
    }

    /// Queue one alert per administrator after a report. Best-effort.
    async fn notify_admins_of_report(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
        candidate_email: &str,
    ) {
        let admins = match UserService::get_all_admin_users(db).await {
            Ok(admins) => admins,
            Err(e) => {
                tracing::warn!(
                    "Failed to list administrators for email-change report: {}",
                    e
                );
                return;
            }
        };

        let target = UserService::get_user_by_id(db, user_id)
            .await
            .ok()
            .flatten();
        let target_username = target
            .as_ref()
            .map(|u| u.username.clone())
            .unwrap_or_else(|| user_id.to_string());
        // Masked in the template; an operator mailbox does not need the
        // clear-text address, the durable trail lives in `audit_logs`.
        let target_email = target.map(|u| u.email).unwrap_or_default();
        let reported_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();

        for admin in admins {
            let locale = Self::locale_for(db, admin.id).await;
            let (subject, body_html, body_text) = self
                .build_reported_admin_email(
                    &locale,
                    &target_username,
                    &target_email,
                    candidate_email,
                    &reported_at,
                )
                .await;
            if let Err(e) = EmailQueueService::enqueue(
                db,
                admin.email.clone(),
                Some(admin.username.clone()),
                self.email_config.smtp_from.clone(),
                self.email_config.smtp_from_name.clone(),
                subject,
                body_html,
                body_text,
                "email_change_reported".to_string(),
            )
            .await
            {
                tracing::warn!(
                    admin_id = %admin.id,
                    "Failed to queue email-change report alert: {}",
                    e
                );
            }
        }
    }

    /// Queue the two link-less notices after an operator-driven change.
    /// Best-effort: the write is already committed.
    async fn notify_admin_forced_change(
        &self,
        db: &DatabaseConnection,
        user_id: Uuid,
        username: &str,
        previous_email: &str,
        new_email: &str,
    ) {
        let locale = Self::locale_for(db, user_id).await;
        let (subject, body_html, body_text) = self
            .build_by_admin_email(&locale, username, previous_email, new_email)
            .await;

        for recipient in [previous_email, new_email] {
            if let Err(e) = EmailQueueService::enqueue(
                db,
                recipient.to_string(),
                Some(username.to_string()),
                self.email_config.smtp_from.clone(),
                self.email_config.smtp_from_name.clone(),
                subject.clone(),
                body_html.clone(),
                body_text.clone(),
                "email_change_by_admin".to_string(),
            )
            .await
            {
                tracing::warn!(
                    user_id = %user_id,
                    "Failed to queue admin email-change notice: {}",
                    e
                );
            }
        }
    }
}

/// Serialises every test that plants or purges rows past the retention
/// window.
///
/// [`EmailChangeService::cleanup_expired`] deletes **every** such row, whoever
/// owns it, so two retention tests running concurrently would purge each
/// other's fixtures. Shared with `tasks::pending_email_change_cleanup`, which
/// drives the same predicate from its processor.
#[cfg(test)]
pub(crate) static RETENTION_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

#[cfg(test)]
mod tests {
    use super::*;
    use crate::models::{email_queue, refresh_token};
    use crate::services::role_service::RoleService;
    use async_trait::async_trait;
    use sea_orm::{PaginatorTrait, QueryOrder};
    use std::sync::Mutex;

    /// Base URL of the links built by the service; the tests parse the token
    /// back out of the queued bodies, exactly like a human clicking them.
    const TEST_BASE_URL: &str = "https://rw.test";

    /// One captured `on_audit_event` call, owned so the assertions can outlive
    /// the borrowed [`AuditEvent`].
    #[derive(Clone, Debug)]
    struct CapturedEvent {
        kind: String,
        actor_id: Option<Uuid>,
        target: Option<(String, Uuid)>,
        metadata: serde_json::Value,
    }

    type AuditSink = Arc<Mutex<Vec<CapturedEvent>>>;
    type VerifiedSink = Arc<Mutex<Vec<Uuid>>>;

    /// Hooks impl forwarding every audit event to an externally-owned sink —
    /// same shape as `auth_logging::tests::CapturingHooks`, so no downcast is
    /// needed once the value is erased behind `Arc<dyn RustwardenHooks>`.
    struct CapturingHooks {
        audit: AuditSink,
        verified: VerifiedSink,
    }

    #[async_trait]
    impl RustwardenHooks for CapturingHooks {
        async fn on_audit_event(
            &self,
            _db: &DatabaseConnection,
            event: AuditEvent<'_>,
        ) -> anyhow::Result<()> {
            self.audit.lock().expect("audit sink").push(CapturedEvent {
                kind: event.kind.to_string(),
                actor_id: event.actor_id,
                target: event.target.map(|(kind, id)| (kind.to_string(), id)),
                metadata: event.metadata.clone(),
            });
            Ok(())
        }

        async fn on_email_verified(
            &self,
            _db: &DatabaseConnection,
            user_id: Uuid,
        ) -> anyhow::Result<()> {
            self.verified.lock().expect("verified sink").push(user_id);
            Ok(())
        }
    }

    fn capturing_hooks() -> (Arc<dyn RustwardenHooks>, AuditSink, VerifiedSink) {
        let audit = Arc::new(Mutex::new(Vec::new()));
        let verified = Arc::new(Mutex::new(Vec::new()));
        let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks {
            audit: audit.clone(),
            verified: verified.clone(),
        });
        (hooks, audit, verified)
    }

    /// Per-test connection — see `personal_token_service::tests` for rationale.
    async fn test_db() -> DatabaseConnection {
        dotenvy::dotenv().ok();
        let url = std::env::var("DATABASE_URL")
            .expect("DATABASE_URL required for email_change_service tests");
        sea_orm::Database::connect(&url)
            .await
            .expect("connect to the test database")
    }

    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: Some("Rustwarden Test".into()),
            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: TEST_BASE_URL.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,
        }
    }

    /// Service backed by a **private** limiter: the process-wide `OnceLock`
    /// would otherwise leak budgets between tests running in the same binary.
    fn service() -> EmailChangeService {
        EmailChangeService::new_with_limiter(dummy_email_config(), RateLimitService::new())
    }

    const PASSWORD: &str = "TestPassword1!";

    /// Create a **verified** account, the realistic starting point: RFC 0041
    /// protects an identity that has already been proven.
    async fn verified_user(db: &DatabaseConnection) -> user::Model {
        let unique = Uuid::new_v4().simple().to_string();
        let hash = crate::services::password_service::PasswordService::hash_password(PASSWORD)
            .await
            .expect("hash password");
        let created = UserService::create_user(
            db,
            format!("ec-{}", &unique[..10]),
            format!("ec-{}@example.test", &unique[..10]),
            hash,
            false,
        )
        .await
        .expect("create user");

        let mut active: user::ActiveModel = created.into();
        active.email_verified = Set(true);
        active.email_verified_at = Set(Some(Utc::now()));
        active.update(db).await.expect("mark verified")
    }

    /// Address nobody owns yet, unique per call.
    fn fresh_email() -> String {
        format!("cand-{}@example.test", Uuid::new_v4().simple())
    }

    /// Latest queued mail of `email_type` addressed to `to_email`.
    async fn last_queued(
        db: &DatabaseConnection,
        to_email: &str,
        email_type: &str,
    ) -> Option<email_queue::Model> {
        email_queue::Entity::find()
            .filter(email_queue::Column::ToEmail.eq(to_email))
            .filter(email_queue::Column::EmailType.eq(email_type))
            .order_by_desc(email_queue::Column::CreatedAt)
            .one(db)
            .await
            .expect("query email_queue")
    }

    async fn queued_count(db: &DatabaseConnection, to_email: &str, email_type: &str) -> u64 {
        email_queue::Entity::find()
            .filter(email_queue::Column::ToEmail.eq(to_email))
            .filter(email_queue::Column::EmailType.eq(email_type))
            .count(db)
            .await
            .expect("count email_queue")
    }

    /// Pull the token back out of a queued body — the tests follow the same
    /// path as the human: read the mail, take the link.
    fn token_in(body: &str, kind: &str) -> String {
        let marker = format!("{TEST_BASE_URL}/api/v1/auth/email-change/{kind}/");
        let start = body
            .find(&marker)
            .unwrap_or_else(|| panic!("no {kind} link in body: {body}"))
            + marker.len();
        body[start..]
            .chars()
            .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
            .collect()
    }

    async fn confirmation_token(db: &DatabaseConnection, candidate: &str) -> String {
        let mail = last_queued(db, candidate, "email_change_validation")
            .await
            .expect("validation mail queued");
        token_in(&mail.body_text, "confirm")
    }

    async fn report_token(db: &DatabaseConnection, current: &str) -> String {
        let mail = last_queued(db, current, "email_change_notice")
            .await
            .expect("notice mail queued");
        token_in(&mail.body_text, "report")
    }

    async fn pending_rows(
        db: &DatabaseConnection,
        user_id: Uuid,
    ) -> Vec<pending_email_change::Model> {
        pending_email_change::Entity::find()
            .filter(pending_email_change::Column::UserId.eq(user_id))
            .order_by_asc(pending_email_change::Column::CreatedAt)
            .all(db)
            .await
            .expect("query pending_email_changes")
    }

    async fn audit_rows(
        db: &DatabaseConnection,
        user_id: Uuid,
        action: &str,
    ) -> Vec<audit_log::Model> {
        audit_log::Entity::find()
            .filter(
                Condition::any()
                    .add(audit_log::Column::UserId.eq(user_id))
                    .add(audit_log::Column::TargetUserId.eq(user_id)),
            )
            .filter(audit_log::Column::Action.eq(action))
            .all(db)
            .await
            .expect("query audit_logs")
    }

    async fn reload(db: &DatabaseConnection, user_id: Uuid) -> user::Model {
        UserService::get_user_by_id(db, user_id)
            .await
            .expect("reload user")
            .expect("user still exists")
    }

    /// Give `user_id` the `admin` role. Returns `false` when the RBAC seed is
    /// absent, so the admin tests skip instead of failing on a bare schema.
    async fn promote_to_admin(db: &DatabaseConnection, user_id: Uuid) -> bool {
        match RoleService::get_role_by_name(db, "admin").await {
            Ok(Some(role)) => {
                UserService::assign_role_to_user(db, user_id, role.id)
                    .await
                    .expect("assign admin role");
                true
            }
            _ => false,
        }
    }

    /// Force a pending row into the past without going through the service.
    async fn expire(db: &DatabaseConnection, row: pending_email_change::Model) {
        let mut active: pending_email_change::ActiveModel = row.into();
        active.expires_at = Set(Utc::now() - Duration::hours(1));
        active.update(db).await.expect("expire row");
    }

    // ── Unit: token hashing ─────────────────────────────────────────────────

    #[test]
    fn hash_token_is_deterministic_lowercase_sha256_hex() {
        let hash = EmailChangeService::hash_token("abc");
        assert_eq!(hash.len(), 64, "SHA-256 hex is 64 characters");
        assert!(
            hash.chars()
                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
            "hash must be lowercase hex, got {hash}"
        );
        assert_eq!(
            hash,
            EmailChangeService::hash_token("abc"),
            "hashing must be deterministic"
        );
    }

    #[test]
    fn hash_token_differs_for_two_inputs() {
        assert_ne!(
            EmailChangeService::hash_token("token-a"),
            EmailChangeService::hash_token("token-b")
        );
    }

    #[test]
    fn hash_token_never_returns_the_plain_token() {
        let plain = Uuid::new_v4().to_string();
        assert_ne!(EmailChangeService::hash_token(&plain), plain);
    }

    // ── Unit: rate limiter wiring ───────────────────────────────────────────

    #[test]
    fn new_with_limiter_uses_injected_instance() {
        let limiter = RateLimitService::new();
        let svc = EmailChangeService::new_with_limiter(dummy_email_config(), limiter.clone());
        let key = format!("email_change:{}", Uuid::new_v4());
        for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX {
            limiter
                .check_sliding_window(
                    &key,
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    EMAIL_CHANGE_RATE_LIMIT_WINDOW,
                )
                .expect("budget not exhausted yet");
        }
        assert!(
            svc.rate_limiter
                .check_sliding_window(
                    &key,
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    EMAIL_CHANGE_RATE_LIMIT_WINDOW
                )
                .is_err(),
            "the service must see the budget consumed through the injected limiter"
        );
    }

    #[test]
    fn new_shares_the_process_wide_limiter() {
        let a = EmailChangeService::new(dummy_email_config());
        let b = EmailChangeService::new(dummy_email_config());
        let key = format!("email_change:{}", Uuid::new_v4());
        for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX {
            a.rate_limiter
                .check_sliding_window(
                    &key,
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    EMAIL_CHANGE_RATE_LIMIT_WINDOW,
                )
                .expect("budget not exhausted yet");
        }
        assert!(
            b.rate_limiter
                .check_sliding_window(
                    &key,
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    EMAIL_CHANGE_RATE_LIMIT_WINDOW
                )
                .is_err(),
            "two `new()` services must share one process-wide budget"
        );
    }

    #[test]
    fn email_change_budget_is_distinct_from_verify_and_reset() {
        let limiter = RateLimitService::new();
        let id = Uuid::new_v4();
        let window = EMAIL_CHANGE_RATE_LIMIT_WINDOW;
        for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX {
            limiter
                .check_sliding_window(
                    &format!("email_change:{id}"),
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    window,
                )
                .expect("email_change budget");
        }
        // Same subject, other prefixes: untouched. A user who exhausted their
        // change budget must still be able to reset their password.
        assert!(
            limiter
                .check_sliding_window(
                    &format!("email_verify:{id}"),
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    window
                )
                .is_ok(),
            "email_verify budget must not share the email_change key"
        );
        assert!(
            limiter
                .check_sliding_window(
                    &format!("pwd_reset:{id}"),
                    EMAIL_CHANGE_RATE_LIMIT_MAX,
                    window
                )
                .is_ok(),
            "pwd_reset budget must not share the email_change key"
        );
    }

    /// Count the sessions still usable by `user_id`.
    async fn live_sessions(db: &DatabaseConnection, user_id: Uuid) -> u64 {
        refresh_token::Entity::find()
            .filter(refresh_token::Column::UserId.eq(user_id))
            .filter(refresh_token::Column::Revoked.eq(false))
            .count(db)
            .await
            .expect("count refresh tokens")
    }

    // ── request_change ──────────────────────────────────────────────────────

    /// RFC 0041 test 1 — the whole point of the RFC: a request changes nothing
    /// on the account.
    #[tokio::test]
    async fn request_change_writes_a_pending_row_and_leaves_the_account_untouched() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();

        service()
            .request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request must succeed");

        let rows = pending_rows(&db, user.id).await;
        assert_eq!(rows.len(), 1, "exactly one pending row");
        assert_eq!(rows[0].new_email, candidate);
        assert!(rows[0].used_at.is_none(), "a fresh request is not consumed");
        assert!(rows[0].reported_at.is_none());
        assert!(rows[0].is_pending(), "the row must be usable");
        assert_ne!(
            rows[0].token_hash, rows[0].report_token_hash,
            "confirming and reporting are two distinct capabilities"
        );

        let reloaded = reload(&db, user.id).await;
        assert_eq!(
            reloaded.email, user.email,
            "users.email must NOT move before confirmation"
        );
        assert!(
            reloaded.email_verified,
            "a pending request must not un-verify the account"
        );

        assert_eq!(
            queued_count(&db, &candidate, "email_change_validation").await,
            1,
            "one confirmation mail to the candidate address"
        );
        assert_eq!(
            queued_count(&db, &user.email, "email_change_notice").await,
            1,
            "one notice mail to the current address"
        );
    }

    /// The notice must never carry the confirmation link, and must not spell
    /// out the candidate address: the old mailbox may already be hostile.
    #[tokio::test]
    async fn the_notice_carries_the_report_link_only_and_masks_the_candidate() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();

        service()
            .request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request must succeed");

        let notice = last_queued(&db, &user.email, "email_change_notice")
            .await
            .expect("notice queued");
        let confirm_token = confirmation_token(&db, &candidate).await;

        for body in [&notice.body_text, &notice.body_html] {
            assert!(
                !body.contains(&confirm_token),
                "the notice must never carry the confirmation token"
            );
            assert!(
                !body.contains("/email-change/confirm/"),
                "the notice must never carry a confirmation link"
            );
            assert!(
                !body.contains(&candidate),
                "the candidate address must be masked in the notice"
            );
            assert!(
                body.contains("/email-change/report/"),
                "the notice must carry the report link"
            );
        }
    }

    /// RFC 0041 test 3 — a stolen access token alone must not open a change.
    #[tokio::test]
    async fn request_change_with_a_wrong_password_writes_nothing() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();

        let result = service()
            .request_change(&db, user.id, &candidate, "WrongPassword1!")
            .await;

        assert!(
            matches!(result, Err(AppError::InvalidCredentials)),
            "expected InvalidCredentials, got {result:?}"
        );
        assert!(
            pending_rows(&db, user.id).await.is_empty(),
            "no row may exist after a refused request"
        );
        assert_eq!(
            queued_count(&db, &candidate, "email_change_validation").await,
            0,
            "no mail may reach the candidate address"
        );
    }

    /// RFC 0041 test 4 — the format is checked before anything is written.
    #[tokio::test]
    async fn request_change_with_an_invalid_format_writes_nothing() {
        let db = test_db().await;
        let user = verified_user(&db).await;

        let result = service()
            .request_change(&db, user.id, "not-an-email", PASSWORD)
            .await;

        assert!(
            matches!(result, Err(AppError::Validation(_))),
            "expected Validation, got {result:?}"
        );
        assert!(pending_rows(&db, user.id).await.is_empty());
        assert_eq!(reload(&db, user.id).await.email, user.email);
    }

    /// RFC 0041 test 5 — an address already owned by another account.
    #[tokio::test]
    async fn request_change_on_a_taken_address_is_refused() {
        let db = test_db().await;
        let alice = verified_user(&db).await;
        let bob = verified_user(&db).await;

        let result = service()
            .request_change(&db, alice.id, &bob.email, PASSWORD)
            .await;

        assert!(
            matches!(result, Err(AppError::Validation(_))),
            "expected Validation, got {result:?}"
        );
        assert!(pending_rows(&db, alice.id).await.is_empty());
    }

    /// RFC 0041 test 6 — echoing the current address is a refusal here, not a
    /// silent no-op: `request_change` expresses an explicit intent, unlike a
    /// profile form that resubmits every field.
    #[tokio::test]
    async fn request_change_on_the_current_address_is_refused() {
        let db = test_db().await;
        let user = verified_user(&db).await;

        let result = service()
            .request_change(&db, user.id, &user.email, PASSWORD)
            .await;

        assert!(
            matches!(result, Err(AppError::Validation(_))),
            "expected Validation, got {result:?}"
        );
        assert!(pending_rows(&db, user.id).await.is_empty());
        assert_eq!(
            queued_count(&db, &user.email, "email_change_notice").await,
            0,
            "a refused request must not mail anybody"
        );
    }

    /// RFC 0041 test 7 — the per-user budget bounds "one account spamming
    /// third-party mailboxes".
    #[tokio::test]
    async fn the_fourth_request_within_the_hour_is_rate_limited() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let svc = service();

        for i in 1..=EMAIL_CHANGE_RATE_LIMIT_MAX {
            svc.request_change(&db, user.id, &fresh_email(), PASSWORD)
                .await
                .unwrap_or_else(|e| panic!("request {i} must succeed: {e:?}"));
        }

        let candidate = fresh_email();
        let result = svc.request_change(&db, user.id, &candidate, PASSWORD).await;
        match result {
            Err(AppError::TooManyRequests { retry_after_secs }) => assert!(
                retry_after_secs > 0,
                "a 429 must carry an actionable Retry-After"
            ),
            other => panic!("expected TooManyRequests, got {other:?}"),
        }
        assert_eq!(
            queued_count(&db, &candidate, "email_change_validation").await,
            0,
            "a throttled request must not mail the candidate address"
        );
    }

    /// RFC 0041 test 10 — a second request consumes the first: two live links
    /// would mean two ways to move the account's identity.
    #[tokio::test]
    async fn a_new_request_consumes_the_previous_one() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let svc = service();

        let first_candidate = fresh_email();
        svc.request_change(&db, user.id, &first_candidate, PASSWORD)
            .await
            .expect("first request");
        let first_token = confirmation_token(&db, &first_candidate).await;

        let second_candidate = fresh_email();
        svc.request_change(&db, user.id, &second_candidate, PASSWORD)
            .await
            .expect("second request");

        assert_eq!(
            EmailChangeService::inspect_confirmation_token(&db, &first_token)
                .await
                .expect("inspect"),
            EmailChangeLinkState::Used,
            "the superseded link must be dead"
        );
        assert!(
            svc.confirm_change(&db, &first_token, None).await.is_err(),
            "the superseded token must not confirm"
        );
        assert_eq!(
            reload(&db, user.id).await.email,
            user.email,
            "a dead token must not move the address"
        );
    }

    // ── confirm_change ──────────────────────────────────────────────────────

    /// RFC 0041 test 2 — the happy path, including the session closure the
    /// flow promises.
    #[tokio::test]
    async fn confirm_change_applies_the_address_and_closes_every_session() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();
        let svc = service();

        RefreshTokenService::create_refresh_token(&db, user.id, 30)
            .await
            .expect("seed a live session");

        svc.request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let token = confirmation_token(&db, &candidate).await;

        let changed_id = svc
            .confirm_change(&db, &token, None)
            .await
            .expect("confirmation must succeed");
        assert_eq!(changed_id, user.id);

        let reloaded = reload(&db, user.id).await;
        assert_eq!(
            reloaded.email, candidate,
            "the new address is now the account's"
        );
        assert!(
            reloaded.email_verified,
            "clicking the link IS the proof of possession"
        );
        assert!(reloaded.email_verified_at.is_some());

        let rows = pending_rows(&db, user.id).await;
        assert_eq!(rows.len(), 1);
        assert!(rows[0].used_at.is_some(), "the row must be consumed");

        assert_eq!(
            live_sessions(&db, user.id).await,
            0,
            "the login identity moved: every session must re-authenticate"
        );
        assert_eq!(
            audit_rows(&db, user.id, "email_changed").await.len(),
            1,
            "exactly one audit row"
        );
    }

    /// RFC 0041 test 8 — the three refusal shapes, each leaving the account
    /// untouched.
    #[tokio::test]
    async fn confirm_change_refuses_unknown_expired_and_used_tokens() {
        let db = test_db().await;
        let svc = service();

        // Unknown.
        let unknown = Uuid::new_v4().to_string();
        assert!(matches!(
            svc.confirm_change(&db, &unknown, None).await,
            Err(AppError::Validation(_))
        ));
        assert_eq!(
            EmailChangeService::inspect_confirmation_token(&db, &unknown)
                .await
                .expect("inspect"),
            EmailChangeLinkState::Unknown
        );

        // Expired.
        let expired_user = verified_user(&db).await;
        let expired_candidate = fresh_email();
        svc.request_change(&db, expired_user.id, &expired_candidate, PASSWORD)
            .await
            .expect("request");
        let expired_token = confirmation_token(&db, &expired_candidate).await;
        expire(&db, pending_rows(&db, expired_user.id).await.remove(0)).await;
        assert!(matches!(
            svc.confirm_change(&db, &expired_token, None).await,
            Err(AppError::Validation(_))
        ));
        assert_eq!(
            EmailChangeService::inspect_confirmation_token(&db, &expired_token)
                .await
                .expect("inspect"),
            EmailChangeLinkState::Expired
        );
        assert_eq!(
            reload(&db, expired_user.id).await.email,
            expired_user.email,
            "an expired link must not move the address"
        );

        // Already used.
        let used_user = verified_user(&db).await;
        let used_candidate = fresh_email();
        svc.request_change(&db, used_user.id, &used_candidate, PASSWORD)
            .await
            .expect("request");
        let used_token = confirmation_token(&db, &used_candidate).await;
        svc.confirm_change(&db, &used_token, None)
            .await
            .expect("first confirmation");
        assert!(matches!(
            svc.confirm_change(&db, &used_token, None).await,
            Err(AppError::Validation(_))
        ));
        assert_eq!(
            EmailChangeService::inspect_confirmation_token(&db, &used_token)
                .await
                .expect("inspect"),
            EmailChangeLinkState::Used
        );
        assert_eq!(
            reload(&db, used_user.id).await.email,
            used_candidate,
            "replaying a used link must not change the address a second time"
        );
    }

    /// RFC 0041 test 9 — two accounts racing for the same address. The loser
    /// gets a neutral message and keeps a **reusable** row.
    #[tokio::test]
    async fn the_loser_of_a_race_keeps_a_reusable_row() {
        let db = test_db().await;
        let alice = verified_user(&db).await;
        let bob = verified_user(&db).await;
        let shared = fresh_email();
        let svc = service();

        svc.request_change(&db, alice.id, &shared, PASSWORD)
            .await
            .expect("alice requests");
        let alice_token = confirmation_token(&db, &shared).await;

        svc.request_change(&db, bob.id, &shared, PASSWORD)
            .await
            .expect("bob requests the same address");
        let bob_token = confirmation_token(&db, &shared).await;
        assert_ne!(alice_token, bob_token, "two requests, two secrets");

        svc.confirm_change(&db, &alice_token, None)
            .await
            .expect("alice confirms first");

        match svc.confirm_change(&db, &bob_token, None).await {
            Err(AppError::Validation(msg)) => assert!(
                !msg.contains(&alice.username) && !msg.contains(&alice.email),
                "the refusal must not name the winning account: {msg}"
            ),
            other => panic!("expected Validation, got {other:?}"),
        }
        assert_eq!(
            reload(&db, bob.id).await.email,
            bob.email,
            "bob keeps his address"
        );
        let bob_rows = pending_rows(&db, bob.id).await;
        assert_eq!(bob_rows.len(), 1);
        assert!(
            bob_rows[0].used_at.is_none(),
            "losing the race must not burn bob's link: it works again if the address frees up"
        );
    }

    /// RFC 0041 test 17 — a password-change link mailed to the previous
    /// address must die with that address.
    #[tokio::test]
    async fn confirm_change_drops_pending_password_change_requests() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();
        let password_service = PasswordChangeService::new();

        password_service
            .create_pending_request(&db, user.id, "AnotherPassword1!")
            .await
            .expect("seed a pending password change");
        assert!(
            password_service
                .find_pending_by_user_id(&db, user.id)
                .await
                .expect("lookup")
                .is_some(),
            "precondition: a pending password change exists"
        );

        let svc = service();
        svc.request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let token = confirmation_token(&db, &candidate).await;
        svc.confirm_change(&db, &token, None)
            .await
            .expect("confirmation");

        assert!(
            password_service
                .find_pending_by_user_id(&db, user.id)
                .await
                .expect("lookup")
                .is_none(),
            "the pending password change must be gone"
        );
    }

    /// RFC 0041 test 16 — hooks dispatched on confirmation, addresses masked.
    #[tokio::test]
    async fn confirm_change_dispatches_audit_and_email_verified_hooks() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();
        let (hooks, audit, verified) = capturing_hooks();
        let svc = service();

        svc.request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let token = confirmation_token(&db, &candidate).await;
        svc.confirm_change(&db, &token, Some(&hooks))
            .await
            .expect("confirmation");

        let events = audit.lock().expect("audit sink").clone();
        let changed = events
            .iter()
            .find(|event| event.kind == "user.email_changed")
            .unwrap_or_else(|| panic!("user.email_changed must be dispatched, got {events:?}"));
        assert_eq!(
            changed.target,
            Some(("user".to_string(), user.id)),
            "the event must name the account whose address moved"
        );
        let metadata = changed.metadata.to_string();
        assert!(
            !metadata.contains(&candidate) && !metadata.contains(&user.email),
            "hook metadata must carry masked addresses only: {metadata}"
        );
        assert!(
            verified.lock().expect("verified sink").contains(&user.id),
            "the click is the proof of possession: on_email_verified must fire"
        );
    }

    /// The two secrets are not interchangeable.
    #[tokio::test]
    async fn a_report_token_cannot_confirm_and_a_confirmation_token_cannot_report() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();
        let svc = service();

        svc.request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let confirm = confirmation_token(&db, &candidate).await;
        let report = report_token(&db, &user.email).await;

        assert!(
            svc.confirm_change(&db, &report, None).await.is_err(),
            "the report token must not confirm the change"
        );
        assert!(
            svc.report_suspicious(&db, &confirm, None).await.is_err(),
            "the confirmation token must not file a report"
        );
        assert_eq!(reload(&db, user.id).await.email, user.email);
        let rows = pending_rows(&db, user.id).await;
        // The admin fan-out sits behind the `reported_at` write, so an
        // unflagged row is also a row that woke nobody. Counting admin mails
        // directly would race with the other tests sharing this database.
        assert!(rows[0].used_at.is_none() && rows[0].reported_at.is_none());
    }

    /// RFC 0041 test 15 — deleting the account takes its pending requests with
    /// it (FK `ON DELETE CASCADE`).
    #[tokio::test]
    async fn deleting_a_user_cascades_to_their_pending_requests() {
        let db = test_db().await;
        let user = verified_user(&db).await;

        service()
            .request_change(&db, user.id, &fresh_email(), PASSWORD)
            .await
            .expect("request");
        assert_eq!(pending_rows(&db, user.id).await.len(), 1);

        UserService::delete_user(&db, user.id, None)
            .await
            .expect("delete user");

        assert!(
            pending_rows(&db, user.id).await.is_empty(),
            "pending rows must not survive their owner"
        );
    }

    // ── report_suspicious ───────────────────────────────────────────────────

    /// RFC 0041 tests 11-13 — the report alerts, traces, and does **not**
    /// block: the mailbox receiving this link may alert, never decide.
    #[tokio::test]
    async fn report_suspicious_alerts_and_traces_without_blocking_the_change() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();
        let (hooks, audit, _) = capturing_hooks();
        let svc = service();

        svc.request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let report = report_token(&db, &user.email).await;
        let confirm = confirmation_token(&db, &candidate).await;

        let reported_id = svc
            .report_suspicious(&db, &report, Some(&hooks))
            .await
            .expect("report must succeed");
        assert_eq!(reported_id, user.id);

        let rows = pending_rows(&db, user.id).await;
        assert!(rows[0].reported_at.is_some(), "the row must be flagged");
        assert!(
            rows[0].used_at.is_none(),
            "a report must not consume the request"
        );

        let audits = audit_rows(&db, user.id, "email_change_reported").await;
        assert_eq!(audits.len(), 1, "exactly one audit row");
        assert_eq!(audits[0].level, "WARN", "a report is a warning-level event");
        assert!(
            !audits[0].message.contains(&candidate),
            "the audit message must carry masked addresses only"
        );

        let events = audit.lock().expect("audit sink").clone();
        assert!(
            events
                .iter()
                .any(|event| event.kind == "user.email_change_reported"),
            "the audit hook must be dispatched, got {events:?}"
        );

        // The decisive assertion: a report does not veto the change.
        svc.confirm_change(&db, &confirm, None)
            .await
            .expect("a reported request must still be confirmable");
        assert_eq!(reload(&db, user.id).await.email, candidate);
    }

    /// Double click, page refresh, browser form resend: the second report is a
    /// no-op, not a second wake-up call for every administrator.
    #[tokio::test]
    async fn replaying_a_report_is_idempotent() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let svc = service();

        svc.request_change(&db, user.id, &fresh_email(), PASSWORD)
            .await
            .expect("request");
        let report = report_token(&db, &user.email).await;

        let first_id = svc
            .report_suspicious(&db, &report, None)
            .await
            .expect("first report");
        let first_at = pending_rows(&db, user.id).await[0]
            .reported_at
            .expect("the row must be flagged");

        let second_id = svc
            .report_suspicious(&db, &report, None)
            .await
            .expect("a replay must not fail");

        assert_eq!(second_id, first_id);
        assert_eq!(
            pending_rows(&db, user.id).await[0].reported_at,
            Some(first_at),
            "the first report timestamp stands"
        );
        assert_eq!(
            audit_rows(&db, user.id, "email_change_reported")
                .await
                .len(),
            1,
            "replaying must not wake every administrator twice"
        );
    }

    /// RFC 0041 test 14 — reporting an already-consumed request stays useful:
    /// the operator signal keeps its value after the fact.
    #[tokio::test]
    async fn a_consumed_request_can_still_be_reported() {
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();
        let svc = service();

        svc.request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let report = report_token(&db, &user.email).await;
        let confirm = confirmation_token(&db, &candidate).await;
        svc.confirm_change(&db, &confirm, None)
            .await
            .expect("confirmation");

        svc.report_suspicious(&db, &report, None)
            .await
            .expect("reporting after the fact must still work");

        let rows = pending_rows(&db, user.id).await;
        assert!(rows[0].used_at.is_some() && rows[0].reported_at.is_some());
        assert_eq!(
            audit_rows(&db, user.id, "email_change_reported")
                .await
                .len(),
            1,
            "the operator is alerted even though the change already landed"
        );
        assert_eq!(
            reload(&db, user.id).await.email,
            candidate,
            "a late report does not roll the change back"
        );
    }

    // ── admin_force_change ──────────────────────────────────────────────────

    /// RFC 0041 tests 23-25 — the operator recourse: a write without proof of
    /// possession, traced as a warning and never silent.
    #[tokio::test]
    async fn admin_force_change_writes_without_proof_and_traces_it_as_a_warning() {
        let db = test_db().await;
        let admin = verified_user(&db).await;
        if !promote_to_admin(&db, admin.id).await {
            eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
            return;
        }
        let target = verified_user(&db).await;
        let fixed = fresh_email();
        let (hooks, audit, verified) = capturing_hooks();

        RefreshTokenService::create_refresh_token(&db, target.id, 30)
            .await
            .expect("seed a live session");

        service()
            .admin_force_change(&db, admin.id, target.id, &fixed, Some(&hooks))
            .await
            .expect("the override must succeed");

        let reloaded = reload(&db, target.id).await;
        assert_eq!(reloaded.email, fixed);
        assert!(
            reloaded.email_verified,
            "the operator vouches for the address out of band"
        );
        assert_eq!(
            live_sessions(&db, target.id).await,
            0,
            "the target's sessions must close, like any identity move"
        );

        let audits = audit_rows(&db, target.id, "email_changed_by_admin").await;
        assert_eq!(audits.len(), 1, "exactly one audit row");
        assert_eq!(
            audits[0].level, "WARN",
            "an identity write without proof must surface"
        );
        assert_eq!(
            audits[0].user_id,
            Some(admin.id),
            "the acting operator must be named"
        );
        assert!(
            !audits[0].message.contains(&fixed) && !audits[0].message.contains(&target.email),
            "the audit message must carry masked addresses only"
        );

        let events = audit.lock().expect("audit sink").clone();
        let forced = events
            .iter()
            .find(|event| event.kind == "user.email_changed_by_admin")
            .unwrap_or_else(|| {
                panic!("user.email_changed_by_admin must be dispatched, got {events:?}")
            });
        assert_eq!(
            forced.actor_id,
            Some(admin.id),
            "the event must name the acting operator"
        );
        assert_eq!(
            forced.target,
            Some(("user".to_string(), target.id)),
            "the event must name the account that was rewritten"
        );
        assert!(
            !verified.lock().expect("verified sink").contains(&target.id),
            "nobody proved possession: on_email_verified must NOT fire"
        );

        // Both mailboxes informed; there is nothing left to confirm, so
        // neither mail carries an action link.
        for mailbox in [target.email.as_str(), fixed.as_str()] {
            let mail = last_queued(&db, mailbox, "email_change_by_admin")
                .await
                .unwrap_or_else(|| panic!("no notice queued for {mailbox}"));
            for body in [&mail.body_text, &mail.body_html] {
                assert!(
                    !body.contains("/email-change/confirm/")
                        && !body.contains("/email-change/report/"),
                    "an operator-driven change leaves nothing to click"
                );
            }
        }
    }

    /// RFC 0041 test 26 — the route middleware is not the only gate: a
    /// headless call straight to the service is refused too.
    #[tokio::test]
    async fn admin_force_change_refuses_a_non_admin_actor() {
        let db = test_db().await;
        let actor = verified_user(&db).await;
        let target = verified_user(&db).await;
        let candidate = fresh_email();

        let result = service()
            .admin_force_change(&db, actor.id, target.id, &candidate, None)
            .await;

        assert!(
            matches!(result, Err(AppError::Forbidden)),
            "expected Forbidden, got {result:?}"
        );
        assert_eq!(
            reload(&db, target.id).await.email,
            target.email,
            "nothing may be written"
        );
        assert!(
            audit_rows(&db, target.id, "email_changed_by_admin")
                .await
                .is_empty(),
            "a refused override leaves no trail to read"
        );
    }

    /// RFC 0041 test 27 — a still-valid confirmation link must not overwrite
    /// the address the operator just set.
    #[tokio::test]
    async fn admin_force_change_kills_a_live_confirmation_link() {
        let db = test_db().await;
        let admin = verified_user(&db).await;
        if !promote_to_admin(&db, admin.id).await {
            eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
            return;
        }
        let target = verified_user(&db).await;
        let candidate = fresh_email();
        let fixed = fresh_email();
        let svc = service();

        svc.request_change(&db, target.id, &candidate, PASSWORD)
            .await
            .expect("the user opens a request");
        let token = confirmation_token(&db, &candidate).await;

        svc.admin_force_change(&db, admin.id, target.id, &fixed, None)
            .await
            .expect("the operator overrides it");

        assert_eq!(
            EmailChangeService::inspect_confirmation_token(&db, &token)
                .await
                .expect("inspect"),
            EmailChangeLinkState::Used,
            "the pending request must be consumed by the override"
        );
        assert!(
            svc.confirm_change(&db, &token, None).await.is_err(),
            "a stale link must not resurrect the previous candidate address"
        );
        assert_eq!(reload(&db, target.id).await.email, fixed);
    }

    /// Admin forms resubmit every field: echoing the current address must not
    /// pollute the trail with a change that did not happen.
    #[tokio::test]
    async fn admin_force_change_on_the_current_address_is_a_silent_noop() {
        let db = test_db().await;
        let admin = verified_user(&db).await;
        if !promote_to_admin(&db, admin.id).await {
            eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
            return;
        }
        let target = verified_user(&db).await;

        service()
            .admin_force_change(&db, admin.id, target.id, &target.email, None)
            .await
            .expect("echoing the current address must not fail");

        assert_eq!(reload(&db, target.id).await.email, target.email);
        assert!(
            audit_rows(&db, target.id, "email_changed_by_admin")
                .await
                .is_empty(),
            "a no-op must not be traced as a change"
        );
        assert_eq!(
            queued_count(&db, &target.email, "email_change_by_admin").await,
            0,
            "a no-op must not mail anybody"
        );
    }

    /// RFC 0041 test 26 (suite) — an unknown actor is a `NotFound`, not a
    /// silent `Forbidden`: the two say different things to an operator.
    #[tokio::test]
    async fn admin_force_change_with_an_unknown_actor_is_not_found() {
        let db = test_db().await;
        let target = verified_user(&db).await;

        let result = service()
            .admin_force_change(&db, Uuid::new_v4(), target.id, &fresh_email(), None)
            .await;

        assert!(
            matches!(result, Err(AppError::NotFound(_))),
            "expected NotFound, got {result:?}"
        );
        assert_eq!(reload(&db, target.id).await.email, target.email);
    }

    /// RFC 0041 test 27 — the operator recourse is not a licence to duplicate
    /// an address: uniqueness still holds.
    #[tokio::test]
    async fn admin_force_change_on_a_taken_address_is_refused() {
        let db = test_db().await;
        let admin = verified_user(&db).await;
        if !promote_to_admin(&db, admin.id).await {
            eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
            return;
        }
        let target = verified_user(&db).await;
        let neighbour = verified_user(&db).await;

        let result = service()
            .admin_force_change(&db, admin.id, target.id, &neighbour.email, None)
            .await;

        assert!(
            matches!(result, Err(AppError::Validation(_))),
            "expected Validation, got {result:?}"
        );
        assert_eq!(reload(&db, target.id).await.email, target.email);
        assert_eq!(reload(&db, neighbour.id).await.email, neighbour.email);
    }

    /// RFC 0041 test 17, admin side — same reason as on the user path: a
    /// password-change link mailed to the previous address must not survive it.
    #[tokio::test]
    async fn admin_force_change_drops_pending_password_change_requests() {
        let db = test_db().await;
        let admin = verified_user(&db).await;
        if !promote_to_admin(&db, admin.id).await {
            eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
            return;
        }
        let target = verified_user(&db).await;
        let password_service = PasswordChangeService::new();

        password_service
            .create_pending_request(&db, target.id, "AnotherPassword1!")
            .await
            .expect("seed a pending password change");

        service()
            .admin_force_change(&db, admin.id, target.id, &fresh_email(), None)
            .await
            .expect("override");

        assert!(
            password_service
                .find_pending_by_user_id(&db, target.id)
                .await
                .expect("lookup")
                .is_none(),
            "the pending password change must be gone"
        );
    }

    /// The operator keeps working: only the target account is logged out.
    #[tokio::test]
    async fn admin_force_change_leaves_the_operators_own_sessions_alone() {
        let db = test_db().await;
        let admin = verified_user(&db).await;
        if !promote_to_admin(&db, admin.id).await {
            eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
            return;
        }
        let target = verified_user(&db).await;

        RefreshTokenService::create_refresh_token(&db, admin.id, 30)
            .await
            .expect("seed the operator's session");
        RefreshTokenService::create_refresh_token(&db, target.id, 30)
            .await
            .expect("seed the target's session");

        service()
            .admin_force_change(&db, admin.id, target.id, &fresh_email(), None)
            .await
            .expect("override");

        assert_eq!(
            live_sessions(&db, target.id).await,
            0,
            "the rewritten account must re-authenticate"
        );
        assert_eq!(
            live_sessions(&db, admin.id).await,
            1,
            "the operator must not be logged out by their own action"
        );
    }

    // ── Retention ───────────────────────────────────────────────────────────

    /// RFC 0041 test 28 — `new_email` is personal data in clear text: it must
    /// not outlive the retention window.
    #[tokio::test]
    async fn cleanup_expired_deletes_rows_past_the_retention_window() {
        let _serialised = RETENTION_GUARD.lock().await;
        let db = test_db().await;
        let user = verified_user(&db).await;

        service()
            .request_change(&db, user.id, &fresh_email(), PASSWORD)
            .await
            .expect("request");
        let mut active: pending_email_change::ActiveModel =
            pending_rows(&db, user.id).await.remove(0).into();
        active.expires_at =
            Set(Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 1));
        active.update(&db).await.expect("age the row");

        EmailChangeService::cleanup_expired(&db)
            .await
            .expect("cleanup");

        assert!(
            pending_rows(&db, user.id).await.is_empty(),
            "a row past retention must be hard-deleted"
        );
    }

    /// RFC 0041 test 29 — a merely expired row stays: the user who clicks late
    /// deserves "this link expired", not "unknown token".
    #[tokio::test]
    async fn cleanup_expired_keeps_rows_still_within_retention() {
        let _serialised = RETENTION_GUARD.lock().await;
        let db = test_db().await;
        let user = verified_user(&db).await;
        let candidate = fresh_email();

        service()
            .request_change(&db, user.id, &candidate, PASSWORD)
            .await
            .expect("request");
        let token = confirmation_token(&db, &candidate).await;
        expire(&db, pending_rows(&db, user.id).await.remove(0)).await;

        EmailChangeService::cleanup_expired(&db)
            .await
            .expect("cleanup");

        assert_eq!(pending_rows(&db, user.id).await.len(), 1);
        assert_eq!(
            EmailChangeService::inspect_confirmation_token(&db, &token)
                .await
                .expect("inspect"),
            EmailChangeLinkState::Expired,
            "within retention the link explains itself instead of looking unknown"
        );
    }

    /// RFC 0041 test 30 — retention is honoured even by a consumer that never
    /// starts the cleanup processor: the next request purges the user's own
    /// stale rows, and only those.
    #[tokio::test]
    async fn a_new_request_purges_the_users_own_stale_rows_only() {
        let _serialised = RETENTION_GUARD.lock().await;
        let db = test_db().await;
        let user = verified_user(&db).await;
        let neighbour = verified_user(&db).await;
        let svc = service();

        for owner in [&user, &neighbour] {
            svc.request_change(&db, owner.id, &fresh_email(), PASSWORD)
                .await
                .expect("seed a request");
            let mut active: pending_email_change::ActiveModel =
                pending_rows(&db, owner.id).await.remove(0).into();
            active.expires_at =
                Set(Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 1));
            active.update(&db).await.expect("age the row");
        }

        svc.request_change(&db, user.id, &fresh_email(), PASSWORD)
            .await
            .expect("a fresh request triggers the opportunistic purge");

        let rows = pending_rows(&db, user.id).await;
        assert_eq!(rows.len(), 1, "only the fresh request must remain");
        assert!(rows[0].is_pending());
        assert_eq!(
            pending_rows(&db, neighbour.id).await.len(),
            1,
            "the purge must stay scoped to the acting user"
        );
    }

    // ── Locales ─────────────────────────────────────────────────────────────

    /// Every string this service and the interstitial pages resolve, in the six
    /// shipped locales.
    ///
    /// A missing key is silently replaced by the English fallback baked into
    /// the code, so a gap never shows up as a failure at runtime — only as a
    /// German user reading an English sentence. This test is the only place
    /// that gap becomes visible.
    #[test]
    fn the_rfc_0041_keys_exist_in_the_six_locales() {
        use yaml_rust2::YamlLoader;

        const LOCALES: [(&str, &str); 6] = [
            ("de", include_str!("../../locales/de.yml")),
            ("en", include_str!("../../locales/en.yml")),
            ("es", include_str!("../../locales/es.yml")),
            ("fr", include_str!("../../locales/fr.yml")),
            ("it", include_str!("../../locales/it.yml")),
            ("pt", include_str!("../../locales/pt.yml")),
        ];

        let keys_of = |source: &str, block: &str| -> Vec<String> {
            let docs = YamlLoader::load_from_str(source).expect("valid YAML");
            let mut keys: Vec<String> = docs[0][block]
                .as_hash()
                .unwrap_or_else(|| panic!("block `{block}` is missing"))
                .keys()
                .filter_map(|k| k.as_str().map(str::to_string))
                .collect();
            keys.sort();
            keys
        };

        for block in ["email_change", "email_change_page"] {
            let reference = keys_of(LOCALES[1].1, block);
            assert!(
                !reference.is_empty(),
                "en.yml must define the `{block}` block"
            );
            for (locale, source) in LOCALES {
                assert_eq!(
                    keys_of(source, block),
                    reference,
                    "{locale}.yml must define exactly the `{block}` keys of en.yml"
                );
            }
        }
    }
}