//! Email service for sending transactional emails via Postmark. //! //! - `templates` — email composition methods (one per email type) //! - `tokens` — HMAC-signed URL generation/verification for email actions mod templates; mod tokens; pub use tokens::*; use std::sync::Arc; use crate::error::{AppError, Result, ResultExt}; /// Format an optional display name as a greeting suffix: " Alice" or "". fn greeting(name: Option<&str>) -> String { name.map(|n| format!(" {}", n)).unwrap_or_default() } /// Email service configuration #[derive(Clone)] pub struct EmailConfig { /// Postmark API token (optional, logs if not set) pub postmark_token: Option, /// Default from address pub from_address: String, /// Default from name pub from_name: String, } impl std::fmt::Debug for EmailConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("EmailConfig") .field("postmark_token", &self.postmark_token.as_ref().map(|_| "[REDACTED]")) .field("from_address", &self.from_address) .field("from_name", &self.from_name) .finish() } } impl EmailConfig { /// Load email configuration from environment pub fn from_env() -> Self { EmailConfig { postmark_token: std::env::var("POSTMARK_TOKEN").ok(), from_address: std::env::var("EMAIL_FROM_ADDRESS") .unwrap_or_else(|_| "noreply@makenot.work".to_string()), from_name: std::env::var("EMAIL_FROM_NAME") .unwrap_or_else(|_| "Makenotwork".to_string()), } } } /// Core email sending abstraction. Implement this to provide a custom /// transport (Postmark, logging, recording for tests, etc.). #[async_trait::async_trait] pub trait EmailTransport: Send + Sync { /// Send a plain email. async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()>; /// Send an email with an optional unsubscribe link. async fn send_email_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, ) -> Result<()>; /// Send an email with extra headers and an optional unsubscribe link. async fn send_email_with_headers_and_unsub( &self, to: &str, subject: &str, body: &str, extra_headers: &[(&str, String)], unsub_url: Option<&str>, ) -> Result<()>; /// Send via the broadcast stream with an optional unsubscribe link. async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, ) -> Result<()>; } /// Send creator-departure notifications to historical buyers, bounded. /// /// Called from the two account-deletion confirmation paths (POST `/api/users/me` /// and the email-link `GET` form-confirm). Account deletion is rare and the /// notification is courtesy — but a creator with a very large completed-buyer /// pool would otherwise turn one deletion into a Postmark spend bomb, which is /// the same disease class the broadcast cap closes. Same parallelism + cadence /// shape as `routes/api/users/broadcast.rs`. /// /// Recipients are capped at `BUYER_DEPARTURE_MAX_NOTIFICATIONS`; if the cap is /// hit, the oldest-buyers slice (the SQL has no ORDER BY, so it's /// implementation-dependent, but bounded) is notified and a warning is logged /// so support can follow up manually for the remainder. #[tracing::instrument(skip(pool, email_client, creator_name))] pub async fn send_creator_departure_notifications( pool: &sqlx::PgPool, email_client: &EmailClient, user_id: crate::db::UserId, creator_name: String, ) { let buyers = match crate::db::transactions::get_all_buyers_for_seller( pool, user_id, crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS, ) .await { Ok(b) => b, Err(e) => { tracing::error!(error = ?e, %user_id, "failed to query buyers for departure notification"); return; } }; let count = buyers.len(); let cap = crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS as usize; if count >= cap { tracing::warn!( %user_id, count, cap, "creator-departure notification capped; remainder requires manual outreach" ); } else { tracing::info!(%user_id, buyer_count = count, "sending creator departure notifications"); } let mut set = tokio::task::JoinSet::new(); let delay = std::time::Duration::from_millis(crate::constants::BROADCAST_CHUNK_DELAY_MS); for buyer in buyers { if set.len() >= crate::constants::BROADCAST_PARALLELISM { let _ = set.join_next().await; } let email_client = email_client.clone(); let creator_name = creator_name.clone(); set.spawn(async move { if let Err(e) = email_client .send_creator_departure_notification( &buyer.email, buyer.display_name.as_deref(), &creator_name, ) .await { tracing::error!(error = ?e, buyer_email = %buyer.email, "failed to send creator departure notification"); } }); tokio::time::sleep(delay).await; } while set.join_next().await.is_some() {} } /// Email client for sending emails #[derive(Clone)] pub struct EmailClient { transport: Arc, } impl EmailClient { /// Create a new email client with Postmark transport. pub fn new(config: EmailConfig, pool: Option) -> Self { EmailClient { transport: Arc::new(PostmarkTransport::new(config, pool)), } } /// Create an email client with a custom transport (for testing). pub fn with_transport(transport: Arc) -> Self { EmailClient { transport } } } /// Postmark-backed email transport (the production implementation). #[derive(Clone)] pub(crate) struct PostmarkTransport { config: EmailConfig, http_client: reqwest::Client, pool: Option, } impl PostmarkTransport { fn new(config: EmailConfig, pool: Option) -> Self { let http_client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .expect("Failed to build email HTTP client"); PostmarkTransport { config, http_client, pool, } } /// Shared implementation for send-with-unsubscribe, supporting optional message stream. async fn send_with_unsub_inner( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, stream: Option<&str>, ) -> Result<()> { match unsub_url { Some(url) => { let body_with_footer = format!( "{}\n\nUnsubscribe from these emails:\n{}", body, url ); let headers = [ ("List-Unsubscribe", format!("<{}>", url)), ("List-Unsubscribe-Post", "List-Unsubscribe=One-Click".to_string()), ]; self.send_email_inner(to, subject, &body_with_footer, &headers, stream).await } None => self.send_email_inner(to, subject, body, &[], stream).await, } } /// Internal send implementation supporting optional custom headers and message stream. async fn send_email_inner( &self, to: &str, subject: &str, body: &str, extra_headers: &[(&str, String)], stream: Option<&str>, ) -> Result<()> { // Check suppression list before sending if let Some(ref pool) = self.pool { match crate::db::email_suppressions::is_suppressed(pool, to).await { Ok(true) => { tracing::info!(recipient = %to, subject = %subject, "email skipped (suppressed)"); return Ok(()); } Ok(false) => {} Err(e) => { // Log but don't block sending on suppression check failure tracing::warn!(recipient = %to, error = %e, "suppression check failed, sending anyway"); } } } if let Some(ref token) = self.config.postmark_token { self.send_via_postmark(token, to, subject, body, extra_headers, stream).await } else { tracing::info!( recipient = %to, subject = %subject, "email sent (dev mode — body redacted)" ); Ok(()) } } /// Send email via Postmark API async fn send_via_postmark( &self, token: &str, to: &str, subject: &str, body: &str, extra_headers: &[(&str, String)], stream: Option<&str>, ) -> Result<()> { let from = format!("{} <{}>", self.config.from_name, self.config.from_address); let mut payload = serde_json::json!({ "From": from, "To": to, "Subject": subject, "TextBody": body, }); if let Some(stream_id) = stream { payload["MessageStream"] = serde_json::Value::String(stream_id.to_string()); } if !extra_headers.is_empty() { let headers: Vec = extra_headers .iter() .map(|(name, value)| { serde_json::json!({ "Name": name, "Value": value }) }) .collect(); payload["Headers"] = serde_json::Value::Array(headers); } let response = self.http_client .post("https://api.postmarkapp.com/email") .header("X-Postmark-Server-Token", token) .header("Content-Type", "application/json") .json(&payload) .send() .await .context("postmark http request")?; if response.status().is_success() { tracing::info!(recipient = %to, subject = %subject, "email sent"); Ok(()) } else { let status = response.status(); let error_text = response.text().await.unwrap_or_default(); tracing::error!(status = %status, error = %error_text, "failed to send email"); Err(AppError::Internal(anyhow::anyhow!( "Failed to send email: {}", status ))) } } } #[async_trait::async_trait] impl EmailTransport for PostmarkTransport { async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> { self.send_email_inner(to, subject, body, &[], None).await } async fn send_email_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, ) -> Result<()> { self.send_with_unsub_inner(to, subject, body, unsub_url, None).await } async fn send_email_with_headers_and_unsub( &self, to: &str, subject: &str, body: &str, extra_headers: &[(&str, String)], unsub_url: Option<&str>, ) -> Result<()> { match unsub_url { Some(url) => { let body_with_footer = format!( "{}\n\nUnsubscribe from these emails:\n{}", body, url ); let mut all_headers: Vec<(&str, String)> = extra_headers.to_vec(); all_headers.push(("List-Unsubscribe", format!("<{}>", url))); all_headers.push(("List-Unsubscribe-Post", "List-Unsubscribe=One-Click".to_string())); self.send_email_inner(to, subject, &body_with_footer, &all_headers, None).await } None => self.send_email_inner(to, subject, body, extra_headers, None).await, } } async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, ) -> Result<()> { self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast")).await } }