//! Release and blog post announcement emails via project mailing lists. use crate::db; use crate::db::{DbBlogPost, DbItem}; use crate::AppState; /// Atomically mark an item as release-announced and send subscriber emails /// via the project's content mailing list. /// /// Shared between the manual publish handler (`routes/api/items.rs`) and the /// scheduler. Safe to call multiple times — `mark_release_announced` /// is a no-op if the item was already announced. pub async fn send_release_announcements(state: &AppState, item: &DbItem) { if !db::items::mark_release_announced(&state.db, item.id) .await .unwrap_or(false) { return; } // Skip email delivery for web-only items if item.web_only { return; } let Ok(Some(project)) = db::projects::get_project_by_id(&state.db, item.project_id).await else { return; }; let Ok(Some(creator)) = db::users::get_user_by_id(&state.db, project.user_id).await else { return; }; let Ok(Some(list)) = db::mailing_lists::get_list_by_project_and_type( &state.db, item.project_id, db::MailingListType::Content, ) .await else { return; }; let Ok(subscribers) = db::mailing_lists::get_subscriber_emails(&state.db, list.id).await else { return; }; let creator_name = creator .display_name .as_deref() .unwrap_or(&creator.username) .to_string(); let item_title = item.title.clone(); let item_url = format!("{}/i/{}", state.config.host_url, item.id); let email_client = state.email.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); let list_id_str = list.id.to_string(); tokio::spawn(async move { for (i, subscriber) in subscribers.iter().enumerate() { // Rate-limit: pause briefly every 50 emails to avoid hammering Postmark if i > 0 && i % 50 == 0 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } let unsub_url = crate::email::generate_unsubscribe_url( &host_url, subscriber.id, crate::email::UnsubscribeAction::MailingList, &list_id_str, &signing_secret, ); if let Err(e) = email_client .send_release_announcement( &subscriber.email, subscriber.display_name.as_deref(), &creator_name, &item_title, &item_url, Some(&unsub_url), ) .await { tracing::error!( error = ?e, "failed to send release announcement email" ); } } }); } /// Atomically mark a blog post as announced and send subscriber emails /// via the project's content mailing list. /// /// Shared between the blog post publish handlers and the scheduler. /// Safe to call multiple times — `mark_blog_post_announced` is a no-op /// if the post was already announced. pub async fn send_blog_post_announcements(state: &AppState, post: &DbBlogPost) { if !db::blog_posts::mark_blog_post_announced(&state.db, post.id) .await .unwrap_or(false) { return; } // Skip email delivery for web-only posts if post.web_only { return; } let Ok(Some(project)) = db::projects::get_project_by_id(&state.db, post.project_id).await else { return; }; let Ok(Some(creator)) = db::users::get_user_by_id(&state.db, project.user_id).await else { return; }; let Ok(Some(list)) = db::mailing_lists::get_list_by_project_and_type( &state.db, post.project_id, db::MailingListType::Content, ) .await else { return; }; let Ok(subscribers) = db::mailing_lists::get_subscriber_emails(&state.db, list.id).await else { return; }; let creator_name = creator .display_name .as_deref() .unwrap_or(&creator.username) .to_string(); let post_title = post.title.clone(); let post_url = format!( "{}/{}/blog/{}", state.config.host_url, project.slug, post.slug ); let email_client = state.email.clone(); let host_url = state.config.host_url.clone(); let signing_secret = state.config.signing_secret.clone(); let list_id_str = list.id.to_string(); tokio::spawn(async move { for (i, subscriber) in subscribers.iter().enumerate() { // Rate-limit: pause briefly every 50 emails to avoid hammering Postmark if i > 0 && i % 50 == 0 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; } let unsub_url = crate::email::generate_unsubscribe_url( &host_url, subscriber.id, crate::email::UnsubscribeAction::MailingList, &list_id_str, &signing_secret, ); if let Err(e) = email_client .send_blog_post_announcement( &subscriber.email, subscriber.display_name.as_deref(), &creator_name, &post_title, &post_url, Some(&unsub_url), ) .await { tracing::error!( error = ?e, "failed to send blog post announcement email" ); } } }); } /// Onboarding email drip steps (maps to `onboarding_email_step` i16 column). #[allow(clippy::enum_variant_names)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(i16)] enum OnboardingStep { /// Welcome email sent at signup. WelcomeSent = 1, /// Profile tips email (24h after welcome). ProfileTipsSent = 2, /// Stripe guide email (72h after welcome). StripeGuideSent = 3, } impl OnboardingStep { fn as_i16(self) -> i16 { self as i16 } } /// Process the getting-started email drip sequence. /// /// Step 1 (welcome) is sent at signup in the auth handler. /// Step 2 (profile tips) fires 24h after welcome, skipped if display_name is set. /// Step 3 (Stripe guide) fires 72h after welcome, skipped if Stripe is connected. pub(super) async fn send_onboarding_emails(state: &AppState) { let host_url = &state.config.host_url; // Step 1→2: profile tips (24h after welcome) let next = OnboardingStep::ProfileTipsSent; if let Ok(users) = db::users::get_onboarding_candidates(&state.db, OnboardingStep::WelcomeSent.as_i16(), chrono::Duration::hours(24)).await { // Batch-advance users who already set a display name (skip email) let (skip, send): (Vec<_>, Vec<_>) = users.into_iter().partition(|u| u.display_name.is_some()); if !skip.is_empty() { let skip_ids: Vec<_> = skip.iter().map(|u| u.id).collect(); if let Err(e) = db::users::batch_advance_onboarding_step(&state.db, &skip_ids, next.as_i16()).await { tracing::warn!(count = skip_ids.len(), step = ?next, error = ?e, "failed to batch advance onboarding step"); } } for user in send { // Advance step BEFORE sending email to prevent duplicates on DB failure. // Missing a non-critical onboarding email is better than sending it twice. if let Err(e) = db::users::advance_onboarding_step(&state.db, user.id, next.as_i16()).await { tracing::warn!(user_id = %user.id, step = ?next, error = ?e, "failed to advance onboarding step"); continue; } if let Err(e) = state .email .send_onboarding_profile(&user.email, user.display_name.as_deref(), host_url) .await { tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding profile email"); } } } // Step 2→3: Stripe guide (72h after welcome) let next = OnboardingStep::StripeGuideSent; if let Ok(users) = db::users::get_onboarding_candidates(&state.db, OnboardingStep::ProfileTipsSent.as_i16(), chrono::Duration::hours(48)).await { // Batch-advance users who already connected Stripe (skip email) let (skip, send): (Vec<_>, Vec<_>) = users.into_iter().partition(|u| u.stripe_account_id.is_some()); if !skip.is_empty() { let skip_ids: Vec<_> = skip.iter().map(|u| u.id).collect(); if let Err(e) = db::users::batch_advance_onboarding_step(&state.db, &skip_ids, next.as_i16()).await { tracing::warn!(count = skip_ids.len(), step = ?next, error = ?e, "failed to batch advance onboarding step"); } } for user in send { if let Err(e) = db::users::advance_onboarding_step(&state.db, user.id, next.as_i16()).await { tracing::warn!(user_id = %user.id, step = ?next, error = ?e, "failed to advance onboarding step"); continue; } if let Err(e) = state .email .send_onboarding_stripe(&user.email, user.display_name.as_deref(), host_url) .await { tracing::error!(error = ?e, user_id = %user.id, "failed to send onboarding stripe email"); } } } }