Skip to main content

max / makenotwork

11.9 KB · 341 lines History Blame Raw
1 //! Email service for sending transactional emails via Postmark.
2 //!
3 //! - `templates` — email composition methods (one per email type)
4 //! - `tokens` — HMAC-signed URL generation/verification for email actions
5
6 mod templates;
7 mod tokens;
8 pub use tokens::*;
9
10 use std::sync::Arc;
11
12 use crate::error::{AppError, Result, ResultExt};
13
14 /// Format an optional display name as a greeting suffix: " Alice" or "".
15 fn greeting(name: Option<&str>) -> String {
16 name.map(|n| format!(" {}", n)).unwrap_or_default()
17 }
18
19 /// Email service configuration
20 #[derive(Clone)]
21 pub struct EmailConfig {
22 /// Postmark API token (optional, logs if not set)
23 pub postmark_token: Option<String>,
24 /// Default from address
25 pub from_address: String,
26 /// Default from name
27 pub from_name: String,
28 }
29
30 impl std::fmt::Debug for EmailConfig {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("EmailConfig")
33 .field("postmark_token", &self.postmark_token.as_ref().map(|_| "[REDACTED]"))
34 .field("from_address", &self.from_address)
35 .field("from_name", &self.from_name)
36 .finish()
37 }
38 }
39
40 impl EmailConfig {
41 /// Load email configuration from environment
42 pub fn from_env() -> Self {
43 EmailConfig {
44 postmark_token: std::env::var("POSTMARK_TOKEN").ok(),
45 from_address: std::env::var("EMAIL_FROM_ADDRESS")
46 .unwrap_or_else(|_| "noreply@makenot.work".to_string()),
47 from_name: std::env::var("EMAIL_FROM_NAME")
48 .unwrap_or_else(|_| "Makenotwork".to_string()),
49 }
50 }
51 }
52
53 /// Core email sending abstraction. Implement this to provide a custom
54 /// transport (Postmark, logging, recording for tests, etc.).
55 #[async_trait::async_trait]
56 pub trait EmailTransport: Send + Sync {
57 /// Send a plain email.
58 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()>;
59
60 /// Send an email with an optional unsubscribe link.
61 async fn send_email_with_unsub(
62 &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>,
63 ) -> Result<()>;
64
65 /// Send an email with extra headers and an optional unsubscribe link.
66 async fn send_email_with_headers_and_unsub(
67 &self, to: &str, subject: &str, body: &str,
68 extra_headers: &[(&str, String)], unsub_url: Option<&str>,
69 ) -> Result<()>;
70
71 /// Send via the broadcast stream with an optional unsubscribe link.
72 async fn send_email_broadcast_with_unsub(
73 &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>,
74 ) -> Result<()>;
75 }
76
77 /// Send creator-departure notifications to historical buyers, bounded.
78 ///
79 /// Called from the two account-deletion confirmation paths (POST `/api/users/me`
80 /// and the email-link `GET` form-confirm). Account deletion is rare and the
81 /// notification is courtesy — but a creator with a very large completed-buyer
82 /// pool would otherwise turn one deletion into a Postmark spend bomb, which is
83 /// the same disease class the broadcast cap closes. Same parallelism + cadence
84 /// shape as `routes/api/users/broadcast.rs`.
85 ///
86 /// Recipients are capped at `BUYER_DEPARTURE_MAX_NOTIFICATIONS`; if the cap is
87 /// hit, the oldest-buyers slice (the SQL has no ORDER BY, so it's
88 /// implementation-dependent, but bounded) is notified and a warning is logged
89 /// so support can follow up manually for the remainder.
90 #[tracing::instrument(skip(pool, email_client, creator_name))]
91 pub async fn send_creator_departure_notifications(
92 pool: &sqlx::PgPool,
93 email_client: &EmailClient,
94 user_id: crate::db::UserId,
95 creator_name: String,
96 ) {
97 let buyers = match crate::db::transactions::get_all_buyers_for_seller(
98 pool,
99 user_id,
100 crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS,
101 )
102 .await
103 {
104 Ok(b) => b,
105 Err(e) => {
106 tracing::error!(error = ?e, %user_id, "failed to query buyers for departure notification");
107 return;
108 }
109 };
110 let count = buyers.len();
111 let cap = crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS as usize;
112 if count >= cap {
113 tracing::warn!(
114 %user_id, count, cap,
115 "creator-departure notification capped; remainder requires manual outreach"
116 );
117 } else {
118 tracing::info!(%user_id, buyer_count = count, "sending creator departure notifications");
119 }
120 let mut set = tokio::task::JoinSet::new();
121 let delay = std::time::Duration::from_millis(crate::constants::BROADCAST_CHUNK_DELAY_MS);
122 for buyer in buyers {
123 if set.len() >= crate::constants::BROADCAST_PARALLELISM {
124 let _ = set.join_next().await;
125 }
126 let email_client = email_client.clone();
127 let creator_name = creator_name.clone();
128 set.spawn(async move {
129 if let Err(e) = email_client
130 .send_creator_departure_notification(
131 &buyer.email,
132 buyer.display_name.as_deref(),
133 &creator_name,
134 )
135 .await
136 {
137 tracing::error!(error = ?e, buyer_email = %buyer.email, "failed to send creator departure notification");
138 }
139 });
140 tokio::time::sleep(delay).await;
141 }
142 while set.join_next().await.is_some() {}
143 }
144
145 /// Email client for sending emails
146 #[derive(Clone)]
147 pub struct EmailClient {
148 transport: Arc<dyn EmailTransport>,
149 }
150
151 impl EmailClient {
152 /// Create a new email client with Postmark transport.
153 pub fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self {
154 EmailClient {
155 transport: Arc::new(PostmarkTransport::new(config, pool)),
156 }
157 }
158
159 /// Create an email client with a custom transport (for testing).
160 pub fn with_transport(transport: Arc<dyn EmailTransport>) -> Self {
161 EmailClient { transport }
162 }
163 }
164
165 /// Postmark-backed email transport (the production implementation).
166 #[derive(Clone)]
167 pub(crate) struct PostmarkTransport {
168 config: EmailConfig,
169 http_client: reqwest::Client,
170 pool: Option<sqlx::PgPool>,
171 }
172
173 impl PostmarkTransport {
174 fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self {
175 let http_client = reqwest::Client::builder()
176 .timeout(std::time::Duration::from_secs(10))
177 .build()
178 .expect("Failed to build email HTTP client");
179
180 PostmarkTransport {
181 config,
182 http_client,
183 pool,
184 }
185 }
186
187 /// Shared implementation for send-with-unsubscribe, supporting optional message stream.
188 async fn send_with_unsub_inner(
189 &self,
190 to: &str,
191 subject: &str,
192 body: &str,
193 unsub_url: Option<&str>,
194 stream: Option<&str>,
195 ) -> Result<()> {
196 match unsub_url {
197 Some(url) => {
198 let body_with_footer = format!(
199 "{}\n\nUnsubscribe from these emails:\n{}",
200 body, url
201 );
202 let headers = [
203 ("List-Unsubscribe", format!("<{}>", url)),
204 ("List-Unsubscribe-Post", "List-Unsubscribe=One-Click".to_string()),
205 ];
206 self.send_email_inner(to, subject, &body_with_footer, &headers, stream).await
207 }
208 None => self.send_email_inner(to, subject, body, &[], stream).await,
209 }
210 }
211
212 /// Internal send implementation supporting optional custom headers and message stream.
213 async fn send_email_inner(
214 &self,
215 to: &str,
216 subject: &str,
217 body: &str,
218 extra_headers: &[(&str, String)],
219 stream: Option<&str>,
220 ) -> Result<()> {
221 // Check suppression list before sending
222 if let Some(ref pool) = self.pool {
223 match crate::db::email_suppressions::is_suppressed(pool, to).await {
224 Ok(true) => {
225 tracing::info!(recipient = %to, subject = %subject, "email skipped (suppressed)");
226 return Ok(());
227 }
228 Ok(false) => {}
229 Err(e) => {
230 // Log but don't block sending on suppression check failure
231 tracing::warn!(recipient = %to, error = %e, "suppression check failed, sending anyway");
232 }
233 }
234 }
235
236 if let Some(ref token) = self.config.postmark_token {
237 self.send_via_postmark(token, to, subject, body, extra_headers, stream).await
238 } else {
239 tracing::info!(
240 recipient = %to, subject = %subject,
241 "email sent (dev mode — body redacted)"
242 );
243 Ok(())
244 }
245 }
246
247 /// Send email via Postmark API
248 async fn send_via_postmark(
249 &self,
250 token: &str,
251 to: &str,
252 subject: &str,
253 body: &str,
254 extra_headers: &[(&str, String)],
255 stream: Option<&str>,
256 ) -> Result<()> {
257 let from = format!("{} <{}>", self.config.from_name, self.config.from_address);
258
259 let mut payload = serde_json::json!({
260 "From": from,
261 "To": to,
262 "Subject": subject,
263 "TextBody": body,
264 });
265
266 if let Some(stream_id) = stream {
267 payload["MessageStream"] = serde_json::Value::String(stream_id.to_string());
268 }
269
270 if !extra_headers.is_empty() {
271 let headers: Vec<serde_json::Value> = extra_headers
272 .iter()
273 .map(|(name, value)| {
274 serde_json::json!({ "Name": name, "Value": value })
275 })
276 .collect();
277 payload["Headers"] = serde_json::Value::Array(headers);
278 }
279
280 let response = self.http_client
281 .post("https://api.postmarkapp.com/email")
282 .header("X-Postmark-Server-Token", token)
283 .header("Content-Type", "application/json")
284 .json(&payload)
285 .send()
286 .await
287 .context("postmark http request")?;
288
289 if response.status().is_success() {
290 tracing::info!(recipient = %to, subject = %subject, "email sent");
291 Ok(())
292 } else {
293 let status = response.status();
294 let error_text = response.text().await.unwrap_or_default();
295 tracing::error!(status = %status, error = %error_text, "failed to send email");
296 Err(AppError::Internal(anyhow::anyhow!(
297 "Failed to send email: {}",
298 status
299 )))
300 }
301 }
302 }
303
304 #[async_trait::async_trait]
305 impl EmailTransport for PostmarkTransport {
306 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
307 self.send_email_inner(to, subject, body, &[], None).await
308 }
309
310 async fn send_email_with_unsub(
311 &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>,
312 ) -> Result<()> {
313 self.send_with_unsub_inner(to, subject, body, unsub_url, None).await
314 }
315
316 async fn send_email_with_headers_and_unsub(
317 &self, to: &str, subject: &str, body: &str,
318 extra_headers: &[(&str, String)], unsub_url: Option<&str>,
319 ) -> Result<()> {
320 match unsub_url {
321 Some(url) => {
322 let body_with_footer = format!(
323 "{}\n\nUnsubscribe from these emails:\n{}",
324 body, url
325 );
326 let mut all_headers: Vec<(&str, String)> = extra_headers.to_vec();
327 all_headers.push(("List-Unsubscribe", format!("<{}>", url)));
328 all_headers.push(("List-Unsubscribe-Post", "List-Unsubscribe=One-Click".to_string()));
329 self.send_email_inner(to, subject, &body_with_footer, &all_headers, None).await
330 }
331 None => self.send_email_inner(to, subject, body, extra_headers, None).await,
332 }
333 }
334
335 async fn send_email_broadcast_with_unsub(
336 &self, to: &str, subject: &str, body: &str, unsub_url: Option<&str>,
337 ) -> Result<()> {
338 self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast")).await
339 }
340 }
341