Skip to main content

max / makenotwork

7.8 KB · 237 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 // The fan-out, recorded before it happens so a complaint about it can be
145 // traced back here (`93f23f00`). No list: a broadcast goes to a creator's
146 // followers, which is not one. `None` on failure and the mail still goes --
147 // a broadcast is worth more than its attribution, and the cost is a
148 // denominator rather than a send.
149 let send = db::mail_attribution::record_send(
150 &db,
151 user.id,
152 None,
153 db::mail_attribution::SendKind::Broadcast,
154 i64::try_from(count).unwrap_or(i64::MAX),
155 )
156 .await
157 .inspect_err(|error| {
158 tracing::warn!(error = ?error, user_id = %user.id,
159 "could not record the send; this broadcast will be unattributed");
160 })
161 .ok();
162
163 // Send to each follower (fire-and-forget)
164 let subject = subject.to_string();
165 let body = body.to_string();
166 let creator_name = creator_name.to_string();
167 let creator_id = user.id;
168 let email_client = email.clone();
169 let host_url = config.host_url.clone();
170 let signing_secret = config.signing_secret.clone();
171
172 let followers = recipients.into_inner();
173 tokio::spawn(async move {
174 let mut set = tokio::task::JoinSet::new();
175 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
176
177 for follower in followers {
178 if set.len() >= constants::BROADCAST_PARALLELISM {
179 let _ = set.join_next().await;
180 }
181
182 let email_client = email_client.clone();
183 let host_url = host_url.clone();
184 let signing_secret = signing_secret.clone();
185 let creator_name = creator_name.clone();
186 let subject = subject.clone();
187 let body = body.clone();
188 let creator_id_str = creator_id.to_string();
189
190 set.spawn(async move {
191 let unsub_url = crate::email::generate_unsubscribe_url(
192 &host_url,
193 follower.id,
194 crate::email::UnsubscribeAction::Broadcast,
195 &creator_id_str,
196 &signing_secret,
197 );
198 if let Err(e) = email_client
199 .send_broadcast(
200 &follower.email,
201 follower.display_name.as_deref(),
202 &creator_name,
203 &subject,
204 &body,
205 crate::email::Fanout {
206 unsub_url: Some(&unsub_url),
207 send,
208 },
209 )
210 .await
211 {
212 tracing::warn!(error = ?e, to = %follower.email, "broadcast email failed");
213 }
214 });
215
216 tokio::time::sleep(chunk_delay).await;
217 }
218
219 while set.join_next().await.is_some() {}
220 });
221
222 tracing::info!(user_id = %user.id, recipient_count = count, "broadcast sent");
223
224 Ok(Html(
225 FormStatusTemplate {
226 success: true,
227 message: format!(
228 "Broadcast sent to {} follower{}",
229 count,
230 if count == 1 { "" } else { "s" }
231 ),
232 }
233 .render_string()?,
234 )
235 .into_response())
236 }
237