//! Email service for sending transactional emails via Postmark. //! //! - `class`, what kind of mail a send is and whether it can be turned off //! - `templates`, email composition methods (one per email type) //! - `tokens`, HMAC-signed URL generation/verification for email actions //! //! Every template method reaches the transport through [`EmailClient::dispatch`], //! which takes an [`EmailClass`] and an [`Audience`]. That is the whole point of //! the shape: a new email cannot be written without saying which class it is, //! and an `Optional` one is preference-checked by the send path rather than by //! whoever remembered to call `may_notify` at the call site. mod class; mod templates; mod tokens; pub use class::{EmailClass, OperationalKind, operational_mail_doc}; pub use tokens::*; use std::sync::Arc; use crate::db::{ListKind, UserId}; 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 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, and the /// fan-out this mail belongs to. /// /// On this method alone, because it is the only mail that belongs to one: /// a transactional send has no list, no fan-out and nothing to attribute a /// complaint about it to. async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, send: Option, ) -> 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, an arbitrary bounded slice is notified (the SQL has no ORDER BY, so /// which buyers land in it is up to the planner) 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() {} } /// Who a message is going to, and what is known about their consent. /// /// The distinction is load-bearing rather than cosmetic: an `Optional` message /// can only be preference-checked against a user id, so pairing one with an /// [`Audience::Address`] is a bug the dispatcher refuses rather than a silent /// bypass of the gate. #[derive(Debug, Clone, Copy)] pub enum Audience<'a> { /// Someone with an account, so their notification preferences can be read. User(UserId, &'a str), /// A bare address with no account behind it: a guest buyer, an imported /// list subscriber, an operations mailbox. Address(&'a str), } impl Audience<'_> { fn email(&self) -> &str { match self { Audience::User(_, email) | Audience::Address(email) => email, } } } /// Per-message delivery options: the parts that vary between senders but are /// not the class, the audience, the subject or the body. #[derive(Default)] pub(crate) struct Delivery<'a> { /// One-click unsubscribe link. Required by every `Optional` and /// `ListAudience` send; see `dispatch`. pub unsub_url: Option<&'a str>, /// Extra headers (issue threading: Reply-To, Message-ID, In-Reply-To). pub headers: &'a [(&'a str, String)], /// Send on Postmark's broadcast stream rather than the transactional one. pub broadcast: bool, /// The fan-out this mail belongs to, for the provider to hand back when the /// address bounces or complains. /// /// `93f23f00`. Without it a complaint names an address and nothing else, /// and no complaint rate is computable at any granularity. `None` for every /// transactional send: those belong to no fan-out, and an id invented for /// one would attribute a receipt to a list. pub send: Option, } /// What a fan-out's mail carries beyond its words. /// /// Two facts about the send rather than about the message, and they arrive /// together at every broadcast sender: where this recipient unsubscribes, and /// which fan-out this is. Grouped because they were the sixth and seventh /// parameters of three senders that already took five, and because a third /// per-send fact would have been the eighth. #[derive(Debug, Clone, Copy, Default)] pub struct Fanout<'a> { /// This recipient's one-click unsubscribe link. pub unsub_url: Option<&'a str>, /// The row in `email_sends` this mail belongs to, so a complaint about it /// can name the list and the creator (`93f23f00`). pub send: Option, } /// Email client for sending emails #[derive(Clone)] pub struct EmailClient { transport: Arc, /// Read for the `Optional` preference check. `None` in tests and in the /// dev-mode client, where `dispatch` sends rather than silently dropping: /// a test asserting an email was composed should not fail because there was /// no database to ask. pool: Option, } 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.clone())), pool, } } /// Create an email client with a custom transport (for testing). pub fn with_transport(transport: Arc) -> Self { EmailClient { transport, pool: None, } } /// Attach a pool to a custom-transport client so `Optional` sends are /// preference-checked against a real database in integration tests. #[must_use] pub fn with_pool(mut self, pool: sqlx::PgPool) -> Self { self.pool = Some(pool); self } /// The single path from a template method to the transport. /// /// Every `send_*` goes through here, which is what makes the class /// mandatory: there is no way to compose an email and put it on the wire /// without naming one. For [`EmailClass::Optional`] the preference check /// happens here, so the sixteen `may_notify` calls that used to be spread /// across seven route modules collapse into this one. /// /// A failed preference lookup sends. That matches what every one of those /// call sites did (`.unwrap_or(true)`) and it is the right default: a /// database blip should not silently swallow a creator's sale notification. pub(crate) async fn dispatch( &self, class: EmailClass, audience: Audience<'_>, subject: &str, body: &str, delivery: Delivery<'_>, ) -> Result<()> { if let EmailClass::Optional(kind) = class && !self.wants(audience, kind).await? { tracing::debug!( list_kind = %kind, "email suppressed by the recipient's notification preference" ); return Ok(()); } let to = audience.email(); if delivery.broadcast { self.transport .send_email_broadcast_with_unsub( to, subject, body, delivery.unsub_url, delivery.send, ) .await } else if delivery.headers.is_empty() && delivery.unsub_url.is_none() { self.transport.send_email(to, subject, body).await } else { self.transport .send_email_with_headers_and_unsub( to, subject, body, delivery.headers, delivery.unsub_url, ) .await } } /// Whether an `Optional` message may go to this audience. async fn wants(&self, audience: Audience<'_>, kind: ListKind) -> Result { match audience { Audience::User(user_id, _) => { let Some(pool) = self.pool.as_ref() else { return Ok(true); }; Ok(crate::db::lists::may_notify(pool, user_id, kind) .await .unwrap_or(true)) } Audience::Address(email) => { // An opt-outable message aimed at an address with no account // cannot be preference-checked. Sending anyway would be the // silent bypass this module exists to remove, so refuse and let // it surface as an error rather than as unstoppable mail. tracing::error!( recipient = %email, list_kind = %kind, "optional-class email addressed to a bare address; no preference to check" ); Err(AppError::Internal(anyhow::anyhow!( "optional-class email ({kind}) requires Audience::User, got Audience::Address" ))) } } } } /// 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 { crate::crypto::install_default_crypto_provider(); 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>, send: Option, ) -> 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, send) .await } None => { self.send_email_inner(to, subject, body, &[], stream, send) .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>, send: Option, ) -> 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, send) .await } else { tracing::info!( recipient = %to, subject = %subject, "email sent (dev mode, body redacted)" ); Ok(()) } } /// Send email via Postmark API #[allow( clippy::too_many_arguments, reason = "every parameter is a distinct field of one Postmark request, \ and a struct holding exactly the arguments of one private \ call site names nothing" )] async fn send_via_postmark( &self, token: &str, to: &str, subject: &str, body: &str, extra_headers: &[(&str, String)], stream: Option<&str>, send: Option, ) -> 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()); } // Postmark hands `Metadata` back on the bounce and complaint webhooks, // which is the whole mechanism: the id goes out with the mail and comes // back with the complaint. One key, because everything else about the // send -- its list, its creator -- is on the row this names, and a // second copy in the metadata is a second thing that can disagree. if let Some(send) = send { payload["Metadata"] = serde_json::json!({ "send": send.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, 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, None) .await } None => { self.send_email_inner(to, subject, body, extra_headers, None, None) .await } } } async fn send_email_broadcast_with_unsub( &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>, send: Option, ) -> Result<()> { self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast"), send) .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 notification-preference gate. /// /// `may_notify` used to be called from sixteen places across seven route /// modules, which is how the gate became forgettable: a new email was /// opt-outable only if whoever wrote the handler remembered, and nothing failed /// when they did not. The fix moves the check into [`EmailClient::dispatch`], /// where declaring an [`EmailClass`] is mandatory. This test fails the build if /// any file outside `db/lists.rs` (its definition) and this module (its one /// caller) references it again, so re-inlining the check at a call site is not /// something you can do quietly. /// /// `notification_prefs` is deliberately not sealed: it reads the same /// preferences to render the settings form, which is a different job from /// gating a send. #[cfg(test)] mod notification_gate_seal_guard { use std::path::Path; #[test] fn may_notify_called_only_from_the_send_path() { let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let allowed = [ Path::new(env!("CARGO_MANIFEST_DIR")).join("src/db/lists.rs"), Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"), Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/class.rs"), ]; let mut offenders = Vec::new(); super::broadcast_cap_seal_guard::walk(&src_dir, &mut |path, contents| { if allowed.iter().any(|a| a == path) { return; } for (i, line) in contents.lines().enumerate() { let code = line.trim(); if code.contains("may_notify") && !code.starts_with("//") { offenders.push(format!("{}:{}: {}", path.display(), i + 1, code)); } } }); assert!( offenders.is_empty(), "notification-preference seal violated. Do not check may_notify at a call site: \ give the email an EmailClass::Optional(kind) and let EmailClient::dispatch do it, \ so the gate cannot be forgotten by the next handler. Offending lines:\n{}", offenders.join("\n") ); } } /// 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") ); } pub(super) 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); } } } }