//! 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}; /// 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() } /// A recipient list proven to be within [`BROADCAST_MAX_RECIPIENTS`](crate::constants::BROADCAST_MAX_RECIPIENTS). /// /// Broadcasts must not materialize an unbounded recipient set in memory and /// fan out unthrottled email. The only way to obtain this type is /// [`BoundedRecipients::new`], which enforces the cap at construction, so a new /// broadcast site physically cannot skip the check. This replaces the /// copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` guards that had drifted /// between the public and internal broadcast handlers (the cap constant now /// lives only in this constructor; a grep guard below enforces that). On /// overflow `new` returns the actual recipient count so the caller can build a /// user-facing message and roll back any rate-limit slot it already consumed. #[derive(Debug)] pub struct BoundedRecipients(Vec); impl BoundedRecipients { /// Construct from a raw recipient list, enforcing the broadcast cap. /// Returns `Err(count)` with the actual recipient count on overflow. pub fn new(recipients: Vec) -> std::result::Result { let n = recipients.len(); if n > crate::constants::BROADCAST_MAX_RECIPIENTS { Err(n) } else { Ok(Self(recipients)) } } /// Number of recipients (always `<= BROADCAST_MAX_RECIPIENTS`). pub fn len(&self) -> usize { self.0.len() } /// Whether the recipient list is empty. pub fn is_empty(&self) -> bool { self.0.is_empty() } /// Consume into the inner recipient vector for fan-out iteration. pub fn into_inner(self) -> Vec { self.0 } } /// 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!("{body}\n\nUnsubscribe from these emails:\n{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); } // Retry transient failures (network/timeout, 5xx, 429) with bounded // exponential backoff so a brief Postmark blip doesn't permanently drop // critical mail, password resets, purchase receipts, Fan+ credit codes // (Run 20 Resilience). Permanent 4xx (bad request, inactive recipient, // hard bounce) are NOT retried: retrying can't help and only delays the // caller. Bounded to EMAIL_SEND_MAX_ATTEMPTS so an awaited caller adds at // most ~1s on a failing send. const EMAIL_SEND_MAX_ATTEMPTS: u32 = 3; let mut attempt: u32 = 0; loop { attempt += 1; let send_result = self .http_client .post("https://api.postmarkapp.com/email") .header("X-Postmark-Server-Token", token) .header("Content-Type", "application/json") .json(&payload) .send() .await; match send_result { Ok(response) if response.status().is_success() => { tracing::info!(recipient = %to, subject = %subject, attempt, "email sent"); return Ok(()); } Ok(response) => { let status = response.status(); let transient = status.is_server_error() || status.as_u16() == 429; let error_text = response.text().await.unwrap_or_default(); if transient && attempt < EMAIL_SEND_MAX_ATTEMPTS { let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1)); tracing::warn!(status = %status, attempt, error = %error_text, "transient email send failure, retrying after backoff"); tokio::time::sleep(backoff).await; continue; } tracing::error!(status = %status, error = %error_text, attempt, "failed to send email"); return Err(AppError::Internal(anyhow::anyhow!( "Failed to send email: {status}" ))); } Err(e) => { // Network/timeout: always transient. if attempt < EMAIL_SEND_MAX_ATTEMPTS { let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1)); tracing::warn!(attempt, error = %e, "email send request error, retrying after backoff"); tokio::time::sleep(backoff).await; continue; } return Err(AppError::Internal(anyhow::anyhow!( "postmark http request: {e}" ))); } } } } } #[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!("{body}\n\nUnsubscribe from these emails:\n{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 } } #[cfg(test)] mod bounded_recipients_tests { use super::*; #[test] fn accepts_under_cap() { let r = BoundedRecipients::new(vec![1, 2, 3]).expect("under cap"); assert_eq!(r.len(), 3); assert!(!r.is_empty()); assert_eq!(r.into_inner(), vec![1, 2, 3]); } #[test] fn accepts_exactly_at_cap() { let v = vec![0u8; crate::constants::BROADCAST_MAX_RECIPIENTS]; assert!(BoundedRecipients::new(v).is_ok()); } #[test] fn rejects_over_cap_with_count() { let n = crate::constants::BROADCAST_MAX_RECIPIENTS + 1; let v = vec![0u8; n]; assert_eq!(BoundedRecipients::new(v).unwrap_err(), n); } #[test] fn empty_is_allowed() { let r = BoundedRecipients::::new(vec![]).expect("empty ok"); assert!(r.is_empty()); } } /// Seal for the broadcast recipient cap. /// /// The cap was a copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` check that had /// drifted between the public and internal broadcast handlers. The fix routes /// both through [`BoundedRecipients`], so the constant must appear ONLY in its /// definition (`constants.rs`) and this module's constructor. This test fails /// the build if any other file references the constant, forcing a new broadcast /// site through the sealed constructor instead of re-inlining the check. #[cfg(test)] mod broadcast_cap_seal_guard { use std::path::Path; #[test] fn cap_constant_used_only_in_sealed_constructor() { let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let allowed = [ Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs"), Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"), ]; let mut offenders = Vec::new(); walk(&src_dir, &mut |path, contents| { if allowed.iter().any(|a| a == path) { return; } for (i, line) in contents.lines().enumerate() { if line.contains("BROADCAST_MAX_RECIPIENTS") { offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); } } }); assert!( offenders.is_empty(), "broadcast-cap seal violated, enforce the recipient cap via \ email::BoundedRecipients::new, never by referencing BROADCAST_MAX_RECIPIENTS \ directly in a handler. Offending lines:\n{}", offenders.join("\n") ); } fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { walk(&path, f); } else if path.extension().is_some_and(|e| e == "rs") && let Ok(contents) = std::fs::read_to_string(&path) { f(&path, &contents); } } } }