Skip to main content

max / makenotwork

5.4 KB · 156 lines History Blame Raw
1 //! Broadcast email to followers.
2
3 use axum::{
4 extract::State,
5 response::{Html, IntoResponse, Response},
6 Form,
7 };
8 use serde::Deserialize;
9
10 use crate::{
11 auth::AuthUser,
12 constants,
13 db,
14 error::{AppError, Result},
15 templates::FormStatusTemplate,
16 AppState,
17 };
18
19 /// Form input for broadcasting to followers.
20 #[derive(Debug, Deserialize)]
21 pub struct BroadcastForm {
22 pub subject: String,
23 pub body: String,
24 }
25
26 /// Send a plain-text broadcast email to all followers.
27 #[tracing::instrument(skip_all, name = "users::broadcast_send")]
28 pub(in crate::routes::api) async fn broadcast_send(
29 State(state): State<AppState>,
30 AuthUser(user): AuthUser,
31 Form(form): Form<BroadcastForm>,
32 ) -> Result<Response> {
33 user.check_not_sandbox()?;
34 user.check_not_suspended()?;
35 // Only creators can broadcast
36 if !user.can_create_projects {
37 return Ok(Html(FormStatusTemplate {
38 success: false,
39 message: "Creator access required".to_string(),
40 }.render_string()).into_response());
41 }
42
43 // Validate subject and body
44 let subject = form.subject.trim();
45 let body = form.body.trim();
46
47 if subject.is_empty() || subject.chars().count() > 200 {
48 return Ok(Html(FormStatusTemplate {
49 success: false,
50 message: "Subject must be between 1 and 200 characters".to_string(),
51 }.render_string()).into_response());
52 }
53
54 if body.is_empty() || body.chars().count() > 5000 {
55 return Ok(Html(FormStatusTemplate {
56 success: false,
57 message: "Body must be between 1 and 5000 characters".to_string(),
58 }.render_string()).into_response());
59 }
60
61 // Rate limit: one broadcast per 24 hours
62 if !db::users::try_set_broadcast_at(&state.db, user.id).await? {
63 return Ok(Html(FormStatusTemplate {
64 success: false,
65 message: "You can only send one broadcast per 24 hours".to_string(),
66 }.render_string()).into_response());
67 }
68
69 // Get follower emails
70 let followers = db::follows::get_follower_emails(&state.db, user.id).await?;
71 let count = followers.len();
72
73 if count == 0 {
74 return Ok(Html(FormStatusTemplate {
75 success: true,
76 message: "No followers to notify".to_string(),
77 }.render_string()).into_response());
78 }
79
80 if count > constants::BROADCAST_MAX_RECIPIENTS {
81 // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap.
82 let _ = db::users::clear_broadcast_at(&state.db, user.id).await;
83 return Ok(Html(FormStatusTemplate {
84 success: false,
85 message: format!(
86 "Broadcast would reach {} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account.",
87 count
88 ),
89 }.render_string()).into_response());
90 }
91
92 // Get creator name
93 let db_user = db::users::get_user_by_id(&state.db, user.id)
94 .await?
95 .ok_or(AppError::NotFound)?;
96 let creator_name = db_user.display_name.as_deref()
97 .unwrap_or(&db_user.username);
98
99 // Send to each follower (fire-and-forget)
100 let subject = subject.to_string();
101 let body = body.to_string();
102 let creator_name = creator_name.to_string();
103 let creator_id = user.id;
104 let email_client = state.email.clone();
105 let host_url = state.config.host_url.clone();
106 let signing_secret = state.config.signing_secret.clone();
107
108 tokio::spawn(async move {
109 let mut set = tokio::task::JoinSet::new();
110 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
111
112 for follower in followers {
113 if set.len() >= constants::BROADCAST_PARALLELISM {
114 let _ = set.join_next().await;
115 }
116
117 let email_client = email_client.clone();
118 let host_url = host_url.clone();
119 let signing_secret = signing_secret.clone();
120 let creator_name = creator_name.clone();
121 let subject = subject.clone();
122 let body = body.clone();
123 let creator_id_str = creator_id.to_string();
124
125 set.spawn(async move {
126 let unsub_url = crate::email::generate_unsubscribe_url(
127 &host_url, follower.id,
128 crate::email::UnsubscribeAction::Broadcast,
129 &creator_id_str, &signing_secret,
130 );
131 if let Err(e) = email_client.send_broadcast(
132 &follower.email,
133 follower.display_name.as_deref(),
134 &creator_name,
135 &subject,
136 &body,
137 Some(&unsub_url),
138 ).await {
139 tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed");
140 }
141 });
142
143 tokio::time::sleep(chunk_delay).await;
144 }
145
146 while set.join_next().await.is_some() {}
147 });
148
149 tracing::info!(user_id = %user.id, recipient_count = count, "broadcast sent");
150
151 Ok(Html(FormStatusTemplate {
152 success: true,
153 message: format!("Broadcast sent to {} follower{}", count, if count == 1 { "" } else { "s" }),
154 }.render_string()).into_response())
155 }
156