Skip to main content

max / makenotwork

26.1 KB · 706 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.
126 async fn send_email_broadcast_with_unsub(
127 &self,
128 to: &str,
129 subject: &str,
130 body: &str,
131 unsub_url: Option<&str>,
132 ) -> Result<()>;
133 }
134
135 /// Send creator-departure notifications to historical buyers, bounded.
136 ///
137 /// Called from the two account-deletion confirmation paths (POST `/api/users/me`
138 /// and the email-link `GET` form-confirm). Account deletion is rare and the
139 /// notification is courtesy, but a creator with a very large completed-buyer
140 /// pool would otherwise turn one deletion into a Postmark spend bomb, which is
141 /// the same disease class the broadcast cap closes. Same parallelism + cadence
142 /// shape as `routes/api/users/broadcast.rs`.
143 ///
144 /// Recipients are capped at `BUYER_DEPARTURE_MAX_NOTIFICATIONS`; if the cap is
145 /// hit, the oldest-buyers slice (the SQL has no ORDER BY, so it's
146 /// implementation-dependent, but bounded) is notified and a warning is logged
147 /// so support can follow up manually for the remainder.
148 #[tracing::instrument(skip(pool, email_client, creator_name))]
149 pub async fn send_creator_departure_notifications(
150 pool: &sqlx::PgPool,
151 email_client: &EmailClient,
152 user_id: crate::db::UserId,
153 creator_name: String,
154 ) {
155 let buyers = match crate::db::transactions::get_all_buyers_for_seller(
156 pool,
157 user_id,
158 crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS,
159 )
160 .await
161 {
162 Ok(b) => b,
163 Err(e) => {
164 tracing::error!(error = ?e, %user_id, "failed to query buyers for departure notification");
165 return;
166 }
167 };
168 let count = buyers.len();
169 let cap = crate::constants::BUYER_DEPARTURE_MAX_NOTIFICATIONS as usize;
170 if count >= cap {
171 tracing::warn!(
172 %user_id, count, cap,
173 "creator-departure notification capped; remainder requires manual outreach"
174 );
175 } else {
176 tracing::info!(%user_id, buyer_count = count, "sending creator departure notifications");
177 }
178 let mut set = tokio::task::JoinSet::new();
179 let delay = std::time::Duration::from_millis(crate::constants::BROADCAST_CHUNK_DELAY_MS);
180 for buyer in buyers {
181 if set.len() >= crate::constants::BROADCAST_PARALLELISM {
182 let _ = set.join_next().await;
183 }
184 let email_client = email_client.clone();
185 let creator_name = creator_name.clone();
186 set.spawn(async move {
187 if let Err(e) = email_client
188 .send_creator_departure_notification(
189 &buyer.email,
190 buyer.display_name.as_deref(),
191 &creator_name,
192 )
193 .await
194 {
195 tracing::error!(error = ?e, buyer_email = %buyer.email, "failed to send creator departure notification");
196 }
197 });
198 tokio::time::sleep(delay).await;
199 }
200 while set.join_next().await.is_some() {}
201 }
202
203 /// Who a message is going to, and what is known about their consent.
204 ///
205 /// The distinction is load-bearing rather than cosmetic: an `Optional` message
206 /// can only be preference-checked against a user id, so pairing one with an
207 /// [`Audience::Address`] is a bug the dispatcher refuses rather than a silent
208 /// bypass of the gate.
209 #[derive(Debug, Clone, Copy)]
210 pub enum Audience<'a> {
211 /// Someone with an account, so their notification preferences can be read.
212 User(UserId, &'a str),
213 /// A bare address with no account behind it: a guest buyer, an imported
214 /// list subscriber, an operations mailbox.
215 Address(&'a str),
216 }
217
218 impl Audience<'_> {
219 fn email(&self) -> &str {
220 match self {
221 Audience::User(_, email) | Audience::Address(email) => email,
222 }
223 }
224 }
225
226 /// Per-message delivery options: the parts that vary between senders but are
227 /// not the class, the audience, the subject or the body.
228 #[derive(Default)]
229 pub(crate) struct Delivery<'a> {
230 /// One-click unsubscribe link. Required by every `Optional` and
231 /// `ListAudience` send; see `dispatch`.
232 pub unsub_url: Option<&'a str>,
233 /// Extra headers (issue threading: Reply-To, Message-ID, In-Reply-To).
234 pub headers: &'a [(&'a str, String)],
235 /// Send on Postmark's broadcast stream rather than the transactional one.
236 pub broadcast: bool,
237 }
238
239 /// Email client for sending emails
240 #[derive(Clone)]
241 pub struct EmailClient {
242 transport: Arc<dyn EmailTransport>,
243 /// Read for the `Optional` preference check. `None` in tests and in the
244 /// dev-mode client, where `dispatch` sends rather than silently dropping:
245 /// a test asserting an email was composed should not fail because there was
246 /// no database to ask.
247 pool: Option<sqlx::PgPool>,
248 }
249
250 impl EmailClient {
251 /// Create a new email client with Postmark transport.
252 pub fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self {
253 EmailClient {
254 transport: Arc::new(PostmarkTransport::new(config, pool.clone())),
255 pool,
256 }
257 }
258
259 /// Create an email client with a custom transport (for testing).
260 pub fn with_transport(transport: Arc<dyn EmailTransport>) -> Self {
261 EmailClient {
262 transport,
263 pool: None,
264 }
265 }
266
267 /// Attach a pool to a custom-transport client so `Optional` sends are
268 /// preference-checked against a real database in integration tests.
269 #[must_use]
270 pub fn with_pool(mut self, pool: sqlx::PgPool) -> Self {
271 self.pool = Some(pool);
272 self
273 }
274
275 /// The single path from a template method to the transport.
276 ///
277 /// Every `send_*` goes through here, which is what makes the class
278 /// mandatory: there is no way to compose an email and put it on the wire
279 /// without naming one. For [`EmailClass::Optional`] the preference check
280 /// happens here, so the sixteen `may_notify` calls that used to be spread
281 /// across seven route modules collapse into this one.
282 ///
283 /// A failed preference lookup sends. That matches what every one of those
284 /// call sites did (`.unwrap_or(true)`) and it is the right default: a
285 /// database blip should not silently swallow a creator's sale notification.
286 pub(crate) async fn dispatch(
287 &self,
288 class: EmailClass,
289 audience: Audience<'_>,
290 subject: &str,
291 body: &str,
292 delivery: Delivery<'_>,
293 ) -> Result<()> {
294 if let EmailClass::Optional(kind) = class
295 && !self.wants(audience, kind).await?
296 {
297 tracing::debug!(
298 list_kind = %kind,
299 "email suppressed by the recipient's notification preference"
300 );
301 return Ok(());
302 }
303
304 let to = audience.email();
305 if delivery.broadcast {
306 self.transport
307 .send_email_broadcast_with_unsub(to, subject, body, delivery.unsub_url)
308 .await
309 } else if delivery.headers.is_empty() && delivery.unsub_url.is_none() {
310 self.transport.send_email(to, subject, body).await
311 } else {
312 self.transport
313 .send_email_with_headers_and_unsub(
314 to,
315 subject,
316 body,
317 delivery.headers,
318 delivery.unsub_url,
319 )
320 .await
321 }
322 }
323
324 /// Whether an `Optional` message may go to this audience.
325 async fn wants(&self, audience: Audience<'_>, kind: ListKind) -> Result<bool> {
326 match audience {
327 Audience::User(user_id, _) => {
328 let Some(pool) = self.pool.as_ref() else {
329 return Ok(true);
330 };
331 Ok(crate::db::lists::may_notify(pool, user_id, kind)
332 .await
333 .unwrap_or(true))
334 }
335 Audience::Address(email) => {
336 // An opt-outable message aimed at an address with no account
337 // cannot be preference-checked. Sending anyway would be the
338 // silent bypass this module exists to remove, so refuse and let
339 // it surface as an error rather than as unstoppable mail.
340 tracing::error!(
341 recipient = %email,
342 list_kind = %kind,
343 "optional-class email addressed to a bare address; no preference to check"
344 );
345 Err(AppError::Internal(anyhow::anyhow!(
346 "optional-class email ({kind}) requires Audience::User, got Audience::Address"
347 )))
348 }
349 }
350 }
351 }
352
353 /// Postmark-backed email transport (the production implementation).
354 #[derive(Clone)]
355 pub(crate) struct PostmarkTransport {
356 config: EmailConfig,
357 http_client: reqwest::Client,
358 pool: Option<sqlx::PgPool>,
359 }
360
361 impl PostmarkTransport {
362 fn new(config: EmailConfig, pool: Option<sqlx::PgPool>) -> Self {
363 crate::crypto::install_default_crypto_provider();
364 let http_client = reqwest::Client::builder()
365 .timeout(std::time::Duration::from_secs(10))
366 .build()
367 .expect("Failed to build email HTTP client");
368
369 PostmarkTransport {
370 config,
371 http_client,
372 pool,
373 }
374 }
375
376 /// Shared implementation for send-with-unsubscribe, supporting optional message stream.
377 async fn send_with_unsub_inner(
378 &self,
379 to: &str,
380 subject: &str,
381 body: &str,
382 unsub_url: Option<&str>,
383 stream: Option<&str>,
384 ) -> Result<()> {
385 match unsub_url {
386 Some(url) => {
387 let body_with_footer = format!("{body}\n\nUnsubscribe from these emails:\n{url}");
388 let headers = [
389 ("List-Unsubscribe", format!("<{url}>")),
390 (
391 "List-Unsubscribe-Post",
392 "List-Unsubscribe=One-Click".to_string(),
393 ),
394 ];
395 self.send_email_inner(to, subject, &body_with_footer, &headers, stream)
396 .await
397 }
398 None => self.send_email_inner(to, subject, body, &[], stream).await,
399 }
400 }
401
402 /// Internal send implementation supporting optional custom headers and message stream.
403 async fn send_email_inner(
404 &self,
405 to: &str,
406 subject: &str,
407 body: &str,
408 extra_headers: &[(&str, String)],
409 stream: Option<&str>,
410 ) -> Result<()> {
411 // Check suppression list before sending
412 if let Some(ref pool) = self.pool {
413 match crate::db::email_suppressions::is_suppressed(pool, to).await {
414 Ok(true) => {
415 tracing::info!(recipient = %to, subject = %subject, "email skipped (suppressed)");
416 return Ok(());
417 }
418 Ok(false) => {}
419 Err(e) => {
420 // Log but don't block sending on suppression check failure
421 tracing::warn!(recipient = %to, error = %e, "suppression check failed, sending anyway");
422 }
423 }
424 }
425
426 if let Some(ref token) = self.config.postmark_token {
427 self.send_via_postmark(token, to, subject, body, extra_headers, stream)
428 .await
429 } else {
430 tracing::info!(
431 recipient = %to, subject = %subject,
432 "email sent (dev mode, body redacted)"
433 );
434 Ok(())
435 }
436 }
437
438 /// Send email via Postmark API
439 async fn send_via_postmark(
440 &self,
441 token: &str,
442 to: &str,
443 subject: &str,
444 body: &str,
445 extra_headers: &[(&str, String)],
446 stream: Option<&str>,
447 ) -> Result<()> {
448 let from = format!("{} <{}>", self.config.from_name, self.config.from_address);
449
450 let mut payload = serde_json::json!({
451 "From": from,
452 "To": to,
453 "Subject": subject,
454 "TextBody": body,
455 });
456
457 if let Some(stream_id) = stream {
458 payload["MessageStream"] = serde_json::Value::String(stream_id.to_string());
459 }
460
461 if !extra_headers.is_empty() {
462 let headers: Vec<serde_json::Value> = extra_headers
463 .iter()
464 .map(|(name, value)| serde_json::json!({ "Name": name, "Value": value }))
465 .collect();
466 payload["Headers"] = serde_json::Value::Array(headers);
467 }
468
469 // Retry transient failures (network/timeout, 5xx, 429) with bounded
470 // exponential backoff so a brief Postmark blip doesn't permanently drop
471 // critical mail, password resets, purchase receipts, Fan+ credit codes
472 // (Run 20 Resilience). Permanent 4xx (bad request, inactive recipient,
473 // hard bounce) are NOT retried: retrying can't help and only delays the
474 // caller. Bounded to EMAIL_SEND_MAX_ATTEMPTS so an awaited caller adds at
475 // most ~1s on a failing send.
476 const EMAIL_SEND_MAX_ATTEMPTS: u32 = 3;
477 let mut attempt: u32 = 0;
478 loop {
479 attempt += 1;
480 let send_result = self
481 .http_client
482 .post("https://api.postmarkapp.com/email")
483 .header("X-Postmark-Server-Token", token)
484 .header("Content-Type", "application/json")
485 .json(&payload)
486 .send()
487 .await;
488
489 match send_result {
490 Ok(response) if response.status().is_success() => {
491 tracing::info!(recipient = %to, subject = %subject, attempt, "email sent");
492 return Ok(());
493 }
494 Ok(response) => {
495 let status = response.status();
496 let transient = status.is_server_error() || status.as_u16() == 429;
497 let error_text = response.text().await.unwrap_or_default();
498 if transient && attempt < EMAIL_SEND_MAX_ATTEMPTS {
499 let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1));
500 tracing::warn!(status = %status, attempt, error = %error_text, "transient email send failure, retrying after backoff");
501 tokio::time::sleep(backoff).await;
502 continue;
503 }
504 tracing::error!(status = %status, error = %error_text, attempt, "failed to send email");
505 return Err(AppError::Internal(anyhow::anyhow!(
506 "Failed to send email: {status}"
507 )));
508 }
509 Err(e) => {
510 // Network/timeout: always transient.
511 if attempt < EMAIL_SEND_MAX_ATTEMPTS {
512 let backoff = std::time::Duration::from_millis(200 * 2u64.pow(attempt - 1));
513 tracing::warn!(attempt, error = %e, "email send request error, retrying after backoff");
514 tokio::time::sleep(backoff).await;
515 continue;
516 }
517 return Err(AppError::Internal(anyhow::anyhow!(
518 "postmark http request: {e}"
519 )));
520 }
521 }
522 }
523 }
524 }
525
526 #[async_trait::async_trait]
527 impl EmailTransport for PostmarkTransport {
528 async fn send_email(&self, to: &str, subject: &str, body: &str) -> Result<()> {
529 self.send_email_inner(to, subject, body, &[], None).await
530 }
531
532 async fn send_email_with_headers_and_unsub(
533 &self,
534 to: &str,
535 subject: &str,
536 body: &str,
537 extra_headers: &[(&str, String)],
538 unsub_url: Option<&str>,
539 ) -> Result<()> {
540 match unsub_url {
541 Some(url) => {
542 let body_with_footer = format!("{body}\n\nUnsubscribe from these emails:\n{url}");
543 let mut all_headers: Vec<(&str, String)> = extra_headers.to_vec();
544 all_headers.push(("List-Unsubscribe", format!("<{url}>")));
545 all_headers.push((
546 "List-Unsubscribe-Post",
547 "List-Unsubscribe=One-Click".to_string(),
548 ));
549 self.send_email_inner(to, subject, &body_with_footer, &all_headers, None)
550 .await
551 }
552 None => {
553 self.send_email_inner(to, subject, body, extra_headers, None)
554 .await
555 }
556 }
557 }
558
559 async fn send_email_broadcast_with_unsub(
560 &self,
561 to: &str,
562 subject: &str,
563 body: &str,
564 unsub_url: Option<&str>,
565 ) -> Result<()> {
566 self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast"))
567 .await
568 }
569 }
570
571 #[cfg(test)]
572 mod bounded_recipients_tests {
573 use super::*;
574
575 #[test]
576 fn accepts_under_cap() {
577 let r = BoundedRecipients::new(vec![1, 2, 3]).expect("under cap");
578 assert_eq!(r.len(), 3);
579 assert!(!r.is_empty());
580 assert_eq!(r.into_inner(), vec![1, 2, 3]);
581 }
582
583 #[test]
584 fn accepts_exactly_at_cap() {
585 let v = vec![0u8; crate::constants::BROADCAST_MAX_RECIPIENTS];
586 assert!(BoundedRecipients::new(v).is_ok());
587 }
588
589 #[test]
590 fn rejects_over_cap_with_count() {
591 let n = crate::constants::BROADCAST_MAX_RECIPIENTS + 1;
592 let v = vec![0u8; n];
593 assert_eq!(BoundedRecipients::new(v).unwrap_err(), n);
594 }
595
596 #[test]
597 fn empty_is_allowed() {
598 let r = BoundedRecipients::<u8>::new(vec![]).expect("empty ok");
599 assert!(r.is_empty());
600 }
601 }
602
603 /// Seal for the notification-preference gate.
604 ///
605 /// `may_notify` used to be called from sixteen places across seven route
606 /// modules, which is how the gate became forgettable: a new email was
607 /// opt-outable only if whoever wrote the handler remembered, and nothing failed
608 /// when they did not. The fix moves the check into [`EmailClient::dispatch`],
609 /// where declaring an [`EmailClass`] is mandatory. This test fails the build if
610 /// any file outside `db/lists.rs` (its definition) and this module (its one
611 /// caller) references it again, so re-inlining the check at a call site is not
612 /// something you can do quietly.
613 ///
614 /// `notification_prefs` is deliberately not sealed: it reads the same
615 /// preferences to render the settings form, which is a different job from
616 /// gating a send.
617 #[cfg(test)]
618 mod notification_gate_seal_guard {
619 use std::path::Path;
620
621 #[test]
622 fn may_notify_called_only_from_the_send_path() {
623 let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
624 let allowed = [
625 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/db/lists.rs"),
626 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"),
627 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/class.rs"),
628 ];
629 let mut offenders = Vec::new();
630 super::broadcast_cap_seal_guard::walk(&src_dir, &mut |path, contents| {
631 if allowed.iter().any(|a| a == path) {
632 return;
633 }
634 for (i, line) in contents.lines().enumerate() {
635 let code = line.trim();
636 if code.contains("may_notify") && !code.starts_with("//") {
637 offenders.push(format!("{}:{}: {}", path.display(), i + 1, code));
638 }
639 }
640 });
641 assert!(
642 offenders.is_empty(),
643 "notification-preference seal violated. Do not check may_notify at a call site: \
644 give the email an EmailClass::Optional(kind) and let EmailClient::dispatch do it, \
645 so the gate cannot be forgotten by the next handler. Offending lines:\n{}",
646 offenders.join("\n")
647 );
648 }
649 }
650
651 /// Seal for the broadcast recipient cap.
652 ///
653 /// The cap was a copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` check that had
654 /// drifted between the public and internal broadcast handlers. The fix routes
655 /// both through [`BoundedRecipients`], so the constant must appear ONLY in its
656 /// definition (`constants.rs`) and this module's constructor. This test fails
657 /// the build if any other file references the constant, forcing a new broadcast
658 /// site through the sealed constructor instead of re-inlining the check.
659 #[cfg(test)]
660 mod broadcast_cap_seal_guard {
661 use std::path::Path;
662
663 #[test]
664 fn cap_constant_used_only_in_sealed_constructor() {
665 let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
666 let allowed = [
667 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs"),
668 Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"),
669 ];
670 let mut offenders = Vec::new();
671 walk(&src_dir, &mut |path, contents| {
672 if allowed.iter().any(|a| a == path) {
673 return;
674 }
675 for (i, line) in contents.lines().enumerate() {
676 if line.contains("BROADCAST_MAX_RECIPIENTS") {
677 offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
678 }
679 }
680 });
681 assert!(
682 offenders.is_empty(),
683 "broadcast-cap seal violated, enforce the recipient cap via \
684 email::BoundedRecipients::new, never by referencing BROADCAST_MAX_RECIPIENTS \
685 directly in a handler. Offending lines:\n{}",
686 offenders.join("\n")
687 );
688 }
689
690 pub(super) fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
691 let Ok(entries) = std::fs::read_dir(dir) else {
692 return;
693 };
694 for entry in entries.flatten() {
695 let path = entry.path();
696 if path.is_dir() {
697 walk(&path, f);
698 } else if path.extension().is_some_and(|e| e == "rs")
699 && let Ok(contents) = std::fs::read_to_string(&path)
700 {
701 f(&path, &contents);
702 }
703 }
704 }
705 }
706