//! Broadcast email to followers. use axum::{ extract::State, response::{Html, IntoResponse, Response}, Form, }; use serde::Deserialize; use crate::{ auth::AuthUser, constants, db, error::{AppError, Result}, templates::FormStatusTemplate, AppState, }; /// Form input for broadcasting to followers. #[derive(Debug, Deserialize)] pub struct BroadcastForm { pub subject: String, pub body: String, } /// Send a plain-text broadcast email to all followers. #[tracing::instrument(skip_all, name = "users::broadcast_send")] pub(in crate::routes::api) async fn broadcast_send( State(state): State, AuthUser(user): AuthUser, Form(form): Form, ) -> Result { user.check_not_sandbox()?; user.check_not_suspended()?; // Only creators can broadcast if !user.can_create_projects { return Ok(Html(FormStatusTemplate { success: false, message: "Creator access required".to_string(), }.render_string()).into_response()); } // Validate subject and body let subject = form.subject.trim(); let body = form.body.trim(); if subject.is_empty() || subject.chars().count() > 200 { return Ok(Html(FormStatusTemplate { success: false, message: "Subject must be between 1 and 200 characters".to_string(), }.render_string()).into_response()); } if body.is_empty() || body.chars().count() > 5000 { return Ok(Html(FormStatusTemplate { success: false, message: "Body must be between 1 and 5000 characters".to_string(), }.render_string()).into_response()); } // Rate limit: one broadcast per 24 hours if !db::users::try_set_broadcast_at(&state.db, user.id).await? { return Ok(Html(FormStatusTemplate { success: false, message: "You can only send one broadcast per 24 hours".to_string(), }.render_string()).into_response()); } // Get follower emails let followers = db::follows::get_follower_emails(&state.db, user.id).await?; let count = followers.len(); if count == 0 { return Ok(Html(FormStatusTemplate { success: true, message: "No followers to notify".to_string(), }.render_string()).into_response()); } if count > constants::BROADCAST_MAX_RECIPIENTS { // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap. let _ = db::users::clear_broadcast_at(&state.db, user.id).await; return Ok(Html(FormStatusTemplate { success: false, message: format!( "Broadcast would reach {} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account.", count ), }.render_string()).into_response()); } // Get creator name let db_user = db::users::get_user_by_id(&state.db, user.id) .await? .ok_or(AppError::NotFound)?; let creator_name = db_user.display_name.as_deref() .unwrap_or(&db_user.username); // Send to each follower (fire-and-forget) let subject = subject.to_string(); let body = body.to_string(); let creator_name = creator_name.to_string(); let creator_id = user.id; let email_client = state.email.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); tokio::spawn(async move { let mut set = tokio::task::JoinSet::new(); let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS); for follower in followers { if set.len() >= constants::BROADCAST_PARALLELISM { let _ = set.join_next().await; } let email_client = email_client.clone(); let host_url = host_url.clone(); let signing_secret = signing_secret.clone(); let creator_name = creator_name.clone(); let subject = subject.clone(); let body = body.clone(); let creator_id_str = creator_id.to_string(); set.spawn(async move { let unsub_url = crate::email::generate_unsubscribe_url( &host_url, follower.id, crate::email::UnsubscribeAction::Broadcast, &creator_id_str, &signing_secret, ); if let Err(e) = email_client.send_broadcast( &follower.email, follower.display_name.as_deref(), &creator_name, &subject, &body, Some(&unsub_url), ).await { tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed"); } }); tokio::time::sleep(chunk_delay).await; } while set.join_next().await.is_some() {} }); tracing::info!(user_id = %user.id, recipient_count = count, "broadcast sent"); Ok(Html(FormStatusTemplate { success: true, message: format!("Broadcast sent to {} follower{}", count, if count == 1 { "" } else { "s" }), }.render_string()).into_response()) }