Skip to main content

max / makenotwork

6.9 KB · 215 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 // The monthly allowance, on top of the per-send cap above and the 24h slot
108 // below it. Those two bound one send and its rate; this one bounds the count
109 // over the billing month, which is the number the shared Postmark IP pool's
110 // reputation actually follows (`db::mail_caps`).
111 let verdict =
112 db::mail_caps::reserve(&db, user.id, i64::try_from(count).unwrap_or(i64::MAX)).await?;
113 if let Some(message) = verdict.refusal_message() {
114 // Give the 24h slot back: the creator has not spent their broadcast on
115 // a send that never left, and the same rollback the per-send cap does.
116 let _ = db::users::clear_broadcast_at(&db, user.id).await;
117 return Ok(Html(
118 FormStatusTemplate {
119 success: false,
120 message,
121 }
122 .render_string()?,
123 )
124 .into_response());
125 }
126
127 if count == 0 {
128 return Ok(Html(
129 FormStatusTemplate {
130 success: true,
131 message: "No followers to notify".to_string(),
132 }
133 .render_string()?,
134 )
135 .into_response());
136 }
137
138 // Get creator name
139 let db_user = db::users::get_user_by_id(&db, user.id)
140 .await?
141 .ok_or(AppError::NotFound)?;
142 let creator_name = db_user.display_name.as_deref().unwrap_or(&db_user.username);
143
144 // Send to each follower (fire-and-forget)
145 let subject = subject.to_string();
146 let body = body.to_string();
147 let creator_name = creator_name.to_string();
148 let creator_id = user.id;
149 let email_client = email.clone();
150 let host_url = config.host_url.clone();
151 let signing_secret = config.signing_secret.clone();
152
153 let followers = recipients.into_inner();
154 tokio::spawn(async move {
155 let mut set = tokio::task::JoinSet::new();
156 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
157
158 for follower in followers {
159 if set.len() >= constants::BROADCAST_PARALLELISM {
160 let _ = set.join_next().await;
161 }
162
163 let email_client = email_client.clone();
164 let host_url = host_url.clone();
165 let signing_secret = signing_secret.clone();
166 let creator_name = creator_name.clone();
167 let subject = subject.clone();
168 let body = body.clone();
169 let creator_id_str = creator_id.to_string();
170
171 set.spawn(async move {
172 let unsub_url = crate::email::generate_unsubscribe_url(
173 &host_url,
174 follower.id,
175 crate::email::UnsubscribeAction::Broadcast,
176 &creator_id_str,
177 &signing_secret,
178 );
179 if let Err(e) = email_client
180 .send_broadcast(
181 &follower.email,
182 follower.display_name.as_deref(),
183 &creator_name,
184 &subject,
185 &body,
186 Some(&unsub_url),
187 )
188 .await
189 {
190 tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed");
191 }
192 });
193
194 tokio::time::sleep(chunk_delay).await;
195 }
196
197 while set.join_next().await.is_some() {}
198 });
199
200 tracing::info!(user_id = %user.id, recipient_count = count, "broadcast sent");
201
202 Ok(Html(
203 FormStatusTemplate {
204 success: true,
205 message: format!(
206 "Broadcast sent to {} follower{}",
207 count,
208 if count == 1 { "" } else { "s" }
209 ),
210 }
211 .render_string()?,
212 )
213 .into_response())
214 }
215