//! Account deletion confirmation and unsubscribe handlers. use axum::{ extract::{Query, State}, response::{IntoResponse, Response}, Form, }; use serde::Deserialize; use tower_sessions::Session; use crate::{ db::{self, UserId}, email, error::{AppError, Result}, helpers::{constant_time_compare, get_csrf_token}, templates::*, AppState, }; /// Query parameters for the signed account deletion confirmation link. #[derive(Debug, Deserialize)] pub struct ConfirmDeleteQuery { pub user: String, pub expires: String, pub sig: String, } /// Form input for the POST account deletion confirmation. #[derive(Debug, Deserialize)] pub struct ConfirmDeleteForm { pub user: String, pub expires: String, pub sig: String, } /// Validate the deletion link parameters and return parsed values, or an error /// response if the link is expired or the signature is invalid. async fn validate_deletion_link( state: &AppState, user_str: &str, expires_str: &str, sig: &str, ) -> Result> { // Parse user ID let user_id: UserId = match user_str.parse() { Ok(id) => id, Err(_) => { return Ok(Err(EmailResultTemplate { csrf_token: None, title: "Invalid Link".to_string(), message: "This deletion link is invalid.".to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response())) } }; // Parse expiry let expires: i64 = match expires_str.parse() { Ok(e) => e, Err(_) => { return Ok(Err(EmailResultTemplate { csrf_token: None, title: "Invalid Link".to_string(), message: "This deletion link is invalid.".to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response())) } }; // Check if link has expired let now = chrono::Utc::now().timestamp(); if now > expires { return Ok(Err(EmailResultTemplate { csrf_token: None, title: "Link Expired".to_string(), message: "This deletion link has expired. Please request a new one from your account settings.".to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response())); } // Get user to verify they exist and get their email for signature verification let user = match db::users::get_user_by_id(&state.db, user_id).await? { Some(u) => u, None => { return Ok(Err(EmailResultTemplate { csrf_token: None, title: "Invalid Link".to_string(), message: "This deletion link is invalid.".to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response())) } }; // Verify signature let expected_sig = email::generate_deletion_signature(&state.config.signing_secret, user_id, expires, &user.email); if !constant_time_compare(sig, &expected_sig) { return Ok(Err(EmailResultTemplate { csrf_token: None, title: "Invalid Link".to_string(), message: "This deletion link is invalid.".to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response())); } Ok(Ok(user_id)) } /// Show the account deletion confirmation page (GET). /// /// Validates the signed link from the email, then renders a confirmation page /// with a POST form so that link prefetching by browsers and email clients /// cannot accidentally trigger the deletion. #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_page")] pub(super) async fn confirm_delete_page( State(state): State, session: Session, Query(query): Query, ) -> Result { // Validate the deletion link parameters match validate_deletion_link(&state, &query.user, &query.expires, &query.sig).await? { Err(error_response) => Ok(error_response), Ok(_user_id) => { let csrf_token = get_csrf_token(&session).await; Ok(ConfirmDeleteTemplate { csrf_token, user: query.user, expires: query.expires, sig: query.sig, } .into_response()) } } } /// Perform the actual account deletion (POST). /// /// Re-validates the signed link parameters from the form body, then deletes /// the account and destroys the session. #[tracing::instrument(skip_all, name = "email_actions::confirm_delete_handler")] pub(super) async fn confirm_delete_handler( State(state): State, session: Session, Form(form): Form, ) -> Result { // Validate the deletion link parameters let user_id = match validate_deletion_link(&state, &form.user, &form.expires, &form.sig).await? { Err(error_response) => return Ok(error_response), Ok(id) => id, }; // If creator has sales, schedule 90-day content grace period instead of immediate deletion if db::users::has_completed_sales(&state.db, user_id).await? { db::users::schedule_content_removal(&state.db, user_id).await?; tracing::info!(user_id = %user_id, event = "account_deletion_scheduled", "Creator account scheduled for removal with 90-day content grace period"); // Notify all buyers that this creator is leaving (fire-and-forget) let pool = state.db.clone(); let email_client = state.email.clone(); tokio::spawn(async move { let creator_name = match db::users::get_user_by_id(&pool, user_id).await { Ok(Some(u)) => u.display_name.unwrap_or_else(|| u.username.to_string()), _ => "A creator".to_string(), }; crate::email::send_creator_departure_notifications(&pool, &email_client, user_id, creator_name).await; }); } else { db::users::delete_user(&state.db, user_id).await?; tracing::info!(user_id = %user_id, event = "account_deleted", "Account permanently deleted via confirmed POST"); } // Destroy session let _ = session.flush().await; Ok(AccountDeletedTemplate { csrf_token: None }.into_response()) } // -- Unsubscribe -- /// Query parameters for unsubscribe links. #[derive(Debug, Deserialize)] pub struct UnsubscribeQuery { pub user: Option, pub action: Option, pub target: Option, pub sig: Option, } /// Show the unsubscribe confirmation page (GET). /// /// Verifies the signature and performs the unsubscribe action immediately. /// This handles clicks from email body links. #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_page")] pub(super) async fn unsubscribe_page( State(state): State, Query(query): Query, ) -> Result { let result = |title: &str, msg: &str| -> Response { EmailResultTemplate { csrf_token: None, title: title.to_string(), message: msg.to_string(), link_url: "/dashboard".to_string(), link_text: "Go to dashboard".to_string(), } .into_response() }; let (user_str, action_str, target, sig) = match (&query.user, &query.action, &query.target, &query.sig) { (Some(u), Some(a), Some(t), Some(s)) => (u.clone(), a.clone(), t.clone(), s.clone()), _ => return Ok(result("Invalid Link", "This unsubscribe link is invalid.")), }; let user_id: UserId = match user_str.parse() { Ok(id) => id, Err(_) => return Ok(result("Invalid Link", "This unsubscribe link is invalid.")), }; let action: email::UnsubscribeAction = match action_str.parse() { Ok(a) => a, Err(_) => return Ok(result("Invalid Link", "This unsubscribe link is invalid.")), }; if !email::verify_unsubscribe_signature( user_id, action, &target, &sig, &state.config.signing_secret, ) { return Ok(result("Invalid Link", "This unsubscribe link is invalid.")); } let message = perform_unsubscribe(&state, user_id, action, &target).await?; Ok(result("Unsubscribed", &message)) } /// Handle RFC 8058 one-click unsubscribe (POST). /// /// Email clients (Gmail, Apple Mail, etc.) send a POST with /// `List-Unsubscribe=One-Click` in the body. CSRF is exempted for this path. #[tracing::instrument(skip_all, name = "email_actions::unsubscribe_handler")] pub(super) async fn unsubscribe_handler( State(state): State, Query(query): Query, ) -> Result { let (user_str, action_str, target, sig) = match (&query.user, &query.action, &query.target, &query.sig) { (Some(u), Some(a), Some(t), Some(s)) => (u.clone(), a.clone(), t.clone(), s.clone()), _ => return Err(AppError::BadRequest("Invalid unsubscribe link".to_string())), }; let user_id: UserId = user_str .parse() .map_err(|_| AppError::BadRequest("Invalid user ID".to_string()))?; let action: email::UnsubscribeAction = action_str .parse() .map_err(|_| AppError::BadRequest("Invalid unsubscribe action".to_string()))?; if !email::verify_unsubscribe_signature( user_id, action, &target, &sig, &state.config.signing_secret, ) { return Err(AppError::BadRequest( "Invalid unsubscribe signature".to_string(), )); } perform_unsubscribe(&state, user_id, action, &target).await?; // RFC 8058 expects a 200 response Ok(axum::http::StatusCode::OK.into_response()) } /// Execute the unsubscribe action and return a human-readable message. async fn perform_unsubscribe( state: &AppState, user_id: UserId, action: email::UnsubscribeAction, target: &str, ) -> Result { use email::UnsubscribeAction; match action { UnsubscribeAction::Broadcast => { // Unfollow the creator let target_id: UserId = target .parse() .map_err(|_| AppError::BadRequest("Invalid target".to_string()))?; db::follows::unfollow( &state.db, user_id, db::FollowTargetType::User, target_id.into(), ) .await?; Ok("You have unfollowed this creator and will no longer receive their broadcasts." .to_string()) } UnsubscribeAction::Release => { db::users::disable_notification(&state.db, user_id, "notify_release").await?; Ok("You will no longer receive emails about new releases from creators you follow." .to_string()) } UnsubscribeAction::Sale => { db::users::disable_notification(&state.db, user_id, "notify_sale").await?; Ok("You will no longer receive email notifications when someone buys your content." .to_string()) } UnsubscribeAction::Follower => { db::users::disable_notification(&state.db, user_id, "notify_follower").await?; Ok( "You will no longer receive email notifications for new followers." .to_string(), ) } UnsubscribeAction::Login => { db::users::disable_notification(&state.db, user_id, "login_notification_enabled") .await?; Ok("You will no longer receive email notifications for new device sign-ins." .to_string()) } UnsubscribeAction::Issue => { db::users::disable_notification(&state.db, user_id, "notify_issues").await?; Ok("You will no longer receive email notifications for issues on your repositories." .to_string()) } UnsubscribeAction::Status => { db::users::disable_notification(&state.db, user_id, "notify_status").await?; Ok("You will no longer receive platform status notifications.".to_string()) } UnsubscribeAction::MailingList => { let list_id: db::MailingListId = target .parse() .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?; db::mailing_lists::unsubscribe(&state.db, list_id, user_id).await?; Ok("You have been unsubscribed from this mailing list.".to_string()) } UnsubscribeAction::NotifyTip => { db::users::disable_notification(&state.db, user_id, "notify_tip").await?; Ok("You will no longer receive email notifications for tips.".to_string()) } } }