//! Account deletion confirmation and unsubscribe handlers. use axum::{ Form, extract::{Query, State}, response::{IntoResponse, Response}, }; use serde::Deserialize; use sqlx::PgPool; use tower_sessions::Session; use crate::{ config::Config, db::{self, UserId}, email, error::{AppError, Result}, helpers::{constant_time_compare, get_csrf_token}, templates::{AccountDeletedTemplate, ConfirmDeleteTemplate, EmailResultTemplate}, }; /// Query parameters for the signed account deletion confirmation link. #[derive(Debug, Deserialize)] pub(super) struct ConfirmDeleteQuery { pub user: String, pub expires: String, pub sig: String, } /// Form input for the POST account deletion confirmation. #[derive(Debug, Deserialize)] pub(super) 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( db: &PgPool, config: &Config, user_str: &str, expires_str: &str, sig: &str, ) -> Result> { 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 Some(user) = db::users::get_user_by_id(db, user_id).await? else { 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(&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(db): State, State(config): State, session: Session, Query(query): Query, ) -> Result { // Validate the deletion link parameters match validate_deletion_link(&db, &config, &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(db): State, State(config): State, State(mailer): State, State(caches): State, session: Session, Form(form): Form, ) -> Result { // Validate the deletion link parameters let user_id = match validate_deletion_link(&db, &config, &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(&db, user_id).await? { db::users::schedule_content_removal(&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 = db.clone(); let email_client = mailer.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 { crate::delete_user_account(&db, &caches, 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(super) struct UnsubscribeQuery { pub user: Option, /// Email-keyed variant for imported subscribers with no MNW account /// (mutually exclusive with `user`). pub email: 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(db): State, State(config): 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() }; match dispatch_unsubscribe(&db, &config, &query).await? { Some(message) => Ok(result("Unsubscribed", &message)), None => Ok(result("Invalid Link", "This unsubscribe link is invalid.")), } } /// 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(db): State, State(config): State, Query(query): Query, ) -> Result { match dispatch_unsubscribe(&db, &config, &query).await? { Some(_) => Ok(axum::http::StatusCode::OK.into_response()), None => Err(AppError::BadRequest("Invalid unsubscribe link".to_string())), } } /// Verify an unsubscribe query (user-keyed or email-keyed) and perform it. /// Returns `Some(message)` on success, `None` if the link is invalid/unverified. /// The email-keyed form serves imported subscribers who have no MNW account. async fn dispatch_unsubscribe( db: &PgPool, config: &Config, query: &UnsubscribeQuery, ) -> Result> { let (Some(action_str), Some(target), Some(sig)) = (&query.action, &query.target, &query.sig) else { return Ok(None); }; let Ok(action) = action_str.parse::() else { return Ok(None); }; // Email-keyed (imported, no MNW account) takes precedence when present. if let Some(email_addr) = &query.email { if !email::verify_email_unsubscribe_signature( email_addr, action, target, sig, &config.signing_secret, ) { return Ok(None); } return Ok(Some( perform_email_unsubscribe(db, email_addr, action, target).await?, )); } let Some(user_str) = &query.user else { return Ok(None); }; let Ok(user_id) = user_str.parse::() else { return Ok(None); }; if !email::verify_unsubscribe_signature(user_id, action, target, sig, &config.signing_secret) { return Ok(None); } Ok(Some( perform_unsubscribe(db, user_id, action, target).await?, )) } /// Perform an email-keyed unsubscribe (imported subscriber, no MNW account). /// Only mailing-list unsubscribe is meaningful without an account, the /// preference/follow actions all key on a user. async fn perform_email_unsubscribe( db: &PgPool, email_addr: &str, action: email::UnsubscribeAction, target: &str, ) -> Result { match action { email::UnsubscribeAction::MailingList => { let list_id: db::MailingListId = target .parse() .map_err(|_| AppError::BadRequest("Invalid mailing list ID".to_string()))?; db::mailing_lists::unsubscribe_by_email(db, list_id, email_addr).await?; Ok("You have been unsubscribed from this mailing list.".to_string()) } _ => Err(AppError::BadRequest( "This unsubscribe link is invalid.".to_string(), )), } } /// Execute the unsubscribe action and return a human-readable message. async fn perform_unsubscribe( db: &PgPool, 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(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(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(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(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(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(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(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(db, list_id, user_id).await?; Ok("You have been unsubscribed from this mailing list.".to_string()) } UnsubscribeAction::NotifyTip => { db::users::disable_notification(db, user_id, "notify_tip").await?; Ok("You will no longer receive email notifications for tips.".to_string()) } } }