Skip to main content

max / makenotwork

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