Skip to main content

max / makenotwork

9.4 KB · 265 lines History Blame Raw
1 //! Release and blog post announcement emails via project mailing lists.
2
3 use crate::db;
4 use crate::db::{DbBlogPost, DbItem};
5 use crate::AppState;
6
7 /// Atomically mark an item as release-announced and send subscriber emails
8 /// via the project's content mailing list.
9 ///
10 /// Shared between the manual publish handler (`routes/api/items.rs`) and the
11 /// scheduler. Safe to call multiple times — `mark_release_announced`
12 /// is a no-op if the item was already announced.
13 pub async fn send_release_announcements(state: &AppState, item: &DbItem) {
14 if !db::items::mark_release_announced(&state.db, item.id)
15 .await
16 .unwrap_or(false)
17 {
18 return;
19 }
20
21 // Skip email delivery for web-only items
22 if item.web_only {
23 return;
24 }
25
26 let Ok(Some(project)) = db::projects::get_project_by_id(&state.db, item.project_id).await
27 else {
28 return;
29 };
30 let Ok(Some(creator)) = db::users::get_user_by_id(&state.db, project.user_id).await else {
31 return;
32 };
33 let Ok(Some(list)) = db::mailing_lists::get_list_by_project_and_type(
34 &state.db,
35 item.project_id,
36 db::MailingListType::Content,
37 )
38 .await
39 else {
40 return;
41 };
42 let Ok(subscribers) = db::mailing_lists::get_subscriber_emails(&state.db, list.id).await
43 else {
44 return;
45 };
46
47 let creator_name = creator
48 .display_name
49 .as_deref()
50 .unwrap_or(&creator.username)
51 .to_string();
52 let item_title = item.title.clone();
53 let item_url = format!("{}/i/{}", state.config.host_url, item.id);
54 let email_client = state.email.clone();
55 let host_url = state.config.host_url.clone();
56 let signing_secret = state.config.signing_secret.clone();
57 let list_id_str = list.id.to_string();
58
59 tokio::spawn(async move {
60 for (i, subscriber) in subscribers.iter().enumerate() {
61 // Rate-limit: pause briefly every 50 emails to avoid hammering Postmark
62 if i > 0 && i % 50 == 0 {
63 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
64 }
65 let unsub_url = crate::email::generate_unsubscribe_url(
66 &host_url,
67 subscriber.id,
68 crate::email::UnsubscribeAction::MailingList,
69 &list_id_str,
70 &signing_secret,
71 );
72 if let Err(e) = email_client
73 .send_release_announcement(
74 &subscriber.email,
75 subscriber.display_name.as_deref(),
76 &creator_name,
77 &item_title,
78 &item_url,
79 Some(&unsub_url),
80 )
81 .await
82 {
83 tracing::error!(
84 error = ?e,
85 "failed to send release announcement email"
86 );
87 }
88 }
89 });
90 }
91
92 /// Atomically mark a blog post as announced and send subscriber emails
93 /// via the project's content mailing list.
94 ///
95 /// Shared between the blog post publish handlers and the scheduler.
96 /// Safe to call multiple times — `mark_blog_post_announced` is a no-op
97 /// if the post was already announced.
98 pub async fn send_blog_post_announcements(state: &AppState, post: &DbBlogPost) {
99 if !db::blog_posts::mark_blog_post_announced(&state.db, post.id)
100 .await
101 .unwrap_or(false)
102 {
103 return;
104 }
105
106 // Skip email delivery for web-only posts
107 if post.web_only {
108 return;
109 }
110
111 let Ok(Some(project)) = db::projects::get_project_by_id(&state.db, post.project_id).await
112 else {
113 return;
114 };
115 let Ok(Some(creator)) = db::users::get_user_by_id(&state.db, project.user_id).await else {
116 return;
117 };
118 let Ok(Some(list)) = db::mailing_lists::get_list_by_project_and_type(
119 &state.db,
120 post.project_id,
121 db::MailingListType::Content,
122 )
123 .await
124 else {
125 return;
126 };
127 let Ok(subscribers) = db::mailing_lists::get_subscriber_emails(&state.db, list.id).await
128 else {
129 return;
130 };
131
132 let creator_name = creator
133 .display_name
134 .as_deref()
135 .unwrap_or(&creator.username)
136 .to_string();
137 let post_title = post.title.clone();
138 let post_url = format!(
139 "{}/{}/blog/{}",
140 state.config.host_url, project.slug, post.slug
141 );
142 let email_client = state.email.clone();
143 let host_url = state.config.host_url.clone();
144 let signing_secret = state.config.signing_secret.clone();
145 let list_id_str = list.id.to_string();
146
147 tokio::spawn(async move {
148 for (i, subscriber) in subscribers.iter().enumerate() {
149 // Rate-limit: pause briefly every 50 emails to avoid hammering Postmark
150 if i > 0 && i % 50 == 0 {
151 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
152 }
153 let unsub_url = crate::email::generate_unsubscribe_url(
154 &host_url,
155 subscriber.id,
156 crate::email::UnsubscribeAction::MailingList,
157 &list_id_str,
158 &signing_secret,
159 );
160 if let Err(e) = email_client
161 .send_blog_post_announcement(
162 &subscriber.email,
163 subscriber.display_name.as_deref(),
164 &creator_name,
165 &post_title,
166 &post_url,
167 Some(&unsub_url),
168 )
169 .await
170 {
171 tracing::error!(
172 error = ?e,
173 "failed to send blog post announcement email"
174 );
175 }
176 }
177 });
178 }
179
180 /// Onboarding email drip steps (maps to `onboarding_email_step` i16 column).
181 #[allow(clippy::enum_variant_names)]
182 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
183 #[repr(i16)]
184 enum OnboardingStep {
185 /// Welcome email sent at signup.
186 WelcomeSent = 1,
187 /// Profile tips email (24h after welcome).
188 ProfileTipsSent = 2,
189 /// Stripe guide email (72h after welcome).
190 StripeGuideSent = 3,
191 }
192
193 impl OnboardingStep {
194 fn as_i16(self) -> i16 { self as i16 }
195 }
196
197 /// Process the getting-started email drip sequence.
198 ///
199 /// Step 1 (welcome) is sent at signup in the auth handler.
200 /// Step 2 (profile tips) fires 24h after welcome, skipped if display_name is set.
201 /// Step 3 (Stripe guide) fires 72h after welcome, skipped if Stripe is connected.
202 pub(super) async fn send_onboarding_emails(state: &AppState) {
203 let host_url = &state.config.host_url;
204
205 // Step 1→2: profile tips (24h after welcome)
206 let next = OnboardingStep::ProfileTipsSent;
207 if let Ok(users) =
208 db::users::get_onboarding_candidates(&state.db, OnboardingStep::WelcomeSent.as_i16(), chrono::Duration::hours(24)).await
209 {
210 // Batch-advance users who already set a display name (skip email)
211 let (skip, send): (Vec<_>, Vec<_>) =
212 users.into_iter().partition(|u| u.display_name.is_some());
213 if !skip.is_empty() {
214 let skip_ids: Vec<_> = skip.iter().map(|u| u.id).collect();
215 if let Err(e) = db::users::batch_advance_onboarding_step(&state.db, &skip_ids, next.as_i16()).await {
216 tracing::warn!(count = skip_ids.len(), step = ?next, error = ?e, "failed to batch advance onboarding step");
217 }
218 }
219 for user in send {
220 // Advance step BEFORE sending email to prevent duplicates on DB failure.
221 // Missing a non-critical onboarding email is better than sending it twice.
222 if let Err(e) = db::users::advance_onboarding_step(&state.db, user.id, next.as_i16()).await {
223 tracing::warn!(user_id = %user.id, step = ?next, error = ?e, "failed to advance onboarding step");
224 continue;
225 }
226 if let Err(e) = state
227 .email
228 .send_onboarding_profile(&user.email, user.display_name.as_deref(), host_url)
229 .await
230 {
231 tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding profile email");
232 }
233 }
234 }
235
236 // Step 2→3: Stripe guide (72h after welcome)
237 let next = OnboardingStep::StripeGuideSent;
238 if let Ok(users) =
239 db::users::get_onboarding_candidates(&state.db, OnboardingStep::ProfileTipsSent.as_i16(), chrono::Duration::hours(48)).await
240 {
241 // Batch-advance users who already connected Stripe (skip email)
242 let (skip, send): (Vec<_>, Vec<_>) =
243 users.into_iter().partition(|u| u.stripe_account_id.is_some());
244 if !skip.is_empty() {
245 let skip_ids: Vec<_> = skip.iter().map(|u| u.id).collect();
246 if let Err(e) = db::users::batch_advance_onboarding_step(&state.db, &skip_ids, next.as_i16()).await {
247 tracing::warn!(count = skip_ids.len(), step = ?next, error = ?e, "failed to batch advance onboarding step");
248 }
249 }
250 for user in send {
251 if let Err(e) = db::users::advance_onboarding_step(&state.db, user.id, next.as_i16()).await {
252 tracing::warn!(user_id = %user.id, step = ?next, error = ?e, "failed to advance onboarding step");
253 continue;
254 }
255 if let Err(e) = state
256 .email
257 .send_onboarding_stripe(&user.email, user.display_name.as_deref(), host_url)
258 .await
259 {
260 tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding stripe email");
261 }
262 }
263 }
264 }
265