//! Account deletion confirmation and unsubscribe handlers. use crate::extractors::ValidatedQuery; use axum::{ Form, extract::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, ValidatedQuery(query): ValidatedQuery, ) -> 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, /// Subscription-keyed variant over the unified tables. Takes precedence /// over both older forms: it identifies one subscription exactly, which is /// what one-click needs, and through it the subscriber, which is what the /// preferences page needs. pub sub: 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, ValidatedQuery(query): ValidatedQuery, ) -> 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() }; // A subscription-keyed link opens the preferences page. The older forms // unsubscribe on GET, which they should not, but changing that would break // links already sitting in inboxes; they retire with their senders. if let Some(sub) = &query.sub && let Some(sig) = &query.sig && let Some(page) = preferences_page(&db, &config, sub, sig).await? { return Ok(page); } 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, ValidatedQuery(query): ValidatedQuery, ) -> Result { // One-click, per RFC 8058: unsubscribe exactly the list the mail came from, // no confirmation step and no page. Required lists are refused, since // nothing should have sent a one-click header for one. if let Some(sub) = &query.sub && let Some(sig) = &query.sig { let Some(subscription_id) = verified_subscription(sub, sig, &config) else { return Err(AppError::BadRequest("Invalid unsubscribe link".to_string())); }; if db::lists::subscription_is_required(&db, subscription_id).await? { return Err(AppError::BadRequest( "This list cannot be unsubscribed from.".to_string(), )); } // Idempotent: a retried one-click reports success rather than failing. db::lists::unsubscribe(&db, subscription_id, db::ConsentEvent::OptOut).await?; return Ok(axum::http::StatusCode::OK.into_response()); } match dispatch_unsubscribe(&db, &config, &query).await? { Some(_) => Ok(axum::http::StatusCode::OK.into_response()), None => Err(AppError::BadRequest("Invalid unsubscribe link".to_string())), } } /// Form body for the preferences-page actions. The signed token travels with /// every form, because it is the authorisation and there is no session. #[derive(Debug, Deserialize)] pub(super) struct PreferencesForm { pub sub: String, pub sig: String, /// The subscription the row acts on. Verified to belong to the same /// subscriber as `sub`, so a valid token cannot be aimed at a stranger. pub target: Option, pub action: Option, } /// Parse and verify a subscription-keyed token. fn verified_subscription(sub: &str, sig: &str, config: &Config) -> Option { let id: uuid::Uuid = sub.parse().ok()?; email::verify_subscription_unsubscribe_signature(id, sig, &config.signing_secret) .then(|| db::ListSubscriptionId::from_uuid(id)) } /// Render the email preferences page. /// /// GET only ever renders. A mail client or link scanner prefetching the URL /// must not unsubscribe anybody, so every mutation on the page is a POST. #[tracing::instrument(skip_all, name = "email_actions::preferences_page")] async fn preferences_page( db: &PgPool, config: &Config, sub: &str, sig: &str, ) -> Result> { let Some(subscription_id) = verified_subscription(sub, sig, config) else { return Ok(None); }; let subscriptions = db::lists::subscriptions_for_peer(db, subscription_id).await?; Ok(Some( crate::templates::EmailPreferencesTemplate { csrf_token: None, token: sub.to_string(), signature: sig.to_string(), origin_subscription: subscription_id, subscriptions, } .into_response(), )) } /// POST /unsubscribe/list: toggle one list from the preferences page. #[tracing::instrument(skip_all, name = "email_actions::preferences_toggle")] pub(super) async fn preferences_toggle( State(db): State, State(config): State, Form(form): Form, ) -> Result { let Some(peer) = verified_subscription(&form.sub, &form.sig, &config) else { return Err(AppError::BadRequest("Invalid link".to_string())); }; let Some(target) = form .target .as_deref() .and_then(|t| t.parse::().ok()) else { return Err(AppError::BadRequest("Invalid target".to_string())); }; let target = db::ListSubscriptionId::from_uuid(target); // The token authorises one subscriber, not one subscription. Every row it // may act on is a row that came back for that subscriber, so checking // membership here is what stops a valid token being retargeted. let rows = db::lists::subscriptions_for_peer(&db, peer).await?; let Some(row) = rows.iter().find(|r| r.subscription_id == target) else { return Err(AppError::BadRequest("Invalid target".to_string())); }; if row.required { return Err(AppError::BadRequest( "This list cannot be unsubscribed from.".to_string(), )); } let enabling = form.action.as_deref() == Some("resubscribe"); if enabling { db::lists::resubscribe(&db, target).await?; } else { db::lists::unsubscribe(&db, target, db::ConsentEvent::OptOut).await?; } // Back to the page, so the change is visible where it was made. Ok( axum::response::Redirect::to(&format!("/unsubscribe?sub={}&sig={}", form.sub, form.sig)) .into_response(), ) } /// POST /unsubscribe/all: leave every list that may be left. #[tracing::instrument(skip_all, name = "email_actions::preferences_unsubscribe_all")] pub(super) async fn preferences_unsubscribe_all( State(db): State, State(config): State, Form(form): Form, ) -> Result { let Some(peer) = verified_subscription(&form.sub, &form.sig, &config) else { return Err(AppError::BadRequest("Invalid link".to_string())); }; let moved = db::lists::unsubscribe_peer_from_all(&db, peer).await?; Ok(EmailResultTemplate { csrf_token: None, title: "Unsubscribed".to_string(), message: format!( "You have been unsubscribed from {moved} list{}. You will still receive \ receipts and security notices, which are not marketing.", if moved == 1 { "" } else { "s" } ), link_url: "/".to_string(), link_text: "Back to Makenotwork".to_string(), } .into_response()) } /// 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()) } // The landing "notify me" list. `target` is unused: there is one such // list, so the address alone identifies the subscription. It is still // signed over, which keeps this token from being replayed as any other // action for the same address. // // An address that was already unsubscribed reports the same message // rather than an error. The person asked not to be mailed and is not // being mailed; telling them the link failed would be both untrue and // alarming, and one-click POSTs get retried. email::UnsubscribeAction::Signup => { db::email_signups::unsubscribe_email_signup(db, email_addr).await?; Ok("You will not receive further updates from Makenotwork.".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()) } UnsubscribeAction::Invite => { db::users::disable_notification(db, user_id, "invite").await?; Ok( "You will no longer receive email notifications when one of your invite \ codes is used." .to_string(), ) } // Signup is keyed on the address, not the account: the landing list // holds addresses that mostly have no user behind them, and an account // sharing an address with a signup row is a coincidence rather than a // link. Honouring a user-keyed token here would unsubscribe by an // association we never established. UnsubscribeAction::Signup => Err(AppError::BadRequest( "This unsubscribe link is invalid.".to_string(), )), } }