Skip to main content

max / makenotwork

28.7 KB · 765 lines History Blame Raw
1 //! Email service for sending transactional emails via Postmark.
2 //!
3 //! - `class`, what kind of mail a send is and whether it can be turned off
4 //! - `templates`, email composition methods (one per email type)
5 //! - `tokens`, HMAC-signed URL generation/verification for email actions
6 //!
7 //! Every template method reaches the transport through [`EmailClient::dispatch`],
8 //! which takes an [`EmailClass`] and an [`Audience`]. That is the whole point of
9 //! the shape: a new email cannot be written without saying which class it is,
10 //! and an `Optional` one is preference-checked by the send path rather than by
11 //! whoever remembered to call `may_notify` at the call site.
12
13 mod class;
14 mod templates;
15 mod tokens;
16 pub use class::{EmailClass, OperationalKind, operational_mail_doc};
17 pub use tokens::*;
18
19 use std::sync::Arc;
20
21 use crate::db::{ListKind, UserId};
22 use crate::error::{AppError, Result};
23
24 /// Format an optional display name as a greeting suffix: " Alice" or "".
25 fn greeting(name: Option<&str>) -> String {
26 name.map(|n| format!(" {n}")).unwrap_or_default()
27 }
28
29 /// A recipient list proven to be within [`BROADCAST_MAX_RECIPIENTS`](crate::constants::BROADCAST_MAX_RECIPIENTS).
30 ///
31 /// Broadcasts must not materialize an unbounded recipient set in memory and
32 /// fan out unthrottled email. The only way to obtain this type is
33 /// [`BoundedRecipients::new`], which enforces the cap at construction, so a new
34 /// broadcast site physically cannot skip the check. This replaces the
35 /// copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` guards that had drifted
36 /// between the public and internal broadcast handlers (the cap constant now
37 /// lives only in this constructor; a grep guard below enforces that). On
38 /// overflow `new` returns the actual recipient count so the caller can build a
39 /// user-facing message and roll back any rate-limit slot it already consumed.
40 #[derive(Debug)]
41 pub struct BoundedRecipients<T>(Vec<T>);
42
43 impl<T> BoundedRecipients<T> {
44 /// Construct from a raw recipient list, enforcing the broadcast cap.
45 /// Returns `Err(count)` with the actual recipient count on overflow.
46 pub fn new(recipients: Vec<T>) -> std::result::Result<Self, usize> {
47 let n = recipients.len();
48 if n > crate::constants::BROADCAST_MAX_RECIPIENTS {
49 Err(n)
50 } else {
51 Ok(Self(recipients))
52 }
53 }
54
55 /// Number of recipients (always `<= BROADCAST_MAX_RECIPIENTS`).
56 pub fn len(&self) -> usize {
57 self.0.len()
58 }
59
60 /// Whether the recipient list is empty.
61 pub fn is_empty(&self) -> bool {
62 self.0.is_empty()
63 }
64
65 /// Consume into the inner recipient vector for fan-out iteration.
66 pub fn into_inner(self) -> Vec<T> {
67 self.0
68 }
69 }
70
71 /// Email service configuration
72 #[derive(Clone)]
73 pub struct EmailConfig {
74 /// Postmark API token (optional, logs if not set)
75 pub postmark_token: Option<String>,
76 /// Default from address
77 pub from_address: String,
78 /// Default from name
79 pub from_name: String,
80 }
81
82 impl std::fmt::Debug for EmailConfig {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("EmailConfig")
85 .field(
86 "postmark_token",
87 &self.postmark_token.as_ref().map(|_| "[REDACTED]"),
88 )
89 .field("from_address", &self.from_address)
90 .field("from_name", &self.from_name)
91 .finish()
92 }
93 }
94
95 impl EmailConfig {
96 /// Load email configuration from environment
97 pub fn from_env() -> Self {
98 EmailConfig {
99 postmark_token: std::env::var("POSTMARK_TOKEN").ok(),
100 from_address: std::env::var("EMAIL_FROM_ADDRESS")
101 .unwrap_or_else(|_| "noreply@makenot.work".to_string()),
102 from_name: std::env::var("EMAIL_FROM_NAME")
103 .unwrap_or_else(|_| "Makenotwork".to_string()),
104 }
105 }
106 }
107
108 /// Core email sending abstraction. Implement this to provide a custom
109 /// transport (Postmark, logging, recording for tests, etc.).
110 #[async_trait::async_trait]
111 pub trait EmailTransport: Send + Sync {
112 /// Send a plain email.
113 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()>;
114
115 /// Send an email with extra headers and an optional unsubscribe link.
116 async fn send_email_with_headers_and_unsub(
117 &self,
118 to: &str,
119 subject: &str,
120 body: &str,
121 extra_headers: &[(&str, String)],
122 unsub_url: Option<&str>,
123 ) -> Result<()>;
124
125 /// Send via the broadcast stream with an optional unsubscribe link, and the
126 /// fan-out this mail belongs to.
127 ///
128 /// On this method alone, because it is the only mail that belongs to one:
129 /// a transactional send has no list, no fan-out and nothing to attribute a
130 /// complaint about it to.
131 async fn send_email_broadcast_with_unsub(
132 &self,
133 to: &str,
134 subject: &str,
135 body: &str,
136 unsub_url: Option<&str>,
137 send: Option<crate::db::EmailSendId>,
138 ) -> Result<()>;
139 }
140
141 /// Send creator-departure notifications to historical buyers, bounded.
142 ///
143 /// Called from the two account-deletion confirmation paths (POST `/api/users/me`
144 /// and the email-link `GET` form-confirm). Account deletion is rare and the
145 /// notification is courtesy, but a creator with a very large completed-buyer
146 /// pool would otherwise turn one deletion into a Postmark spend bomb, which is
147 /// the same disease class the broadcast cap closes. Same parallelism + cadence
148 /// shape as `routes/api/users/broadcast.rs`.
149 ///
150 /// Recipients are capped at `BUYER_DEPARTURE_MAX_NOTIFICATIONS`. If the cap is
151 /// hit, an arbitrary bounded slice is notified (the SQL has no ORDER BY, so
152 /// which buyers land in it is up to the planner) and a warning is logged so
153 /// support can follow up manually for the remainder.
154 #[tracing::instrument(skip(pool, email_client, creator_name))]
155 pub async fn send_creator_departure_notifications(
156 pool: &sqlx::PgPool,
157 email_client: &EmailClient,
158 user_id: crate::db::UserId,
159 creator_name: String,
160 ) {
161 let buyers = match crate::db::transactions::get_all_buyers_for_seller(
162 pool,
163 user_id,
164 crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS,
165 )
166 .await
167 {
168 Ok(b) => b,
169 Err(e) => {
170 tracing::error!(error = ?e, %user_id, "failed to query buyers for departure notification");
171 return;
172 }
173 };
174 let count = buyers.len();
175 let cap = crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS as usize;
176 if count >= cap {
177 tracing::warn!(
178 %user_id, count, cap,
179 "creator-departure notification capped; remainder requires manual outreach"
180 );
181 } else {
182 tracing::info!(%user_id, buyer_count = count, "sending creator departure notifications");
183 }
184 let mut set = tokio::task::JoinSet::new();
185 let delay = std::time::Duration::from_millis(crate::constants::BROADCAST_CHUNK_DELAY_MS);
186 for buyer in buyers {
187 if set.len() >= crate::constants::BROADCAST_PARALLELISM {
188 let _ = set.join_next().await;
189 }
190 let email_client = email_client.clone();
191 let creator_name = creator_name.clone();
192 set.spawn(async move {
193 if let Err(e) = email_client
194 .send_creator_departure_notification(
195 &buyer.email,
196 buyer.display_name.as_deref(),
197 &creator_name,
198 )
199 .await
200 {
201 tracing::error!(error = ?e, buyer_email = %buyer.email, "failed to send creator departure notification");
202 }
203 });
204 tokio::time::sleep(delay).await;
205 }
206 while set.join_next().await.is_some() {}
207 }
208
209 /// Who a message is going to, and what is known about their consent.
210 ///
211 /// The distinction is load-bearing rather than cosmetic: an `Optional` message
212 /// can only be preference-checked against a user id, so pairing one with an
213 /// [`Audience::Address`] is a bug the dispatcher refuses rather than a silent
214 /// bypass of the gate.
215 #[derive(Debug, Clone, Copy)]
216 pub enum Audience<'a> {
217 /// Someone with an account, so their notification preferences can be read.
218 User(UserId, &'a str),
219 /// A bare address with no account behind it: a guest buyer, an imported
220 /// list subscriber, an operations mailbox.
221 Address(&'a str),
222 }
223
224 impl Audience<'_> {
225 fn email(&self) -> &str {
226 match self {
227 Audience::User(_, email) | Audience::Address(email) => email,
228 }
229 }
230 }
231
232 /// Per-message delivery options: the parts that vary between senders but are
233 /// not the class, the audience, the subject or the body.
234 #[derive(Default)]
235 pub(crate) struct Delivery<'a> {
236 /// One-click unsubscribe link. Required by every `Optional` and
237 /// `ListAudience` send; see `dispatch`.
238 pub unsub_url: Option<&'a str>,
239 /// Extra headers (issue threading: Reply-To, Message-ID, In-Reply-To).
240 pub headers: &'a [(&'a str, String)],
241 /// Send on Postmark's broadcast stream rather than the transactional one.
242 pub broadcast: bool,
243 /// The fan-out this mail belongs to, for the provider to hand back when the
244 /// address bounces or complains.
245 ///
246 /// `93f23f00`. Without it a complaint names an address and nothing else,
247 /// and no complaint rate is computable at any granularity. `None` for every
248 /// transactional send: those belong to no fan-out, and an id invented for
249 /// one would attribute a receipt to a list.
250 pub send: Option<crate::db::EmailSendId>,
251 }
252
253 /// What a fan-out's mail carries beyond its words.
254 ///
255 /// Two facts about the send rather than about the message, and they arrive
256 /// together at every broadcast sender: where this recipient unsubscribes, and
257 /// which fan-out this is. Grouped because they were the sixth and seventh
258 /// parameters of three senders that already took five, and because a third
259 /// per-send fact would have been the eighth.
260 #[derive(Debug, Clone, Copy, Default)]
261 pub struct Fanout<'a> {
262 /// This recipient's one-click unsubscribe link.
263 pub unsub_url: Option<&'a str>,
264 /// The row in `email_sends` this mail belongs to, so a complaint about it
265 /// can name the list and the creator (`93f23f00`).
266 pub send: Option<crate::db::EmailSendId>,
267 }
268
269 /// Email client for sending emails
270 #[derive(Clone)]
271 pub struct EmailClient {
272 transport: Arc<dyn EmailTransport>,
273 /// Read for the `Optional` preference check. `None` in tests and in the
274 /// dev-mode client, where `dispatch` sends rather than silently dropping:
275 /// a test asserting an email was composed should not fail because there was
276 /// no database to ask.
277 pool: Option<sqlx::PgPool>,
278 }
279
280 impl EmailClient {
281 /// Create a new email client with Postmark transport.
282 pub fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self {
283 EmailClient {
284 transport: Arc::new(PostmarkTransport::new(config, pool.clone())),
285 pool,
286 }
287 }
288
289 /// Create an email client with a custom transport (for testing).
290 pub fn with_transport(transport: Arc<dyn EmailTransport>) -> Self {
291 EmailClient {
292 transport,
293 pool: None,
294 }
295 }
296
297 /// Attach a pool to a custom-transport client so `Optional` sends are
298 /// preference-checked against a real database in integration tests.
299 #[must_use]
300 pub fn with_pool(mut self, pool: sqlx::PgPool) -> Self {
301 self.pool = Some(pool);
302 self
303 }
304
305 /// The single path from a template method to the transport.
306 ///
307 /// Every `send_*` goes through here, which is what makes the class
308 /// mandatory: there is no way to compose an email and put it on the wire
309 /// without naming one. For [`EmailClass::Optional`] the preference check
310 /// happens here, so the sixteen `may_notify` calls that used to be spread
311 /// across seven route modules collapse into this one.
312 ///
313 /// A failed preference lookup sends. That matches what every one of those
314 /// call sites did (`.unwrap_or(true)`) and it is the right default: a
315 /// database blip should not silently swallow a creator's sale notification.
316 pub(crate) async fn dispatch(
317 &self,
318 class: EmailClass,
319 audience: Audience<'_>,
320 subject: &str,
321 body: &str,
322 delivery: Delivery<'_>,
323 ) -> Result<()> {
324 if let EmailClass::Optional(kind) = class
325 && !self.wants(audience, kind).await?
326 {
327 tracing::debug!(
328 list_kind = %kind,
329 "email suppressed by the recipient's notification preference"
330 );
331 return Ok(());
332 }
333
334 let to = audience.email();
335 if delivery.broadcast {
336 self.transport
337 .send_email_broadcast_with_unsub(
338 to,
339 subject,
340 body,
341 delivery.unsub_url,
342 delivery.send,
343 )
344 .await
345 } else if delivery.headers.is_empty() && delivery.unsub_url.is_none() {
346 self.transport.send_email(to, subject, body).await
347 } else {
348 self.transport
349 .send_email_with_headers_and_unsub(
350 to,
351 subject,
352 body,
353 delivery.headers,
354 delivery.unsub_url,
355 )
356 .await
357 }
358 }
359
360 /// Whether an `Optional` message may go to this audience.
361 async fn wants(&self, audience: Audience<'_>, kind: ListKind) -> Result<bool> {
362 match audience {
363 Audience::User(user_id, _) => {
364 let Some(pool) = self.pool.as_ref() else {
365 return Ok(true);
366 };
367 Ok(crate::db::lists::may_notify(pool, user_id, kind)
368 .await
369 .unwrap_or(true))
370 }
371 Audience::Address(email) => {
372 // An opt-outable message aimed at an address with no account
373 // cannot be preference-checked. Sending anyway would be the
374 // silent bypass this module exists to remove, so refuse and let
375 // it surface as an error rather than as unstoppable mail.
376 tracing::error!(
377 recipient = %email,
378 list_kind = %kind,
379 "optional-class email addressed to a bare address; no preference to check"
380 );
381 Err(AppError::Internal(anyhow::anyhow!(
382 "optional-class email ({kind}) requires Audience::User, got Audience::Address"
383 )))
384 }
385 }
386 }
387 }
388
389 /// Postmark-backed email transport (the production implementation).
390 #[derive(Clone)]
391 pub(crate) struct PostmarkTransport {
392 config: EmailConfig,
393 http_client: reqwest::Client,
394 pool: Option<sqlx::PgPool>,
395 }
396
397 impl PostmarkTransport {
398 fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self {
399 crate::crypto::install_default_crypto_provider();
400 let http_client = reqwest::Client::builder()
401 .timeout(std::time::Duration::from_secs(10))
402 .build()
403 .expect("Failed to build email HTTP client");
404
405 PostmarkTransport {
406 config,
407 http_client,
408 pool,
409 }
410 }
411
412 /// Shared implementation for send-with-unsubscribe, supporting optional message stream.
413 async fn send_with_unsub_inner(
414 &self,
415 to: &str,
416 subject: &str,
417 body: &str,
418 unsub_url: Option<&str>,
419 stream: Option<&str>,
420 send: Option<crate::db::EmailSendId>,
421 ) -> Result<()> {
422 match unsub_url {
423 Some(url) => {
424 let body_with_footer = format!("{body}\n\nUnsubscribe from these emails:\n{url}");
425 let headers = [
426 ("List-Unsubscribe", format!("<{url}>")),
427 (
428 "List-Unsubscribe-Post",
429 "List-Unsubscribe=One-Click".to_string(),
430 ),
431 ];
432 self.send_email_inner(to, subject, &body_with_footer, &headers, stream, send)
433 .await
434 }
435 None => {
436 self.send_email_inner(to, subject, body, &[], stream, send)
437 .await
438 }
439 }
440 }
441
442 /// Internal send implementation supporting optional custom headers and message stream.
443 async fn send_email_inner(
444 &self,
445 to: &str,
446 subject: &str,
447 body: &str,
448 extra_headers: &[(&str, String)],
449 stream: Option<&str>,
450 send: Option<crate::db::EmailSendId>,
451 ) -> Result<()> {
452 // Check suppression list before sending
453 if let Some(ref pool) = self.pool {
454 match crate::db::email_suppressions::is_suppressed(pool, to).await {
455 Ok(true) => {
456 tracing::info!(recipient = %to, subject = %subject, "email skipped (suppressed)");
457 return Ok(());
458 }
459 Ok(false) => {}
460 Err(e) => {
461 // Log but don't block sending on suppression check failure
462 tracing::warn!(recipient = %to, error = %e, "suppression check failed, sending anyway");
463 }
464 }
465 }
466
467 if let Some(ref token) = self.config.postmark_token {
468 self.send_via_postmark(token, to, subject, body, extra_headers, stream, send)
469 .await
470 } else {
471 tracing::info!(
472 recipient = %to, subject = %subject,
473 "email sent (dev mode, body redacted)"
474 );
475 Ok(())
476 }
477 }
478
479 /// Send email via Postmark API
480 #[allow(
481 clippy::too_many_arguments,
482 reason = "every parameter is a distinct field of one Postmark request, \
483 and a struct holding exactly the arguments of one private \
484 call site names nothing"
485 )]
486 async fn send_via_postmark(
487 &self,
488 token: &str,
489 to: &str,
490 subject: &str,
491 body: &str,
492 extra_headers: &[(&str, String)],
493 stream: Option<&str>,
494 send: Option<crate::db::EmailSendId>,
495 ) -> Result<()> {
496 let from = format!("{} <{}>", self.config.from_name, self.config.from_address);
497
498 let mut payload = serde_json::json!({
499 "From": from,
500 "To": to,
501 "Subject": subject,
502 "TextBody": body,
503 });
504
505 if let Some(stream_id) = stream {
506 payload["MessageStream"] = serde_json::Value::String(stream_id.to_string());
507 }
508
509 // Postmark hands `Metadata` back on the bounce and complaint webhooks,
510 // which is the whole mechanism: the id goes out with the mail and comes
511 // back with the complaint. One key, because everything else about the
512 // send -- its list, its creator -- is on the row this names, and a
513 // second copy in the metadata is a second thing that can disagree.
514 if let Some(send) = send {
515 payload["Metadata"] = serde_json::json!({ "send": send.to_string() });
516 }
517
518 if !extra_headers.is_empty() {
519 let headers: Vec<serde_json::Value> = extra_headers
520 .iter()
521 .map(|(name, value)| serde_json::json!({ "Name": name, "Value": value }))
522 .collect();
523 payload["Headers"] = serde_json::Value::Array(headers);
524 }
525
526 // Retry transient failures (network/timeout, 5xx, 429) with bounded
527 // exponential backoff so a brief Postmark blip doesn't permanently drop
528 // critical mail, password resets, purchase receipts, Fan+ credit codes
529 // (Run 20 Resilience). Permanent 4xx (bad request, inactive recipient,
530 // hard bounce) are NOT retried: retrying can't help and only delays the
531 // caller. Bounded to EMAIL_SEND_MAX_ATTEMPTS so an awaited caller adds at
532 // most ~1s on a failing send.
533 const EMAIL_SEND_MAX_ATTEMPTS: u32 = 3;
534 let mut attempt: u32 = 0;
535 loop {
536 attempt += 1;
537 let send_result = self
538 .http_client
539 .post("https://api.postmarkapp.com/email")
540 .header("X-Postmark-Server-Token", token)
541 .header("Content-Type", "application/json")
542 .json(&payload)
543 .send()
544 .await;
545
546 match send_result {
547 Ok(response) if response.status().is_success() => {
548 tracing::info!(recipient = %to, subject = %subject, attempt, "email sent");
549 return Ok(());
550 }
551 Ok(response) => {
552 let status = response.status();
553 let transient = status.is_server_error() || status.as_u16() == 429;
554 let error_text = response.text().await.unwrap_or_default();
555 if transient && attempt < EMAIL_SEND_MAX_ATTEMPTS {
556 let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1));
557 tracing::warn!(status = %status, attempt, error = %error_text, "transient email send failure, retrying after backoff");
558 tokio::time::sleep(backoff).await;
559 continue;
560 }
561 tracing::error!(status = %status, error = %error_text, attempt, "failed to send email");
562 return Err(AppError::Internal(anyhow::anyhow!(
563 "Failed to send email: {status}"
564 )));
565 }
566 Err(e) => {
567 // Network/timeout: always transient.
568 if attempt < EMAIL_SEND_MAX_ATTEMPTS {
569 let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1));
570 tracing::warn!(attempt, error = %e, "email send request error, retrying after backoff");
571 tokio::time::sleep(backoff).await;
572 continue;
573 }
574 return Err(AppError::Internal(anyhow::anyhow!(
575 "postmark http request: {e}"
576 )));
577 }
578 }
579 }
580 }
581 }
582
583 #[async_trait::async_trait]
584 impl EmailTransport for PostmarkTransport {
585 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
586 self.send_email_inner(to, subject, body, &[], None, None)
587 .await
588 }
589
590 async fn send_email_with_headers_and_unsub(
591 &self,
592 to: &str,
593 subject: &str,
594 body: &str,
595 extra_headers: &[(&str, String)],
596 unsub_url: Option<&str>,
597 ) -> Result<()> {
598 match unsub_url {
599 Some(url) => {
600 let body_with_footer = format!("{body}\n\nUnsubscribe from these emails:\n{url}");
601 let mut all_headers: Vec<(&str, String)> = extra_headers.to_vec();
602 all_headers.push(("List-Unsubscribe", format!("<{url}>")));
603 all_headers.push((
604 "List-Unsubscribe-Post",
605 "List-Unsubscribe=One-Click".to_string(),
606 ));
607 self.send_email_inner(to, subject, &body_with_footer, &all_headers, None, None)
608 .await
609 }
610 None => {
611 self.send_email_inner(to, subject, body, extra_headers, None, None)
612 .await
613 }
614 }
615 }
616
617 async fn send_email_broadcast_with_unsub(
618 &self,
619 to: &str,
620 subject: &str,
621 body: &str,
622 unsub_url: Option<&str>,
623 send: Option<crate::db::EmailSendId>,
624 ) -> Result<()> {
625 self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast"), send)
626 .await
627 }
628 }
629
630 #[cfg(test)]
631 mod bounded_recipients_tests {
632 use super::*;
633
634 #[test]
635 fn accepts_under_cap() {
636 let r = BoundedRecipients::new(vec![1, 2, 3]).expect("under cap");
637 assert_eq!(r.len(), 3);
638 assert!(!r.is_empty());
639 assert_eq!(r.into_inner(), vec![1, 2, 3]);
640 }
641
642 #[test]
643 fn accepts_exactly_at_cap() {
644 let v = vec![0u8; crate::constants::BROADCAST_MAX_RECIPIENTS];
645 assert!(BoundedRecipients::new(v).is_ok());
646 }
647
648 #[test]
649 fn rejects_over_cap_with_count() {
650 let n = crate::constants::BROADCAST_MAX_RECIPIENTS + 1;
651 let v = vec![0u8; n];
652 assert_eq!(BoundedRecipients::new(v).unwrap_err(), n);
653 }
654
655 #[test]
656 fn empty_is_allowed() {
657 let r = BoundedRecipients::<u8>::new(vec![]).expect("empty ok");
658 assert!(r.is_empty());
659 }
660 }
661
662 /// Seal for the notification-preference gate.
663 ///
664 /// `may_notify` used to be called from sixteen places across seven route
665 /// modules, which is how the gate became forgettable: a new email was
666 /// opt-outable only if whoever wrote the handler remembered, and nothing failed
667 /// when they did not. The fix moves the check into [`EmailClient::dispatch`],
668 /// where declaring an [`EmailClass`] is mandatory. This test fails the build if
669 /// any file outside `db/lists.rs` (its definition) and this module (its one
670 /// caller) references it again, so re-inlining the check at a call site is not
671 /// something you can do quietly.
672 ///
673 /// `notification_prefs` is deliberately not sealed: it reads the same
674 /// preferences to render the settings form, which is a different job from
675 /// gating a send.
676 #[cfg(test)]
677 mod notification_gate_seal_guard {
678 use std::path::Path;
679
680 #[test]
681 fn may_notify_called_only_from_the_send_path() {
682 let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
683 let allowed = [
684 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/db/lists.rs"),
685 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"),
686 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/class.rs"),
687 ];
688 let mut offenders = Vec::new();
689 super::broadcast_cap_seal_guard::walk(&src_dir, &mut |path, contents| {
690 if allowed.iter().any(|a| a == path) {
691 return;
692 }
693 for (i, line) in contents.lines().enumerate() {
694 let code = line.trim();
695 if code.contains("may_notify") && !code.starts_with("//") {
696 offenders.push(format!("{}:{}: {}", path.display(), i + 1, code));
697 }
698 }
699 });
700 assert!(
701 offenders.is_empty(),
702 "notification-preference seal violated. Do not check may_notify at a call site: \
703 give the email an EmailClass::Optional(kind) and let EmailClient::dispatch do it, \
704 so the gate cannot be forgotten by the next handler. Offending lines:\n{}",
705 offenders.join("\n")
706 );
707 }
708 }
709
710 /// Seal for the broadcast recipient cap.
711 ///
712 /// The cap was a copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` check that had
713 /// drifted between the public and internal broadcast handlers. The fix routes
714 /// both through [`BoundedRecipients`], so the constant must appear ONLY in its
715 /// definition (`constants.rs`) and this module's constructor. This test fails
716 /// the build if any other file references the constant, forcing a new broadcast
717 /// site through the sealed constructor instead of re-inlining the check.
718 #[cfg(test)]
719 mod broadcast_cap_seal_guard {
720 use std::path::Path;
721
722 #[test]
723 fn cap_constant_used_only_in_sealed_constructor() {
724 let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
725 let allowed = [
726 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs"),
727 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"),
728 ];
729 let mut offenders = Vec::new();
730 walk(&src_dir, &mut |path, contents| {
731 if allowed.iter().any(|a| a == path) {
732 return;
733 }
734 for (i, line) in contents.lines().enumerate() {
735 if line.contains("BROADCAST_MAX_RECIPIENTS") {
736 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
737 }
738 }
739 });
740 assert!(
741 offenders.is_empty(),
742 "broadcast-cap seal violated, enforce the recipient cap via \
743 email::BoundedRecipients::new, never by referencing BROADCAST_MAX_RECIPIENTS \
744 directly in a handler. Offending lines:\n{}",
745 offenders.join("\n")
746 );
747 }
748
749 pub(super) fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
750 let Ok(entries) = std::fs::read_dir(dir) else {
751 return;
752 };
753 for entry in entries.flatten() {
754 let path = entry.path();
755 if path.is_dir() {
756 walk(&path, f);
757 } else if path.extension().is_some_and(|e| e == "rs")
758 && let Ok(contents) = std::fs::read_to_string(&path)
759 {
760 f(&path, &contents);
761 }
762 }
763 }
764 }
765