1#![cfg_attr(
11 not(test),
12 deny(
13 clippy::unwrap_used,
14 clippy::expect_used,
15 clippy::panic,
16 clippy::unreachable,
17 clippy::todo,
18 clippy::unimplemented,
19 clippy::indexing_slicing,
20 )
21)]
22
23use std::future::Future;
24use std::path::{Path, PathBuf};
25use std::pin::Pin;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::{SystemTime, UNIX_EPOCH};
29
30use axum::extract::FromRequestParts;
31use axum::response::{Html, IntoResponse, Response};
32use lettre::message::header::{ContentTransferEncoding, ContentType};
33use lettre::message::{
34 Attachment as LettreAttachment, Body as LettreBody, Mailbox, MultiPart, SinglePart,
35};
36use lettre::transport::smtp::authentication::Credentials;
37use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
38use serde::{Deserialize, Serialize};
39use thiserror::Error;
40
41use crate::{AppState, AutumnError, AutumnResult};
42
43#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Transport {
47 Log,
49 File,
51 Smtp,
53 #[default]
55 Disabled,
56}
57
58impl Transport {
59 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
60 match value.trim().to_ascii_lowercase().as_str() {
61 "log" => Some(Self::Log),
62 "file" => Some(Self::File),
63 "smtp" => Some(Self::Smtp),
64 "disabled" => Some(Self::Disabled),
65 _ => None,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum TlsMode {
74 Disabled,
76 #[default]
78 StartTls,
79 Tls,
81}
82
83impl TlsMode {
84 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
85 match value.trim().to_ascii_lowercase().as_str() {
86 "disabled" => Some(Self::Disabled),
87 "starttls" | "start_tls" => Some(Self::StartTls),
88 "tls" => Some(Self::Tls),
89 _ => None,
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
96pub struct SmtpConfig {
97 #[serde(default)]
99 pub host: Option<String>,
100 #[serde(default)]
102 pub port: Option<u16>,
103 #[serde(default)]
105 pub username: Option<String>,
106 #[serde(default)]
108 pub password_env: Option<String>,
109 #[serde(default)]
111 pub tls: TlsMode,
112}
113
114impl Default for SmtpConfig {
115 fn default() -> Self {
116 Self {
117 host: None,
118 port: None,
119 username: None,
120 password_env: None,
121 tls: TlsMode::StartTls,
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
128#[allow(clippy::struct_excessive_bools)] pub struct MailConfig {
130 #[serde(default)]
132 pub transport: Transport,
133 #[serde(default)]
135 pub from: Option<String>,
136 #[serde(default)]
138 pub reply_to: Option<String>,
139 #[serde(default)]
141 pub allow_log_in_production: bool,
142 #[serde(default)]
146 pub allow_in_process_deliver_later_in_production: bool,
147 #[serde(default = "default_file_dir")]
149 pub file_dir: PathBuf,
150 #[serde(default)]
155 pub preview: bool,
156 #[serde(default)]
161 pub unsubscribe_base_url: Option<String>,
162 #[serde(default)]
165 pub unsubscribe_mailto: Option<String>,
166 #[serde(default = "default_unsubscribe_ttl_days")]
168 pub unsubscribe_token_ttl_days: i64,
169 #[serde(default)]
174 pub mount_unsubscribe_endpoint: bool,
175 #[serde(default)]
185 pub inline_css: bool,
186 #[serde(default)]
188 pub smtp: SmtpConfig,
189}
190
191fn is_valid_https_base_url(url: &str) -> bool {
196 if url
202 .chars()
203 .any(|c| c.is_control() || c.is_whitespace() || matches!(c, '<' | '>'))
204 {
205 return false;
206 }
207 match url.strip_prefix("https://") {
214 Some(rest) if !rest.is_empty() && !rest.starts_with('/') => {}
215 _ => return false,
216 }
217 let Ok(parsed) = ::url::Url::parse(url) else {
218 return false;
219 };
220 parsed.scheme() == "https"
226 && parsed.host_str().is_some_and(|h| !h.is_empty())
227 && parsed.username().is_empty()
228 && parsed.password().is_none()
229 && parsed.query().is_none()
230 && parsed.fragment().is_none()
231}
232
233fn is_valid_mailto_address(value: &str) -> bool {
236 if value
242 .chars()
243 .any(|c| c.is_control() || matches!(c, '<' | '>' | ','))
244 {
245 return false;
246 }
247 let address = value
248 .trim()
249 .strip_prefix("mailto:")
250 .unwrap_or_else(|| value.trim());
251 let address = address.split('?').next().unwrap_or("");
253 match address.split_once('@') {
254 Some((local, domain)) => {
255 !local.is_empty()
256 && !domain.is_empty()
257 && domain.contains('.')
258 && !address.contains(char::is_whitespace)
259 && !address.contains([':', '/'])
263 }
264 None => false,
265 }
266}
267
268const fn default_unsubscribe_ttl_days() -> i64 {
269 crate::mail::unsubscribe::DEFAULT_TOKEN_TTL_DAYS
270}
271
272impl Default for MailConfig {
273 fn default() -> Self {
274 Self {
275 transport: Transport::Disabled,
276 from: None,
277 reply_to: None,
278 allow_log_in_production: false,
279 allow_in_process_deliver_later_in_production: false,
280 file_dir: default_file_dir(),
281 preview: false,
282 unsubscribe_base_url: None,
283 unsubscribe_mailto: None,
284 unsubscribe_token_ttl_days: default_unsubscribe_ttl_days(),
285 mount_unsubscribe_endpoint: false,
286 inline_css: false,
287 smtp: SmtpConfig::default(),
288 }
289 }
290}
291
292impl MailConfig {
293 pub fn validate(&self, profile: Option<&str>) -> Result<(), crate::config::ConfigError> {
300 if matches!(profile, Some("prod" | "production"))
301 && self.transport == Transport::Log
302 && !self.allow_log_in_production
303 {
304 return Err(crate::config::ConfigError::Validation(
305 "mail.transport = \"log\" is disabled in prod; set mail.allow_log_in_production = true to acknowledge this explicitly".to_owned(),
306 ));
307 }
308
309 if self.transport == Transport::Smtp
310 && self.smtp.host.as_deref().map_or("", str::trim).is_empty()
311 {
312 return Err(crate::config::ConfigError::Validation(
313 "mail.smtp.host is required when mail.transport = \"smtp\"".to_owned(),
314 ));
315 }
316
317 if self.preview && !matches!(profile, Some("dev" | "development")) {
318 return Err(crate::config::ConfigError::Validation(
319 "mail.preview = true is only allowed in dev; refusing to mount /_autumn/mail outside the dev profile".to_owned(),
320 ));
321 }
322
323 if self.unsubscribe_token_ttl_days <= 0 {
324 return Err(crate::config::ConfigError::Validation(
325 "mail.unsubscribe_token_ttl_days must be a positive number of days; a non-positive value would make every unsubscribe token immediately expired".to_owned(),
326 ));
327 }
328
329 if matches!(profile, Some("prod" | "production"))
330 && let Some(base) = self.unsubscribe_base_url.as_deref().map(str::trim)
331 && !base.is_empty()
332 && !is_valid_https_base_url(base)
333 {
334 return Err(crate::config::ConfigError::Validation(
335 "mail.unsubscribe_base_url must be an absolute https:// URL with a host in prod; mailbox providers require HTTPS for RFC 8058 one-click unsubscribe".to_owned(),
336 ));
337 }
338
339 if matches!(profile, Some("prod" | "production"))
340 && let Some(mailto) = self.unsubscribe_mailto.as_deref().map(str::trim)
341 && !mailto.is_empty()
342 && !is_valid_mailto_address(mailto)
343 {
344 return Err(crate::config::ConfigError::Validation(
345 "mail.unsubscribe_mailto must be a bare mailbox address (or mailto: URI) like unsubscribe@example.com".to_owned(),
346 ));
347 }
348
349 Ok(())
350 }
351
352 pub(crate) fn preview_routes_enabled(&self, profile: Option<&str>) -> bool {
353 matches!(profile, Some("dev" | "development"))
354 && (self.preview || self.transport == Transport::File)
355 }
356
357 pub(crate) fn unsubscribe_base_url_set(&self) -> bool {
360 self.unsubscribe_base_url
361 .as_deref()
362 .is_some_and(|s| !s.trim().is_empty())
363 }
364
365 pub(crate) fn should_mount_unsubscribe_endpoint(&self) -> bool {
369 self.mount_unsubscribe_endpoint && self.unsubscribe_base_url_set()
370 }
371}
372
373fn default_file_dir() -> PathBuf {
374 PathBuf::from("target/mail")
375}
376
377pub trait IntoMailBody {
379 fn into_mail_body(self) -> String;
381}
382
383impl IntoMailBody for String {
384 fn into_mail_body(self) -> String {
385 self
386 }
387}
388
389impl IntoMailBody for &str {
390 fn into_mail_body(self) -> String {
391 self.to_owned()
392 }
393}
394
395impl IntoMailBody for maud::Markup {
396 fn into_mail_body(self) -> String {
397 self.into_string()
398 }
399}
400
401pub const MAIL_LAYOUT_CONTENT_MARKER: &str = "{{ content }}";
407
408#[must_use]
414pub fn compose_layout(layout: &str, body: &str) -> String {
415 if layout.contains(MAIL_LAYOUT_CONTENT_MARKER) {
416 layout.replace(MAIL_LAYOUT_CONTENT_MARKER, body)
417 } else {
418 body.to_owned()
419 }
420}
421
422fn html_contains_style_block(html: &str) -> bool {
426 html.as_bytes()
427 .windows(6)
428 .any(|window| window.eq_ignore_ascii_case(b"<style"))
429}
430
431fn html_is_full_document(html: &str) -> bool {
438 let bytes = html.as_bytes();
439 bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<html"))
440 || bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<body"))
441 || bytes
442 .windows(9)
443 .any(|w| w.eq_ignore_ascii_case(b"<!doctype"))
444}
445
446fn unwrap_synthetic_document(doc: &str) -> String {
464 let inner = doc
467 .strip_prefix("<html>")
468 .and_then(|rest| rest.strip_suffix("</html>"))
469 .unwrap_or(doc);
470 let (head, after_head) = match inner.strip_prefix("<head>") {
471 Some(rest) => match rest.split_once("</head>") {
472 Some(split) => split,
473 None => return doc.to_owned(),
475 },
476 None => ("", inner),
477 };
478 let body = after_head
479 .strip_prefix("<body>")
480 .map_or(after_head, |rest| {
481 rest.strip_suffix("</body>").unwrap_or(rest)
482 });
483 format!("{head}{body}")
484}
485
486fn inline_css_html(html: &str) -> Result<String, MailError> {
514 if !html_contains_style_block(html) {
517 return Ok(html.to_owned());
518 }
519 let inliner = css_inline::CSSInliner::options()
520 .keep_style_tags(true)
522 .keep_at_rules(true)
525 .remove_inlined_selectors(true)
528 .load_remote_stylesheets(false)
530 .keep_link_tags(true)
534 .apply_width_attributes(true)
539 .apply_height_attributes(true)
540 .build();
541 let is_fragment = !html_is_full_document(html);
547 let rendered = inliner
548 .inline(html)
549 .map_err(|error| MailError::CssInline(error.to_string()))?;
556 Ok(if is_fragment {
557 unwrap_synthetic_document(&rendered)
558 } else {
559 rendered
560 })
561}
562
563#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
569pub struct MailAttachment {
570 pub filename: String,
572 pub content_type: String,
574 pub bytes: Vec<u8>,
576}
577
578impl std::fmt::Debug for MailAttachment {
579 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580 f.debug_struct("MailAttachment")
581 .field("filename", &self.filename)
582 .field("content_type", &self.content_type)
583 .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
584 .finish()
585 }
586}
587
588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct Mail {
591 pub from: Option<String>,
593 pub reply_to: Option<String>,
595 pub to: Vec<String>,
597 pub subject: String,
599 pub html: Option<String>,
601 pub text: Option<String>,
603 pub list_unsubscribe: Option<String>,
609 pub extra_headers: Vec<(String, String)>,
613 #[serde(default)]
615 pub attachments: Vec<MailAttachment>,
616 #[serde(default)]
622 pub ignore_suppression: bool,
623 #[serde(default)]
634 pub inline_css: Option<bool>,
635}
636
637pub const MAIL_PREVIEW_PATH: &str = "/_autumn/mail";
639
640const MAIL_PREVIEW_MESSAGE_PATH: &str = "/_autumn/mail/messages/{message_id}";
641const MAIL_PREVIEW_TEMPLATE_PATH: &str = "/_autumn/mail/previews/{mailer}/{method}";
642
643#[derive(Clone)]
645pub struct MailPreview {
646 mailer: &'static str,
647 method: &'static str,
648 render: fn() -> Mail,
649}
650
651impl std::fmt::Debug for MailPreview {
652 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653 f.debug_struct("MailPreview")
654 .field("mailer", &self.mailer)
655 .field("method", &self.method)
656 .finish_non_exhaustive()
657 }
658}
659
660impl MailPreview {
661 #[must_use]
663 pub const fn new(mailer: &'static str, method: &'static str, render: fn() -> Mail) -> Self {
664 Self {
665 mailer,
666 method,
667 render,
668 }
669 }
670
671 #[must_use]
673 pub const fn mailer(&self) -> &'static str {
674 self.mailer
675 }
676
677 #[must_use]
679 pub const fn method(&self) -> &'static str {
680 self.method
681 }
682
683 pub fn render(&self) -> Result<Mail, MailPreviewError> {
690 std::panic::catch_unwind(|| (self.render)()).map_err(|_| {
691 MailPreviewError::PreviewPanicked {
692 mailer: self.mailer,
693 method: self.method,
694 }
695 })
696 }
697}
698
699#[derive(Debug, Clone, Default)]
701pub struct MailPreviewRegistry {
702 previews: Arc<Vec<MailPreview>>,
703}
704
705impl MailPreviewRegistry {
706 #[must_use]
708 pub fn new(previews: Vec<MailPreview>) -> Self {
709 Self {
710 previews: Arc::new(previews),
711 }
712 }
713
714 #[must_use]
716 pub fn previews(&self) -> &[MailPreview] {
717 &self.previews
718 }
719
720 fn find(&self, mailer: &str, method: &str) -> Option<MailPreview> {
721 self.previews
722 .iter()
723 .find(|preview| preview.mailer == mailer && preview.method == method)
724 .cloned()
725 }
726}
727
728#[derive(Debug, Error)]
730pub enum MailPreviewError {
731 #[error("mail preview file IO failed: {0}")]
733 Io(#[from] std::io::Error),
734 #[error("captured mail message not found: {0}")]
736 NotFound(String),
737 #[error("invalid captured mail message id: {0}")]
739 InvalidMessageId(String),
740 #[error("mail preview {mailer}::{method} panicked while rendering")]
742 PreviewPanicked {
743 mailer: &'static str,
745 method: &'static str,
747 },
748}
749
750impl Mail {
751 #[must_use]
753 pub fn builder() -> MailBuilder {
754 MailBuilder::default()
755 }
756
757 fn with_defaults(mut self, defaults: &MailerDefaults) -> Self {
758 if self.from.is_none() {
759 self.from.clone_from(&defaults.from);
760 }
761 if self.reply_to.is_none() {
762 self.reply_to.clone_from(&defaults.reply_to);
763 }
764 self
765 }
766}
767
768#[derive(Debug, Clone, Default)]
770pub struct MailBuilder {
771 from: Option<String>,
772 reply_to: Option<String>,
773 to: Vec<String>,
774 subject: Option<String>,
775 html: Option<String>,
776 text: Option<String>,
777 html_layout: Option<String>,
778 text_layout: Option<String>,
779 list_unsubscribe: Option<String>,
780 extra_headers: Vec<(String, String)>,
781 attachments: Vec<MailAttachment>,
782 ignore_suppression: bool,
783 inline_css: Option<bool>,
784}
785
786impl MailBuilder {
787 #[must_use]
789 pub fn from(mut self, from: impl Into<String>) -> Self {
790 self.from = Some(from.into());
791 self
792 }
793
794 #[must_use]
796 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
797 self.reply_to = Some(reply_to.into());
798 self
799 }
800
801 #[must_use]
803 pub fn to(mut self, to: impl Into<String>) -> Self {
804 self.to.push(to.into());
805 self
806 }
807
808 #[must_use]
810 pub fn subject(mut self, subject: impl Into<String>) -> Self {
811 self.subject = Some(subject.into());
812 self
813 }
814
815 #[must_use]
817 pub fn html(mut self, html: impl IntoMailBody) -> Self {
818 self.html = Some(html.into_mail_body());
819 self
820 }
821
822 #[must_use]
824 pub fn text(mut self, text: impl IntoMailBody) -> Self {
825 self.text = Some(text.into_mail_body());
826 self
827 }
828
829 #[must_use]
836 pub fn list_unsubscribe(mut self, scope: impl Into<String>) -> Self {
837 self.list_unsubscribe = Some(scope.into());
838 self
839 }
840
841 #[must_use]
849 pub const fn ignore_suppression(mut self) -> Self {
850 self.ignore_suppression = true;
851 self
852 }
853
854 #[must_use]
869 pub const fn inline_css(mut self, enabled: bool) -> Self {
870 self.inline_css = Some(enabled);
871 self
872 }
873
874 #[must_use]
876 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
877 self.extra_headers.push((name.into(), value.into()));
878 self
879 }
880
881 #[must_use]
895 pub fn attach(
896 mut self,
897 filename: impl Into<String>,
898 content_type: impl Into<String>,
899 bytes: impl Into<Vec<u8>>,
900 ) -> Self {
901 self.attachments.push(MailAttachment {
902 filename: filename.into(),
903 content_type: content_type.into(),
904 bytes: bytes.into(),
905 });
906 self
907 }
908
909 #[must_use]
920 pub fn layout(
921 mut self,
922 html_layout: impl IntoMailBody,
923 text_layout: impl IntoMailBody,
924 ) -> Self {
925 self.html_layout = Some(html_layout.into_mail_body());
926 self.text_layout = Some(text_layout.into_mail_body());
927 self
928 }
929
930 pub fn build(self) -> Result<Mail, MailError> {
936 if self.to.is_empty() {
937 return Err(MailError::InvalidMessage(
938 "mail must have at least one recipient".to_owned(),
939 ));
940 }
941 let subject = self
942 .subject
943 .filter(|s| !s.trim().is_empty())
944 .ok_or_else(|| MailError::InvalidMessage("mail subject is required".to_owned()))?;
945 if self.html.is_none() && self.text.is_none() {
946 return Err(MailError::InvalidMessage(
947 "mail must include html or text body".to_owned(),
948 ));
949 }
950 for attachment in &self.attachments {
951 if attachment.filename.trim().is_empty()
952 || attachment.filename.chars().any(char::is_control)
953 {
954 return Err(MailError::InvalidMessage(format!(
955 "attachment filename {:?} must be non-empty and free of control characters",
956 attachment.filename
957 )));
958 }
959 if let Err(error) = ContentType::parse(&attachment.content_type) {
960 return Err(MailError::InvalidMessage(format!(
961 "attachment {:?} has invalid content type {:?}: {error}",
962 attachment.filename, attachment.content_type
963 )));
964 }
965 }
966 let html = match (self.html, self.html_layout) {
971 (Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
972 (html, _) => html, };
974 let text = match (self.text, self.text_layout) {
975 (Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
976 (text, _) => text, };
978 Ok(Mail {
979 from: self.from,
980 reply_to: self.reply_to,
981 to: self.to,
982 subject,
983 html,
984 text,
985 list_unsubscribe: self.list_unsubscribe,
986 extra_headers: self.extra_headers,
987 attachments: self.attachments,
988 ignore_suppression: self.ignore_suppression,
989 inline_css: self.inline_css,
990 })
991 }
992}
993
994#[derive(Debug, Error)]
996pub enum MailError {
997 #[error("invalid mail message: {0}")]
999 InvalidMessage(String),
1000 #[error("mail runtime unavailable: {0}")]
1002 RuntimeUnavailable(String),
1003 #[error("invalid mail address {address:?}: {source}")]
1005 InvalidAddress {
1006 address: String,
1008 source: lettre::address::AddressError,
1010 },
1011 #[error("failed to build mail message: {0}")]
1013 Build(#[from] lettre::error::Error),
1014 #[error("smtp send failed: {0}")]
1016 Smtp(#[from] lettre::transport::smtp::Error),
1017 #[error("file mail transport failed: {0}")]
1019 Io(#[from] std::io::Error),
1020 #[error("all recipients are on the mail suppression list; nothing was sent")]
1025 AllRecipientsSuppressed,
1026 #[error("failed to inline CSS into HTML mail body: {0}")]
1033 CssInline(String),
1034}
1035
1036pub trait MailTransport: Send + Sync {
1038 fn send<'a>(
1040 &'a self,
1041 mail: Mail,
1042 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1043
1044 fn is_disabled(&self) -> bool {
1054 false
1055 }
1056}
1057
1058pub trait MailDeliveryQueue: Send + Sync {
1068 fn enqueue<'a>(
1070 &'a self,
1071 mail: Mail,
1072 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1073}
1074
1075#[derive(Clone)]
1081pub struct MailDeliveryQueueHandle(Arc<dyn MailDeliveryQueue>);
1082
1083impl MailDeliveryQueueHandle {
1084 #[must_use]
1086 pub fn new(queue: impl MailDeliveryQueue + 'static) -> Self {
1087 Self(Arc::new(queue))
1088 }
1089
1090 #[must_use]
1092 pub fn from_arc(queue: Arc<dyn MailDeliveryQueue>) -> Self {
1093 Self(queue)
1094 }
1095
1096 #[must_use]
1098 pub fn inner(&self) -> &Arc<dyn MailDeliveryQueue> {
1099 &self.0
1100 }
1101}
1102
1103impl std::fmt::Debug for MailDeliveryQueueHandle {
1104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1105 f.debug_struct("MailDeliveryQueueHandle").finish()
1106 }
1107}
1108
1109pub const UNSUBSCRIBE_PATH: &str = "/_autumn/unsubscribe";
1113
1114#[derive(Debug)]
1120pub struct MailerListUnsubscribeDescriptor {
1121 pub mailer: &'static str,
1123 pub scope: &'static str,
1125}
1126
1127inventory::collect!(MailerListUnsubscribeDescriptor);
1128
1129#[must_use]
1131pub fn registered_list_unsubscribe_scopes() -> Vec<&'static MailerListUnsubscribeDescriptor> {
1132 inventory::iter::<MailerListUnsubscribeDescriptor>
1133 .into_iter()
1134 .collect()
1135}
1136
1137#[must_use]
1140pub fn has_list_unsubscribe_mailers() -> bool {
1141 inventory::iter::<MailerListUnsubscribeDescriptor>
1142 .into_iter()
1143 .next()
1144 .is_some()
1145}
1146
1147#[must_use]
1150#[allow(clippy::fn_params_excessive_bools)]
1151pub(crate) const fn unsubscribe_config_fail_closed(
1152 enforce: bool,
1153 in_production: bool,
1154 has_list_mailers: bool,
1155 unsubscribe_configured: bool,
1156) -> bool {
1157 enforce && in_production && has_list_mailers && !unsubscribe_configured
1158}
1159
1160pub mod unsubscribe {
1172 use aes_gcm::aead::{Aead, KeyInit};
1173 use aes_gcm::{Aes256Gcm, Nonce};
1174 use base64::Engine as _;
1175 use hmac::{Hmac, Mac};
1176 use sha2::Sha256;
1177
1178 use crate::security::config::ResolvedSigningKeys;
1179
1180 pub const DEFAULT_TOKEN_TTL_DAYS: i64 = 30;
1182
1183 const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1184 const TOKEN_VERSION: u8 = 1;
1186 const NONCE_LEN: usize = 12;
1188 const KEY_CONTEXT: &[u8] = b"autumn:unsubscribe-token:v1";
1191
1192 #[derive(Debug, Clone, PartialEq, Eq)]
1194 pub struct Unsubscribed {
1195 pub subscriber: String,
1197 pub list_id: String,
1199 }
1200
1201 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1203 pub enum TokenError {
1204 #[error("unsubscribe token is malformed")]
1206 Malformed,
1207 #[error("unsubscribe token signature is invalid")]
1209 BadSignature,
1210 #[error("unsubscribe token has expired")]
1212 Expired,
1213 }
1214
1215 #[allow(
1218 clippy::expect_used,
1219 reason = "infallible: HMAC accepts any key length"
1220 )]
1221 fn derive_key(signing_key: &[u8]) -> [u8; 32] {
1222 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(signing_key)
1223 .expect("HMAC accepts any key length");
1224 mac.update(KEY_CONTEXT);
1225 let bytes = mac.finalize().into_bytes();
1226 let mut key = [0u8; 32];
1227 key.copy_from_slice(&bytes);
1228 key
1229 }
1230
1231 fn plaintext(subscriber: &str, list_id: &str, expiry_unix: i64) -> String {
1233 format!(
1234 "{}.{}.{expiry_unix}",
1235 ENGINE.encode(subscriber.as_bytes()),
1236 ENGINE.encode(list_id.as_bytes()),
1237 )
1238 }
1239
1240 #[must_use]
1249 #[allow(
1250 clippy::expect_used,
1251 reason = "infallible crypto: AES-256-GCM over a 32-byte derived key; an OS RNG failure is an unrecoverable environment fault surfaced as a documented panic"
1252 )]
1253 pub fn sign_token(
1254 keys: &ResolvedSigningKeys,
1255 subscriber: &str,
1256 list_id: &str,
1257 expiry_unix: i64,
1258 ) -> String {
1259 let key = derive_key(&keys.current);
1260 let cipher = Aes256Gcm::new_from_slice(&key).expect("derived key is always 32 bytes");
1261 let mut nonce_bytes = [0u8; NONCE_LEN];
1262 getrandom::getrandom(&mut nonce_bytes).expect("OS RNG failed");
1263 let ciphertext = cipher
1264 .encrypt(
1265 Nonce::from_slice(&nonce_bytes),
1266 plaintext(subscriber, list_id, expiry_unix).as_bytes(),
1267 )
1268 .expect("AES-GCM encryption cannot fail for valid inputs");
1269 let mut blob = Vec::with_capacity(1 + NONCE_LEN + ciphertext.len());
1270 blob.push(TOKEN_VERSION);
1271 blob.extend_from_slice(&nonce_bytes);
1272 blob.extend_from_slice(&ciphertext);
1273 ENGINE.encode(blob)
1274 }
1275
1276 #[allow(
1284 clippy::indexing_slicing,
1285 reason = "blob.len() is checked to be >= 1 + NONCE_LEN above, so these indices are in bounds"
1286 )]
1287 pub fn verify_token(
1288 keys: &ResolvedSigningKeys,
1289 token: &str,
1290 now_unix: i64,
1291 ) -> Result<Unsubscribed, TokenError> {
1292 let blob = ENGINE.decode(token).map_err(|_| TokenError::Malformed)?;
1293 if blob.len() < 1 + NONCE_LEN {
1294 return Err(TokenError::Malformed);
1295 }
1296 if blob[0] != TOKEN_VERSION {
1297 return Err(TokenError::Malformed);
1298 }
1299 let nonce = Nonce::from_slice(&blob[1..=NONCE_LEN]);
1300 let ciphertext = &blob[1 + NONCE_LEN..];
1301 let payload = std::iter::once(&keys.current)
1304 .chain(keys.previous.iter())
1305 .find_map(|signing_key| {
1306 let key = derive_key(signing_key);
1307 let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
1310 cipher.decrypt(nonce, ciphertext).ok()
1311 })
1312 .ok_or(TokenError::BadSignature)?;
1313 let payload = String::from_utf8(payload).map_err(|_| TokenError::Malformed)?;
1314 let mut parts = payload.split('.');
1315 let subscriber_b64 = parts.next().ok_or(TokenError::Malformed)?;
1316 let list_b64 = parts.next().ok_or(TokenError::Malformed)?;
1317 let expiry_s = parts.next().ok_or(TokenError::Malformed)?;
1318 if parts.next().is_some() {
1319 return Err(TokenError::Malformed);
1320 }
1321 let expiry: i64 = expiry_s.parse().map_err(|_| TokenError::Malformed)?;
1322 if now_unix > expiry {
1323 return Err(TokenError::Expired);
1324 }
1325 let subscriber = decode_field(subscriber_b64)?;
1326 let list_id = decode_field(list_b64)?;
1327 Ok(Unsubscribed {
1328 subscriber,
1329 list_id,
1330 })
1331 }
1332
1333 fn decode_field(encoded: &str) -> Result<String, TokenError> {
1334 let bytes = ENGINE.decode(encoded).map_err(|_| TokenError::Malformed)?;
1335 String::from_utf8(bytes).map_err(|_| TokenError::Malformed)
1336 }
1337
1338 #[must_use]
1340 pub fn unsubscribe_url(base_url: &str, token: &str) -> String {
1341 format!(
1342 "{}{}?token={token}",
1343 base_url.trim_end_matches('/'),
1344 super::UNSUBSCRIBE_PATH,
1345 )
1346 }
1347}
1348
1349pub trait SuppressionStore: Send + Sync {
1356 fn is_suppressed<'a>(
1358 &'a self,
1359 subscriber: &'a str,
1360 list_id: &'a str,
1361 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
1362
1363 fn suppress<'a>(
1365 &'a self,
1366 subscriber: &'a str,
1367 list_id: &'a str,
1368 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1369}
1370
1371#[derive(Clone)]
1373pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
1374
1375impl SuppressionStoreHandle {
1376 #[must_use]
1378 pub fn new(store: impl SuppressionStore + 'static) -> Self {
1379 Self(Arc::new(store))
1380 }
1381
1382 #[must_use]
1384 pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
1385 Self(store)
1386 }
1387
1388 #[must_use]
1390 pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
1391 &self.0
1392 }
1393}
1394
1395impl std::fmt::Debug for SuppressionStoreHandle {
1396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1397 f.debug_struct("SuppressionStoreHandle").finish()
1398 }
1399}
1400
1401#[derive(Debug, Default, Clone)]
1406pub struct InMemorySuppressionStore {
1407 suppressed: Arc<std::sync::Mutex<std::collections::HashSet<(String, String)>>>,
1408}
1409
1410impl InMemorySuppressionStore {
1411 #[must_use]
1413 pub fn new() -> Self {
1414 Self::default()
1415 }
1416}
1417
1418impl SuppressionStore for InMemorySuppressionStore {
1419 fn is_suppressed<'a>(
1420 &'a self,
1421 subscriber: &'a str,
1422 list_id: &'a str,
1423 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
1424 Box::pin(async move {
1425 let key = (subscriber.to_owned(), list_id.to_owned());
1426 let suppressed = self
1427 .suppressed
1428 .lock()
1429 .unwrap_or_else(std::sync::PoisonError::into_inner)
1430 .contains(&key);
1431 Ok(suppressed)
1432 })
1433 }
1434
1435 fn suppress<'a>(
1436 &'a self,
1437 subscriber: &'a str,
1438 list_id: &'a str,
1439 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1440 Box::pin(async move {
1441 let key = (subscriber.to_owned(), list_id.to_owned());
1442 self.suppressed
1443 .lock()
1444 .unwrap_or_else(std::sync::PoisonError::into_inner)
1445 .insert(key);
1446 Ok(())
1447 })
1448 }
1449}
1450
1451pub struct UnsubscribeRuntime {
1458 pub base_url: Option<String>,
1460 pub mailto: Option<String>,
1462 pub signing_keys: Arc<crate::security::config::ResolvedSigningKeys>,
1464 pub ttl_days: i64,
1466 pub suppression: Option<Arc<dyn SuppressionStore>>,
1468}
1469
1470impl std::fmt::Debug for UnsubscribeRuntime {
1471 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1472 f.debug_struct("UnsubscribeRuntime")
1473 .field("base_url", &self.base_url)
1474 .field("mailto", &self.mailto)
1475 .field("ttl_days", &self.ttl_days)
1476 .field("has_suppression", &self.suppression.is_some())
1477 .finish_non_exhaustive()
1478 }
1479}
1480
1481impl UnsubscribeRuntime {
1482 #[must_use]
1486 pub fn list_unsubscribe_header(&self, subscriber: &str, list_id: &str) -> Option<String> {
1487 let mut entries: Vec<String> = Vec::new();
1488 if let Some(base) = self.base_url.as_deref().filter(|s| !s.trim().is_empty()) {
1489 let expiry = current_unix_time().saturating_add(self.ttl_days.saturating_mul(86_400));
1490 let token = unsubscribe::sign_token(&self.signing_keys, subscriber, list_id, expiry);
1491 entries.push(format!("<{}>", unsubscribe::unsubscribe_url(base, &token)));
1492 }
1493 if let Some(mailto) = self.mailto.as_deref().filter(|s| !s.trim().is_empty()) {
1494 let trimmed = mailto.trim();
1500 let address = trimmed.strip_prefix("mailto:").unwrap_or(trimmed);
1501 let address = address.split('?').next().unwrap_or(address);
1502 entries.push(format!("<mailto:{address}?subject=unsubscribe>"));
1503 }
1504 if entries.is_empty() {
1505 None
1506 } else {
1507 Some(entries.join(", "))
1508 }
1509 }
1510
1511 #[must_use]
1515 pub fn supports_one_click(&self) -> bool {
1516 self.base_url
1517 .as_deref()
1518 .is_some_and(|s| !s.trim().is_empty())
1519 }
1520}
1521
1522fn current_unix_time() -> i64 {
1523 SystemTime::now()
1524 .duration_since(UNIX_EPOCH)
1525 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
1526}
1527
1528#[derive(Debug, Clone, Default)]
1529struct MailerDefaults {
1530 from: Option<String>,
1531 reply_to: Option<String>,
1532}
1533
1534#[derive(Clone)]
1536pub struct Mailer {
1537 defaults: Arc<MailerDefaults>,
1538 transport: Arc<dyn MailTransport>,
1539 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
1540 unsubscribe: Option<Arc<UnsubscribeRuntime>>,
1541 suppression: Option<Arc<dyn suppression::SuppressionStore>>,
1545 inline_css_default: bool,
1548}
1549
1550impl Mailer {
1551 #[must_use]
1553 pub fn builder() -> MailerBuilder {
1554 MailerBuilder::default()
1555 }
1556
1557 pub fn from_config(config: &MailConfig) -> Result<Self, MailError> {
1563 Self::from_config_inner(config, None)
1564 }
1565
1566 pub(crate) fn from_config_inner(
1567 config: &MailConfig,
1568 resilience: Option<Arc<crate::config::ResilienceConfig>>,
1569 ) -> Result<Self, MailError> {
1570 let mut builder = Self::builder()
1571 .transport(config.transport)
1572 .inline_css(config.inline_css)
1573 .resilience_config(resilience);
1574 if let Some(from) = &config.from {
1575 builder = builder.from(from.clone());
1576 }
1577 if let Some(reply_to) = &config.reply_to {
1578 builder = builder.reply_to(reply_to.clone());
1579 }
1580 if config.transport == Transport::File {
1581 builder = builder.file_dir(config.file_dir.clone());
1582 }
1583 if config.transport == Transport::Smtp {
1584 builder = builder.smtp(config.smtp.clone());
1585 }
1586 builder.build()
1587 }
1588
1589 #[must_use]
1591 pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
1592 Self {
1593 defaults: Arc::new(MailerDefaults::default()),
1594 transport: Arc::new(transport),
1595 delivery_queue: None,
1596 unsubscribe: None,
1597 suppression: None,
1598 inline_css_default: false,
1599 }
1600 }
1601
1602 #[must_use]
1604 pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
1605 self.delivery_queue = Some(Arc::new(queue));
1606 self
1607 }
1608
1609 #[must_use]
1612 pub fn with_unsubscribe(mut self, runtime: Arc<UnsubscribeRuntime>) -> Self {
1613 self.unsubscribe = Some(runtime);
1614 self
1615 }
1616
1617 #[must_use]
1622 pub fn with_suppression(mut self, store: suppression::SuppressionStoreHandle) -> Self {
1623 self.suppression = Some(store.into_inner());
1624 self
1625 }
1626
1627 #[must_use]
1629 pub fn has_durable_delivery_queue(&self) -> bool {
1630 self.delivery_queue.is_some()
1631 }
1632
1633 #[must_use]
1639 pub fn is_disabled(&self) -> bool {
1640 self.transport.is_disabled()
1641 }
1642
1643 pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
1668 let mut mail = mail.with_defaults(&self.defaults);
1669
1670 self.apply_css_inlining(&mut mail)?;
1676
1677 if !mail.ignore_suppression
1683 && let Some(store) = self.suppression.as_ref()
1684 && !mail.to.is_empty()
1685 {
1686 let mut kept: Vec<String> = Vec::with_capacity(mail.to.len());
1687 for recipient in &mail.to {
1688 if store.is_suppressed(recipient).await? {
1692 suppression::note_skip(&canonical_subscriber(recipient));
1693 } else {
1694 kept.push(recipient.clone());
1695 }
1696 }
1697 if kept.is_empty() {
1698 return Err(MailError::AllRecipientsSuppressed);
1699 }
1700 mail.to = kept;
1701 }
1702
1703 if let Some(list_id) = mail.list_unsubscribe.clone() {
1704 if let Some(runtime) = self.unsubscribe.clone() {
1705 return self.send_list_mail(mail, list_id, &runtime).await;
1706 }
1707 tracing::warn!(
1711 target: "mail",
1712 list_id = %list_id,
1713 "sending list mail without an unsubscribe runtime: no List-Unsubscribe headers or suppression applied (set mail.unsubscribe_base_url / mail.unsubscribe_mailto)"
1714 );
1715 }
1716 self.transport.send(mail).await
1717 }
1718
1719 fn apply_css_inlining(&self, mail: &mut Mail) -> Result<(), MailError> {
1728 let enabled = mail.inline_css.unwrap_or(self.inline_css_default);
1729 if !enabled {
1730 return Ok(());
1731 }
1732 let Some(html) = mail.html.as_deref() else {
1733 return Ok(());
1734 };
1735 match inline_css_html(html) {
1736 Ok(inlined) => {
1737 mail.html = Some(inlined);
1738 Ok(())
1739 }
1740 Err(error) => {
1741 tracing::warn!(
1744 target: "mail",
1745 error = %error,
1746 "CSS inlining failed; HTML body left un-inlined"
1747 );
1748 Err(error)
1749 }
1750 }
1751 }
1752
1753 const fn freeze_inline_css_default(&self, mail: &mut Mail) {
1768 if mail.inline_css.is_none() {
1769 mail.inline_css = Some(self.inline_css_default);
1770 }
1771 }
1772
1773 async fn send_list_mail(
1776 &self,
1777 mail: Mail,
1778 list_id: String,
1779 runtime: &UnsubscribeRuntime,
1780 ) -> Result<(), MailError> {
1781 let mut deliveries: Vec<(String, String)> = Vec::with_capacity(mail.to.len());
1794 for recipient in &mail.to {
1795 parse_mailbox(recipient)?;
1796 let subscriber = canonical_subscriber(recipient);
1797 if let Some(store) = runtime.suppression.as_ref()
1798 && store.is_suppressed(&subscriber, &list_id).await?
1799 {
1800 tracing::info!(
1801 target: "mail",
1802 list_id = %list_id,
1803 outcome = "skipped_suppressed",
1804 "skipping suppressed list-unsubscribe recipient"
1805 );
1806 continue;
1807 }
1808 deliveries.push((recipient.clone(), subscriber));
1809 }
1810
1811 for (recipient, subscriber) in deliveries {
1812 let mut per_recipient = mail.clone();
1813 per_recipient.to = vec![recipient];
1814 if let Some(value) = runtime.list_unsubscribe_header(&subscriber, &list_id) {
1815 per_recipient.extra_headers.retain(|(name, _)| {
1821 !name.eq_ignore_ascii_case("List-Unsubscribe")
1822 && !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
1823 });
1824 per_recipient
1825 .extra_headers
1826 .push(("List-Unsubscribe".to_owned(), value));
1827 if runtime.supports_one_click() {
1830 per_recipient.extra_headers.push((
1831 "List-Unsubscribe-Post".to_owned(),
1832 "List-Unsubscribe=One-Click".to_owned(),
1833 ));
1834 }
1835 }
1836 self.transport.send(per_recipient).await?;
1837 }
1838 Ok(())
1839 }
1840
1841 pub fn deliver_later(&self, mail: Mail) {
1858 if let Err(error) = self.try_deliver_later(mail) {
1859 tracing::error!(error = %error, "background mail delivery was not scheduled");
1860 }
1861 }
1862
1863 pub fn deliver_later_eager(&self, mail: Mail) {
1871 if let Err(error) = self.try_deliver_later_eager(mail) {
1872 tracing::error!(error = %error, "background mail delivery was not scheduled");
1873 }
1874 }
1875
1876 pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
1887 if self.transport.is_disabled() {
1888 return Ok(());
1889 }
1890 let mut mail = mail.with_defaults(&self.defaults);
1891 self.freeze_inline_css_default(&mut mail);
1897
1898 #[cfg(feature = "db")]
1901 {
1902 let mailer = self.clone();
1903 let deferred = mail.clone();
1904 let mut f_opt: Option<(Self, Mail)> = Some((mailer, deferred));
1905 let deliver_span = tracing::Span::current();
1909
1910 crate::db::AFTER_COMMIT_REGISTRY
1911 .try_with(|registry| {
1912 #[allow(
1913 clippy::expect_used,
1914 reason = "unreachable: try_with closure body runs at most once"
1915 )]
1916 let (m, m_mail) = f_opt.take().expect("once");
1917 let span = deliver_span.clone();
1918 let boxed: crate::db::CommitCallback = Box::new(move || {
1919 Box::pin(tracing::Instrument::instrument(
1920 async move {
1921 if let Some(queue) = m.delivery_queue.clone() {
1922 queue.enqueue(m_mail).await.map_err(|e| {
1923 crate::AutumnError::internal_server_error_msg(e.to_string())
1924 })
1925 } else {
1926 m.spawn_mail_delivery(m_mail).map_err(|e| {
1927 crate::AutumnError::internal_server_error_msg(e.to_string())
1928 })
1929 }
1930 },
1931 span,
1932 ))
1933 });
1934 registry
1935 .lock()
1936 .unwrap_or_else(std::sync::PoisonError::into_inner)
1937 .push(boxed);
1938 })
1939 .ok();
1940
1941 if f_opt.is_none() {
1942 return Ok(());
1944 }
1945 }
1946
1947 self.spawn_mail_delivery(mail)
1949 }
1950
1951 pub fn try_deliver_later_eager(&self, mail: Mail) -> Result<(), MailError> {
1957 if self.transport.is_disabled() {
1958 return Ok(());
1959 }
1960 let mut mail = mail.with_defaults(&self.defaults);
1961 self.freeze_inline_css_default(&mut mail);
1962 self.spawn_mail_delivery(mail)
1963 }
1964
1965 fn spawn_mail_delivery(&self, mail: Mail) -> Result<(), MailError> {
1966 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
1970 MailError::RuntimeUnavailable(
1971 "deliver_later requires an active Tokio runtime".to_owned(),
1972 )
1973 })?;
1974 let parent_span = tracing::Span::current();
1975 if let Some(queue) = self.delivery_queue.clone() {
1976 handle.spawn(tracing::Instrument::instrument(
1977 async move {
1978 if let Err(error) = queue.enqueue(mail).await {
1979 tracing::error!(error = %error, "durable mail enqueue failed");
1980 }
1981 },
1982 parent_span,
1983 ));
1984 } else {
1985 let mailer = self.clone();
1986 handle.spawn(tracing::Instrument::instrument(
1987 async move {
1988 if let Err(error) = mailer.send(mail).await {
1989 tracing::error!(error = %error, "background mail delivery failed");
1990 }
1991 },
1992 parent_span,
1993 ));
1994 }
1995 Ok(())
1996 }
1997}
1998
1999impl FromRequestParts<AppState> for Mailer {
2000 type Rejection = AutumnError;
2001
2002 async fn from_request_parts(
2003 _parts: &mut http::request::Parts,
2004 state: &AppState,
2005 ) -> Result<Self, Self::Rejection> {
2006 state
2007 .extension::<Self>()
2008 .as_deref()
2009 .cloned()
2010 .ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
2011 }
2012}
2013
2014#[derive(Clone)]
2016pub struct MailerBuilder {
2017 transport: Transport,
2018 from: Option<String>,
2019 reply_to: Option<String>,
2020 file_dir: PathBuf,
2021 smtp: Option<SmtpConfig>,
2022 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
2023 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2024 inline_css: bool,
2025}
2026
2027impl Default for MailerBuilder {
2028 fn default() -> Self {
2029 Self {
2030 transport: Transport::Log,
2031 from: None,
2032 reply_to: None,
2033 file_dir: default_file_dir(),
2034 smtp: None,
2035 delivery_queue: None,
2036 resilience_config: None,
2037 inline_css: false,
2038 }
2039 }
2040}
2041
2042impl MailerBuilder {
2043 #[must_use]
2045 pub const fn transport(mut self, transport: Transport) -> Self {
2046 self.transport = transport;
2047 self
2048 }
2049
2050 #[must_use]
2052 pub fn from(mut self, from: impl Into<String>) -> Self {
2053 self.from = Some(from.into());
2054 self
2055 }
2056
2057 #[must_use]
2059 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
2060 self.reply_to = Some(reply_to.into());
2061 self
2062 }
2063
2064 #[must_use]
2066 pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
2067 self.file_dir = dir.as_ref().to_path_buf();
2068 self
2069 }
2070
2071 #[must_use]
2073 pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
2074 self.smtp = Some(smtp);
2075 self
2076 }
2077
2078 #[must_use]
2081 pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
2082 self.delivery_queue = Some(Arc::new(queue));
2083 self
2084 }
2085
2086 #[must_use]
2088 pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
2089 self.delivery_queue = Some(queue);
2090 self
2091 }
2092
2093 #[must_use]
2094 pub fn resilience_config(mut self, rc: Option<Arc<crate::config::ResilienceConfig>>) -> Self {
2095 self.resilience_config = rc;
2096 self
2097 }
2098
2099 #[must_use]
2103 pub const fn inline_css(mut self, enabled: bool) -> Self {
2104 self.inline_css = enabled;
2105 self
2106 }
2107
2108 pub fn build(self) -> Result<Mailer, MailError> {
2114 if let Some(from) = &self.from {
2115 parse_mailbox(from)?;
2116 }
2117 if let Some(reply_to) = &self.reply_to {
2118 parse_mailbox(reply_to)?;
2119 }
2120
2121 let transport: Arc<dyn MailTransport> = match self.transport {
2122 Transport::Log => Arc::new(LogTransport),
2123 Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
2124 Transport::Disabled => Arc::new(DisabledTransport),
2125 Transport::Smtp => Arc::new(SmtpTransport::new(
2126 self.smtp.unwrap_or_default(),
2127 self.resilience_config.clone(),
2128 )?),
2129 };
2130
2131 Ok(Mailer {
2132 defaults: Arc::new(MailerDefaults {
2133 from: self.from,
2134 reply_to: self.reply_to,
2135 }),
2136 transport,
2137 delivery_queue: self.delivery_queue,
2138 unsubscribe: None,
2139 suppression: None,
2140 inline_css_default: self.inline_css,
2141 })
2142 }
2143}
2144
2145struct DisabledTransport;
2146
2147impl MailTransport for DisabledTransport {
2148 fn send<'a>(
2149 &'a self,
2150 _mail: Mail,
2151 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2152 Box::pin(async { Ok(()) })
2153 }
2154
2155 fn is_disabled(&self) -> bool {
2156 true
2157 }
2158}
2159
2160struct LogTransport;
2161
2162impl MailTransport for LogTransport {
2163 fn send<'a>(
2164 &'a self,
2165 mail: Mail,
2166 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2167 Box::pin(async move {
2168 tracing::info!(
2169 from = ?mail.from,
2170 reply_to = ?mail.reply_to,
2171 to = ?mail.to,
2172 subject = %mail.subject,
2173 text = ?mail.text,
2174 html = ?mail.html,
2175 attachments = mail.attachments.len(),
2176 "mail captured by log transport"
2177 );
2178 Ok(())
2179 })
2180 }
2181}
2182
2183struct FileTransport {
2184 dir: PathBuf,
2185}
2186
2187static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
2188
2189impl MailTransport for FileTransport {
2190 fn send<'a>(
2191 &'a self,
2192 mail: Mail,
2193 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2194 Box::pin(async move {
2195 tokio::fs::create_dir_all(&self.dir).await?;
2196 let filename = file_transport_filename(&mail);
2197 let path = self.dir.join(filename);
2198 let mut file = tokio::fs::OpenOptions::new()
2199 .write(true)
2200 .create_new(true)
2201 .open(path)
2202 .await?;
2203 let eml = render_eml(&mail);
2204 tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
2205 tokio::io::AsyncWriteExt::flush(&mut file).await?;
2206 file.sync_all().await?;
2207 Ok(())
2208 })
2209 }
2210}
2211
2212struct SmtpTransport {
2213 inner: AsyncSmtpTransport<Tokio1Executor>,
2214 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2215}
2216
2217impl SmtpTransport {
2218 fn new(
2219 config: SmtpConfig,
2220 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2221 ) -> Result<Self, MailError> {
2222 let host = config
2223 .host
2224 .filter(|host| !host.trim().is_empty())
2225 .ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
2226 let mut builder = match config.tls {
2227 TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
2228 TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
2229 TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
2230 };
2231 if let Some(port) = config.port {
2232 builder = builder.port(port);
2233 }
2234 if let Some(username) = config.username {
2235 let password_env = config.password_env.ok_or_else(|| {
2236 MailError::InvalidMessage(
2237 "mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
2238 )
2239 })?;
2240 let password = std::env::var(&password_env)
2241 .map_err(|error| smtp_password_env_error(&password_env, &error))?;
2242 builder = builder.credentials(Credentials::new(username, password));
2243 }
2244 Ok(Self {
2245 inner: builder.build(),
2246 resilience_config,
2247 })
2248 }
2249}
2250
2251fn smtp_password_env_error(password_env: &str, error: &std::env::VarError) -> MailError {
2258 let reason = match error {
2259 std::env::VarError::NotPresent => "environment variable is not set",
2260 std::env::VarError::NotUnicode(_) => "environment variable contains non-unicode data",
2261 };
2262 MailError::InvalidMessage(format!(
2263 "mail.smtp.password_env={password_env:?} could not be resolved: {reason}"
2264 ))
2265}
2266
2267impl MailTransport for SmtpTransport {
2268 fn send<'a>(
2269 &'a self,
2270 mail: Mail,
2271 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2272 Box::pin(async move {
2273 let breaker = self.resilience_config.as_ref().map_or_else(
2274 || {
2275 crate::circuit_breaker::global_registry().get_or_create(
2276 "smtp_mailer",
2277 crate::circuit_breaker::CircuitBreakerPolicy::default(),
2278 )
2279 },
2280 |rc| {
2281 let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(
2282 rc,
2283 "smtp_mailer",
2284 );
2285 crate::circuit_breaker::global_registry()
2286 .get_or_create_with_config("smtp_mailer", policy)
2287 },
2288 );
2289
2290 if breaker.before_call().is_err() {
2291 return Err(MailError::RuntimeUnavailable(
2292 "smtp mailer circuit breaker is open".to_owned(),
2293 ));
2294 }
2295 let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
2296
2297 let message = lettre_message(&mail)?;
2298 let res = self.inner.send(message).await;
2299 if res.is_ok() {
2300 guard.success();
2301 } else {
2302 guard.failure();
2303 }
2304
2305 res.map(|_| ()).map_err(Into::into)
2306 })
2307 }
2308}
2309
2310fn sanitize_filename(value: &str) -> String {
2311 value
2312 .chars()
2313 .map(|ch| {
2314 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
2315 ch
2316 } else {
2317 '_'
2318 }
2319 })
2320 .collect()
2321}
2322
2323const RFC2231_ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
2327 .remove(b'!')
2328 .remove(b'#')
2329 .remove(b'$')
2330 .remove(b'&')
2331 .remove(b'+')
2332 .remove(b'-')
2333 .remove(b'.')
2334 .remove(b'^')
2335 .remove(b'_')
2336 .remove(b'`')
2337 .remove(b'|')
2338 .remove(b'~');
2339
2340fn strip_header_controls(value: &str) -> String {
2346 value.chars().filter(|ch| !ch.is_control()).collect()
2347}
2348
2349fn quote_header_value(value: &str) -> String {
2350 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
2351 format!("\"{escaped}\"")
2352}
2353
2354fn content_disposition_params(filename: &str) -> String {
2360 let mut clean = strip_header_controls(filename);
2361 if clean.trim().is_empty() {
2362 "attachment".clone_into(&mut clean);
2363 }
2364 if clean.is_ascii() {
2365 format!("filename={}", quote_header_value(&clean))
2366 } else {
2367 let fallback: String = clean
2368 .chars()
2369 .map(|ch| if ch.is_ascii() { ch } else { '_' })
2370 .collect();
2371 let encoded = percent_encoding::utf8_percent_encode(&clean, RFC2231_ATTR_CHAR);
2372 format!(
2373 "filename={}; filename*=UTF-8''{encoded}",
2374 quote_header_value(&fallback)
2375 )
2376 }
2377}
2378
2379fn base64_wrap76(bytes: &[u8]) -> String {
2381 use base64::Engine as _;
2382 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
2383 if encoded.len() <= 76 {
2384 return encoded;
2385 }
2386 let newlines = (encoded.len() - 1) / 76;
2387 let mut wrapped = String::with_capacity(encoded.len() + newlines);
2388 for chunk in encoded.as_bytes().chunks(76) {
2389 if !wrapped.is_empty() {
2390 wrapped.push('\n');
2391 }
2392 #[allow(
2393 clippy::expect_used,
2394 reason = "infallible: base64 output is always ASCII"
2395 )]
2396 let chunk_str = std::str::from_utf8(chunk).expect("base64 output is always ASCII");
2397 wrapped.push_str(chunk_str);
2398 }
2399 wrapped
2400}
2401
2402fn file_transport_filename(mail: &Mail) -> String {
2403 let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2404 format!(
2405 "{}-{}-{:016x}-{}.eml",
2406 chrono::Utc::now().format("%Y%m%d%H%M%S%6f"),
2407 std::process::id(),
2408 sequence,
2409 sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
2410 )
2411}
2412
2413fn render_eml(mail: &Mail) -> String {
2414 let mut out = String::new();
2415 if let Some(from) = &mail.from {
2416 out.push_str("From: ");
2417 out.push_str(&strip_header_controls(from));
2418 out.push('\n');
2419 }
2420 for to in &mail.to {
2421 out.push_str("To: ");
2422 out.push_str(&strip_header_controls(to));
2423 out.push('\n');
2424 }
2425 if let Some(reply_to) = &mail.reply_to {
2426 out.push_str("Reply-To: ");
2427 out.push_str(&strip_header_controls(reply_to));
2428 out.push('\n');
2429 }
2430 out.push_str("Date: ");
2431 out.push_str(&chrono::Utc::now().to_rfc2822());
2432 out.push('\n');
2433 out.push_str("Message-Id: <");
2434 out.push_str(&uuid::Uuid::new_v4().to_string());
2435 out.push_str("@autumn.local>\n");
2436 out.push_str("Subject: ");
2437 out.push_str(&strip_header_controls(&mail.subject));
2438 out.push('\n');
2439 for (name, value) in &mail.extra_headers {
2440 out.push_str(&strip_header_controls(name));
2441 out.push_str(": ");
2442 out.push_str(&strip_header_controls(value));
2443 out.push('\n');
2444 }
2445 out.push_str("MIME-Version: 1.0\n");
2446 if mail.attachments.is_empty() {
2447 render_eml_bodies(mail, &mut out);
2448 } else {
2449 use std::fmt::Write as _;
2455 let boundary = format!("autumn-mixed-{}", uuid::Uuid::new_v4().simple());
2456 let _ = write!(
2457 out,
2458 "Content-Type: multipart/mixed; boundary=\"{boundary}\"\n\n"
2459 );
2460 let _ = writeln!(out, "--{boundary}");
2461 render_eml_bodies(mail, &mut out);
2462 for attachment in &mail.attachments {
2463 let _ = writeln!(out, "--{boundary}");
2464 out.push_str("Content-Type: ");
2465 let content_type = strip_header_controls(&attachment.content_type);
2466 if ContentType::parse(&content_type).is_ok() {
2467 out.push_str(&content_type);
2468 } else {
2469 out.push_str("application/octet-stream");
2470 }
2471 out.push('\n');
2472 out.push_str("Content-Disposition: attachment; ");
2473 out.push_str(&content_disposition_params(&attachment.filename));
2474 out.push('\n');
2475 out.push_str("Content-Transfer-Encoding: base64\n\n");
2476 out.push_str(&base64_wrap76(&attachment.bytes));
2477 out.push('\n');
2478 }
2479 let _ = writeln!(out, "--{boundary}--");
2480 }
2481 out
2482}
2483
2484fn render_eml_bodies(mail: &Mail, out: &mut String) {
2489 if mail.html.is_some() && mail.text.is_some() {
2490 out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
2491 if let Some(text) = &mail.text {
2492 out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
2493 out.push_str(text);
2494 out.push('\n');
2495 }
2496 if let Some(html) = &mail.html {
2497 out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
2498 out.push_str(html);
2499 out.push('\n');
2500 }
2501 out.push_str("--autumn-mail--\n");
2502 } else if let Some(html) = &mail.html {
2503 out.push_str("Content-Type: text/html; charset=utf-8\n\n");
2504 out.push_str(html);
2505 out.push('\n');
2506 } else if let Some(text) = &mail.text {
2507 out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
2508 out.push_str(text);
2509 out.push('\n');
2510 }
2511}
2512
2513#[derive(Debug, Clone)]
2514struct ParsedMail {
2515 headers: Vec<(String, String)>,
2516 to: Vec<String>,
2517 subject: String,
2518 date: Option<String>,
2519 html: Option<String>,
2520 text: Option<String>,
2521 attachments: Vec<ParsedAttachment>,
2522 raw: String,
2523}
2524
2525impl ParsedMail {
2526 fn header_value(&self, name: &str) -> Option<&str> {
2527 self.headers
2528 .iter()
2529 .find(|(header, _)| header.eq_ignore_ascii_case(name))
2530 .map(|(_, value)| value.as_str())
2531 }
2532}
2533
2534#[derive(Debug, Clone)]
2537struct ParsedAttachment {
2538 filename: String,
2539 content_type: String,
2540}
2541
2542#[derive(Debug, Clone)]
2543struct CapturedMailSummary {
2544 id: String,
2545 to: Vec<String>,
2546 subject: String,
2547 timestamp: String,
2548 modified: SystemTime,
2549}
2550
2551pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
2552where
2553 S: Clone + Send + Sync + 'static,
2554 AppState: axum::extract::FromRef<S>,
2555{
2556 let file_dir = Arc::new(file_dir);
2557 axum::Router::new()
2558 .route(
2559 MAIL_PREVIEW_PATH,
2560 axum::routing::get({
2561 let file_dir = Arc::clone(&file_dir);
2562 move |axum::extract::State(state): axum::extract::State<AppState>| {
2563 let file_dir = Arc::clone(&file_dir);
2564 async move { list_mail_preview(file_dir, state).await }
2565 }
2566 }),
2567 )
2568 .route(
2569 MAIL_PREVIEW_MESSAGE_PATH,
2570 axum::routing::get({
2571 let file_dir = Arc::clone(&file_dir);
2572 move |axum::extract::Path(message_id): axum::extract::Path<String>| {
2573 let file_dir = Arc::clone(&file_dir);
2574 async move { show_captured_mail(file_dir, message_id).await }
2575 }
2576 }),
2577 )
2578 .route(
2579 MAIL_PREVIEW_TEMPLATE_PATH,
2580 axum::routing::get(
2581 |axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
2582 axum::extract::State(state): axum::extract::State<AppState>| async move {
2583 show_template_preview(&state, &mailer, &method)
2584 },
2585 ),
2586 )
2587}
2588
2589async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
2590 match captured_messages(&file_dir).await {
2591 Ok(messages) => {
2592 let previews = state
2593 .extension::<MailPreviewRegistry>()
2594 .map(|registry| registry.previews().to_vec())
2595 .unwrap_or_default();
2596 html_response(render_mail_index(&messages, &previews, &file_dir))
2597 }
2598 Err(error) => preview_error_response(&error),
2599 }
2600}
2601
2602async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
2603 match read_captured_message(&file_dir, &message_id).await {
2604 Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
2605 Err(error) => preview_error_response(&error),
2606 }
2607}
2608
2609fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
2610 let preview = state
2611 .extension::<MailPreviewRegistry>()
2612 .and_then(|registry| registry.find(mailer, method));
2613 let Some(preview) = preview else {
2614 return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
2615 };
2616
2617 match preview.render() {
2618 Ok(mail) => {
2619 let mut mail = apply_preview_unsubscribe_headers(state, mailer, mail);
2620 if let Some(m) = state.extension::<Mailer>() {
2624 let _ = m.apply_css_inlining(&mut mail);
2627 }
2628 let raw = render_eml(&mail);
2629 let parsed = parse_eml(&raw);
2630 html_response(render_mail_detail(&parsed, "Template preview"))
2631 }
2632 Err(error) => preview_error_response(&error),
2633 }
2634}
2635
2636fn apply_preview_unsubscribe_headers(state: &AppState, mailer_label: &str, mut mail: Mail) -> Mail {
2640 let scope = mail.list_unsubscribe.clone().or_else(|| {
2641 registered_list_unsubscribe_scopes()
2642 .into_iter()
2643 .find(|descriptor| descriptor.mailer == mailer_label)
2644 .map(|descriptor| descriptor.scope.to_owned())
2645 });
2646 let Some(scope) = scope else {
2647 return mail;
2648 };
2649 mail.list_unsubscribe = Some(scope.clone());
2650 let recipient = mail.to.first().map_or_else(
2651 || "subscriber@example.com".to_owned(),
2652 |to| canonical_subscriber(to),
2653 );
2654 let (header, one_click) = state.extension::<UnsubscribeRuntime>().map_or_else(
2658 || {
2659 let sample = UnsubscribeRuntime {
2660 base_url: Some("https://example.com".to_owned()),
2661 mailto: None,
2662 signing_keys: Arc::new(crate::security::config::resolve_signing_keys(
2663 &crate::security::config::SigningSecretConfig::default(),
2664 )),
2665 ttl_days: unsubscribe::DEFAULT_TOKEN_TTL_DAYS,
2666 suppression: None,
2667 };
2668 (
2669 sample.list_unsubscribe_header(&recipient, &scope),
2670 sample.supports_one_click(),
2671 )
2672 },
2673 |runtime| {
2674 (
2675 runtime.list_unsubscribe_header(&recipient, &scope),
2676 runtime.supports_one_click(),
2677 )
2678 },
2679 );
2680 if let Some(value) = header {
2681 mail.extra_headers.retain(|(name, _)| {
2684 !name.eq_ignore_ascii_case("List-Unsubscribe")
2685 && !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
2686 });
2687 mail.extra_headers
2688 .push(("List-Unsubscribe".to_owned(), value));
2689 if one_click {
2690 mail.extra_headers.push((
2691 "List-Unsubscribe-Post".to_owned(),
2692 "List-Unsubscribe=One-Click".to_owned(),
2693 ));
2694 }
2695 }
2696 mail
2697}
2698
2699async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
2700 let mut entries = match tokio::fs::read_dir(dir).await {
2701 Ok(entries) => entries,
2702 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
2703 Err(error) => return Err(error.into()),
2704 };
2705
2706 let mut messages = Vec::new();
2707 while let Some(entry) = entries.next_entry().await? {
2708 let path = entry.path();
2709 if !path
2710 .extension()
2711 .and_then(|ext| ext.to_str())
2712 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
2713 {
2714 continue;
2715 }
2716 let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
2717 continue;
2718 };
2719 let metadata = entry.metadata().await?;
2720 let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
2721 let raw = tokio::fs::read_to_string(&path).await?;
2722 let parsed = parse_eml(&raw);
2723 messages.push(CapturedMailSummary {
2724 id: id.to_owned(),
2725 to: parsed.to,
2726 subject: parsed.subject,
2727 timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
2728 modified,
2729 });
2730 }
2731
2732 messages.sort_by(|left, right| {
2733 right
2734 .modified
2735 .cmp(&left.modified)
2736 .then_with(|| right.id.cmp(&left.id))
2737 });
2738 Ok(messages)
2739}
2740
2741async fn read_captured_message(
2742 dir: &Path,
2743 message_id: &str,
2744) -> Result<ParsedMail, MailPreviewError> {
2745 if !valid_message_id(message_id) {
2746 return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
2747 }
2748 let path = dir.join(message_id);
2749 let raw = match tokio::fs::read_to_string(&path).await {
2750 Ok(raw) => raw,
2751 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2752 return Err(MailPreviewError::NotFound(message_id.to_owned()));
2753 }
2754 Err(error) => return Err(error.into()),
2755 };
2756 Ok(parse_eml(&raw))
2757}
2758
2759fn valid_message_id(message_id: &str) -> bool {
2760 !message_id.is_empty()
2761 && Path::new(message_id)
2762 .extension()
2763 .and_then(|ext| ext.to_str())
2764 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
2765 && !message_id.contains('/')
2766 && !message_id.contains('\\')
2767 && !message_id.contains("..")
2768}
2769
2770fn parse_eml(raw: &str) -> ParsedMail {
2771 let normalized = raw.replace("\r\n", "\n");
2772 let (headers, body) = split_headers_body(&normalized);
2773 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
2774 let (html, text, attachments) = parse_mail_body(&content_type, body);
2775 let to = header_values(&headers, "To");
2776 let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
2777 let date = header_value(&headers, "Date");
2778
2779 ParsedMail {
2780 headers,
2781 to,
2782 subject,
2783 date,
2784 html,
2785 text,
2786 attachments,
2787 raw: raw.to_owned(),
2788 }
2789}
2790
2791fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
2792 let Some((header_block, body)) = raw.split_once("\n\n") else {
2793 return (parse_header_block(raw), "");
2794 };
2795 (parse_header_block(header_block), body)
2796}
2797
2798fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
2799 let mut headers = Vec::new();
2800 let mut current: Option<(String, String)> = None;
2801
2802 for line in header_block.lines() {
2803 if line.starts_with(' ') || line.starts_with('\t') {
2804 if let Some((_, value)) = current.as_mut() {
2805 value.push(' ');
2806 value.push_str(line.trim());
2807 }
2808 continue;
2809 }
2810 if let Some(header) = current.take() {
2811 headers.push(header);
2812 }
2813 if let Some((name, value)) = line.split_once(':') {
2814 current = Some((name.trim().to_owned(), value.trim().to_owned()));
2815 }
2816 }
2817 if let Some(header) = current {
2818 headers.push(header);
2819 }
2820 headers
2821}
2822
2823fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
2824 headers
2825 .iter()
2826 .find(|(header, _)| header.eq_ignore_ascii_case(name))
2827 .map(|(_, value)| value.clone())
2828}
2829
2830fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
2831 headers
2832 .iter()
2833 .filter(|(header, _)| header.eq_ignore_ascii_case(name))
2834 .map(|(_, value)| value.clone())
2835 .collect()
2836}
2837
2838fn parse_mail_body(
2839 content_type: &str,
2840 body: &str,
2841) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
2842 let lower = content_type.to_ascii_lowercase();
2843 if lower.contains("multipart/mixed")
2844 && let Some(boundary) = content_type_boundary(content_type)
2845 {
2846 return parse_multipart_mixed(body, &boundary);
2847 }
2848
2849 if lower.contains("multipart/alternative")
2850 && let Some(boundary) = content_type_boundary(content_type)
2851 {
2852 let (html, text) = parse_multipart_alternative(body, &boundary);
2853 return (html, text, Vec::new());
2854 }
2855
2856 if lower.contains("text/html") {
2857 (Some(trim_body(body)), None, Vec::new())
2858 } else {
2859 (None, Some(trim_body(body)), Vec::new())
2860 }
2861}
2862
2863fn parse_multipart_mixed(
2868 body: &str,
2869 boundary: &str,
2870) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
2871 let marker = format!("--{boundary}");
2872 let mut html = None;
2873 let mut text = None;
2874 let mut attachments = Vec::new();
2875
2876 for segment in body.split(&marker).skip(1) {
2877 let segment = segment.trim_start_matches(['\n', '\r']);
2878 if segment.starts_with("--") {
2879 break;
2880 }
2881 let (headers, part_body) = split_headers_body(segment);
2882 let disposition = header_value(&headers, "Content-Disposition").unwrap_or_default();
2883 let part_content_type = header_value(&headers, "Content-Type").unwrap_or_default();
2884 let disposition_type = split_mime_params(&disposition)
2885 .first()
2886 .copied()
2887 .unwrap_or("");
2888 if disposition_type.eq_ignore_ascii_case("attachment") {
2889 attachments.push(ParsedAttachment {
2890 filename: extract_attachment_filename(&disposition),
2891 content_type: content_type_without_params(&part_content_type),
2892 });
2893 } else {
2894 let (nested_html, nested_text, _) = parse_mail_body(&part_content_type, part_body);
2895 html = html.or(nested_html);
2896 text = text.or(nested_text);
2897 }
2898 }
2899
2900 (html, text, attachments)
2901}
2902
2903fn split_mime_params(value: &str) -> Vec<&str> {
2907 let mut parts = Vec::new();
2908 let mut start = 0;
2909 let mut in_quotes = false;
2910 let mut escaped = false;
2911 for (i, ch) in value.char_indices() {
2912 if escaped {
2913 escaped = false;
2914 continue;
2915 }
2916 match ch {
2917 '\\' if in_quotes => escaped = true,
2918 '"' => in_quotes = !in_quotes,
2919 ';' if !in_quotes => {
2920 parts.push(value[start..i].trim());
2921 start = i + ch.len_utf8();
2922 }
2923 _ => {}
2924 }
2925 }
2926 parts.push(value[start..].trim());
2927 parts
2928}
2929
2930fn unescape_quoted_string(value: &str) -> String {
2933 let mut result = String::with_capacity(value.len());
2934 let mut chars = value.chars();
2935 while let Some(ch) = chars.next() {
2936 if ch == '\\'
2937 && let Some(next) = chars.next()
2938 {
2939 result.push(next);
2940 } else {
2941 result.push(ch);
2942 }
2943 }
2944 result
2945}
2946
2947fn extract_attachment_filename(disposition: &str) -> String {
2953 let params = split_mime_params(disposition);
2954 if let Some(value) = params.iter().skip(1).find_map(|part| {
2955 let (key, val) = part.split_once('=')?;
2956 key.trim().eq_ignore_ascii_case("filename*").then_some(val)
2957 }) {
2958 let encoded = value.splitn(3, '\'').nth(2).unwrap_or(value);
2959 return percent_encoding::percent_decode_str(encoded)
2960 .decode_utf8_lossy()
2961 .into_owned();
2962 }
2963 if let Some(value) = params.iter().skip(1).find_map(|part| {
2964 let (key, val) = part.split_once('=')?;
2965 key.trim()
2966 .eq_ignore_ascii_case("filename")
2967 .then_some(val.trim())
2968 }) {
2969 if let Some(inner) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
2970 return unescape_quoted_string(inner);
2971 }
2972 return value.to_owned();
2973 }
2974 "attachment".to_owned()
2975}
2976
2977fn content_type_without_params(content_type: &str) -> String {
2978 content_type
2979 .split(';')
2980 .next()
2981 .unwrap_or(content_type)
2982 .trim()
2983 .to_owned()
2984}
2985
2986fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
2987 let marker = format!("--{boundary}");
2988 let mut html = None;
2989 let mut text = None;
2990
2991 for segment in body.split(&marker).skip(1) {
2992 let segment = segment.trim_start_matches(['\n', '\r']);
2993 if segment.starts_with("--") {
2994 break;
2995 }
2996 let (headers, part_body) = split_headers_body(segment);
2997 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
2998 if content_type.to_ascii_lowercase().contains("text/html") {
2999 html = Some(trim_body(part_body));
3000 } else if content_type.to_ascii_lowercase().contains("text/plain") {
3001 text = Some(trim_body(part_body));
3002 }
3003 }
3004
3005 (html, text)
3006}
3007
3008fn content_type_boundary(content_type: &str) -> Option<String> {
3009 content_type.split(';').find_map(|part| {
3010 let part = part.trim();
3011 let (name, value) = part.split_once('=')?;
3012 if !name.trim().eq_ignore_ascii_case("boundary") {
3013 return None;
3014 }
3015 Some(value.trim().trim_matches('"').to_owned())
3016 })
3017}
3018
3019fn trim_body(body: &str) -> String {
3020 body.trim_matches(['\r', '\n']).to_owned()
3021}
3022
3023fn format_system_time(time: SystemTime) -> String {
3024 let datetime: chrono::DateTime<chrono::Utc> = time.into();
3025 datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3026}
3027
3028fn render_mail_index(
3029 messages: &[CapturedMailSummary],
3030 previews: &[MailPreview],
3031 file_dir: &Path,
3032) -> String {
3033 let mut body = String::new();
3034 body.push_str("<h1>Autumn Mail</h1>");
3035 body.push_str("<section><h2>Captured messages</h2>");
3036 if messages.is_empty() {
3037 body.push_str("<p class=\"empty\">No captured emails yet. Set <code>mail.transport = "file"</code>, send an email, then refresh this page. Autumn reads <code>");
3038 body.push_str(&escape_html(&file_dir.display().to_string()));
3039 body.push_str("</code>.</p>");
3040 } else {
3041 body.push_str(
3042 "<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
3043 );
3044 for message in messages {
3045 body.push_str("<tr><td>");
3046 body.push_str(&escape_html(&message.timestamp));
3047 body.push_str("</td><td>");
3048 body.push_str(&escape_html(&message.to.join(", ")));
3049 body.push_str("</td><td><a href=\"");
3050 body.push_str(MAIL_PREVIEW_PATH);
3051 body.push_str("/messages/");
3052 body.push_str(&escape_html(&message.id));
3053 body.push_str("\">");
3054 body.push_str(&escape_html(&message.subject));
3055 body.push_str("</a></td></tr>");
3056 }
3057 body.push_str("</tbody></table>");
3058 }
3059 body.push_str("</section><section><h2>Template previews</h2>");
3060 if previews.is_empty() {
3061 body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
3062 } else {
3063 body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
3064 for preview in previews {
3065 body.push_str("<tr><td>");
3066 body.push_str(&escape_html(preview.mailer()));
3067 body.push_str("</td><td><a href=\"");
3068 body.push_str(MAIL_PREVIEW_PATH);
3069 body.push_str("/previews/");
3070 body.push_str(&escape_html(preview.mailer()));
3071 body.push('/');
3072 body.push_str(&escape_html(preview.method()));
3073 body.push_str("\">");
3074 body.push_str(&escape_html(preview.method()));
3075 body.push_str("</a></td></tr>");
3076 }
3077 body.push_str("</tbody></table>");
3078 }
3079 body.push_str("</section>");
3080 render_mail_preview_layout("Autumn Mail", &body)
3081}
3082
3083fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
3084 let mut body = String::new();
3085 body.push_str("<p><a href=\"");
3086 body.push_str(MAIL_PREVIEW_PATH);
3087 body.push_str("\">Back to mail</a></p><h1>");
3088 body.push_str(&escape_html(&parsed.subject));
3089 body.push_str("</h1><p class=\"muted\">");
3090 body.push_str(&escape_html(label));
3091 body.push_str("</p>");
3092
3093 if let Some(html) = &parsed.html {
3094 body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
3095 body.push_str(&escape_html(html));
3096 body.push_str("\"></iframe>");
3097 } else {
3098 body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
3099 }
3100
3101 body.push_str("<details><summary>Plain text</summary><pre>");
3102 body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
3103 body.push_str("</pre></details>");
3104
3105 if !parsed.attachments.is_empty() {
3106 body.push_str("<details open><summary>Attachments (");
3107 body.push_str(&parsed.attachments.len().to_string());
3108 body.push_str(")</summary><ul>");
3109 for attachment in &parsed.attachments {
3110 body.push_str("<li>");
3111 body.push_str(&escape_html(&attachment.filename));
3112 body.push_str(" <span class=\"muted\">(");
3113 body.push_str(&escape_html(&attachment.content_type));
3114 body.push_str(")</span></li>");
3115 }
3116 body.push_str("</ul></details>");
3117 }
3118
3119 body.push_str("<details><summary>Headers</summary><dl>");
3120 for header in [
3121 "From",
3122 "To",
3123 "Reply-To",
3124 "Subject",
3125 "Date",
3126 "Message-Id",
3127 "List-Unsubscribe",
3128 "List-Unsubscribe-Post",
3129 ] {
3130 if let Some(value) = parsed.header_value(header) {
3131 body.push_str("<dt>");
3132 body.push_str(header);
3133 body.push_str("</dt><dd>");
3134 body.push_str(&escape_html(value));
3135 body.push_str("</dd>");
3136 }
3137 }
3138 body.push_str("</dl></details>");
3139
3140 body.push_str("<details><summary>Raw .eml</summary><pre>");
3141 body.push_str(&escape_html(&parsed.raw));
3142 body.push_str("</pre></details>");
3143
3144 render_mail_preview_layout(&parsed.subject, &body)
3145}
3146
3147fn render_mail_preview_layout(title: &str, body: &str) -> String {
3148 format!(
3149 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
3150 escape_html(title),
3151 MAIL_PREVIEW_CSS,
3152 body
3153 )
3154}
3155
3156const MAIL_PREVIEW_CSS: &str = r#"
3157body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
3158h1{margin:0 0 16px;font-size:28px}
3159h2{margin:28px 0 12px;font-size:18px}
3160table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
3161th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
3162th{background:#edf2f7;color:#394b59;font-weight:650}
3163a{color:#0b63ce;text-decoration:none}
3164a:hover{text-decoration:underline}
3165.empty,.muted{color:#52616f}
3166code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
3167pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
3168iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
3169details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
3170summary{cursor:pointer;font-weight:650}
3171dt{font-weight:650;margin-top:8px}
3172dd{margin:2px 0 8px}
3173"#;
3174
3175fn html_response(html: String) -> Response {
3176 Html(html).into_response()
3177}
3178
3179fn preview_error_response(error: &MailPreviewError) -> Response {
3180 let status = match error {
3181 MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
3182 http::StatusCode::NOT_FOUND
3183 }
3184 MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
3185 http::StatusCode::INTERNAL_SERVER_ERROR
3186 }
3187 };
3188 (
3189 status,
3190 Html(render_mail_preview_layout(
3191 "Mail preview error",
3192 &format!(
3193 "<h1>Mail preview error</h1><p>{}</p>",
3194 escape_html(&error.to_string())
3195 ),
3196 )),
3197 )
3198 .into_response()
3199}
3200
3201fn escape_html(value: &str) -> String {
3202 let mut escaped = String::with_capacity(value.len());
3203 for ch in value.chars() {
3204 match ch {
3205 '&' => escaped.push_str("&"),
3206 '<' => escaped.push_str("<"),
3207 '>' => escaped.push_str(">"),
3208 '"' => escaped.push_str("""),
3209 '\'' => escaped.push_str("'"),
3210 _ => escaped.push(ch),
3211 }
3212 }
3213 escaped
3214}
3215
3216fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
3217 address.parse().map_err(|source| MailError::InvalidAddress {
3218 address: address.to_owned(),
3219 source,
3220 })
3221}
3222
3223fn canonical_subscriber(recipient: &str) -> String {
3229 parse_mailbox(recipient).map_or_else(
3230 |_| recipient.trim().to_ascii_lowercase(),
3231 |mailbox| mailbox.email.to_string().to_ascii_lowercase(),
3232 )
3233}
3234
3235enum MailBodyPart {
3241 Single(SinglePart),
3242 Multi(MultiPart),
3243}
3244
3245fn lettre_body_part(mail: &Mail) -> Result<MailBodyPart, MailError> {
3246 match (&mail.text, &mail.html) {
3247 (Some(text), Some(html)) => Ok(MailBodyPart::Multi(
3248 MultiPart::alternative()
3249 .singlepart(SinglePart::plain(text.clone()))
3250 .singlepart(SinglePart::html(html.clone())),
3251 )),
3252 (Some(text), None) => Ok(MailBodyPart::Single(SinglePart::plain(text.clone()))),
3253 (None, Some(html)) => Ok(MailBodyPart::Single(SinglePart::html(html.clone()))),
3254 (None, None) => Err(MailError::InvalidMessage(
3255 "mail must include html or text body".to_owned(),
3256 )),
3257 }
3258}
3259
3260fn lettre_attachment_part(attachment: &MailAttachment) -> Result<SinglePart, MailError> {
3261 let content_type = ContentType::parse(&attachment.content_type).map_err(|error| {
3262 MailError::InvalidMessage(format!(
3263 "attachment {:?} has invalid content type {:?}: {error}",
3264 attachment.filename, attachment.content_type
3265 ))
3266 })?;
3267 #[allow(
3271 clippy::expect_used,
3272 reason = "infallible: base64 encoding is always valid for any byte buffer"
3273 )]
3274 let body =
3275 LettreBody::new_with_encoding(attachment.bytes.clone(), ContentTransferEncoding::Base64)
3276 .expect("base64 encoding is always valid for any byte buffer");
3277 Ok(LettreAttachment::new(attachment.filename.clone()).body(body, content_type))
3278}
3279
3280fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
3281 let from = mail
3282 .from
3283 .as_deref()
3284 .ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
3285 let mut builder = Message::builder().from(parse_mailbox(from)?);
3286 for to in &mail.to {
3287 builder = builder.to(parse_mailbox(to)?);
3288 }
3289 if let Some(reply_to) = &mail.reply_to {
3290 builder = builder.reply_to(parse_mailbox(reply_to)?);
3291 }
3292 builder = builder.subject(mail.subject.clone());
3293
3294 for (name, value) in &mail.extra_headers {
3295 use lettre::message::header::{HeaderName, HeaderValue};
3296 match HeaderName::new_from_ascii(name.clone()) {
3297 Ok(header_name) => {
3298 builder = builder.raw_header(HeaderValue::new(header_name, value.clone()));
3299 }
3300 Err(error) => {
3301 tracing::warn!(
3302 header_name = %name,
3303 error = %error,
3304 "skipping mail header with invalid name"
3305 );
3306 }
3307 }
3308 }
3309
3310 let body_part = lettre_body_part(mail)?;
3311
3312 if mail.attachments.is_empty() {
3313 return Ok(match body_part {
3314 MailBodyPart::Multi(multi) => builder.multipart(multi)?,
3315 MailBodyPart::Single(single) => builder.singlepart(single)?,
3316 });
3317 }
3318
3319 let mut mixed = match body_part {
3320 MailBodyPart::Multi(multi) => MultiPart::mixed().multipart(multi),
3321 MailBodyPart::Single(single) => MultiPart::mixed().singlepart(single),
3322 };
3323 for attachment in &mail.attachments {
3324 mixed = mixed.singlepart(lettre_attachment_part(attachment)?);
3325 }
3326 Ok(builder.multipart(mixed)?)
3327}
3328
3329struct InterceptedMailTransport {
3330 inner: Arc<dyn MailTransport>,
3331 interceptor: Arc<dyn crate::interceptor::MailInterceptor>,
3332}
3333
3334impl MailTransport for InterceptedMailTransport {
3335 fn send<'a>(
3336 &'a self,
3337 mail: Mail,
3338 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3339 Box::pin(async move {
3340 let inner = Arc::clone(&self.inner);
3341 let mail_for_next = mail.clone();
3342 let next = Box::pin(async move { inner.send(mail_for_next).await });
3343 self.interceptor.intercept(&mail, next).await
3344 })
3345 }
3346
3347 fn is_disabled(&self) -> bool {
3348 self.inner.is_disabled()
3349 }
3350}
3351
3352#[allow(clippy::too_many_lines)]
3367pub(crate) fn install_mailer(
3368 state: &AppState,
3369 config: &MailConfig,
3370 enforce_durable_guard: bool,
3371) -> AutumnResult<()> {
3372 let resilience = state
3373 .extension::<crate::config::AutumnConfig>()
3374 .map(|c| Arc::new(c.resilience.clone()));
3375 let mut mailer =
3376 Mailer::from_config_inner(config, resilience).map_err(AutumnError::service_unavailable)?;
3377
3378 if let Some(interceptor) = state.extension::<Arc<dyn crate::interceptor::MailInterceptor>>() {
3379 mailer.transport = Arc::new(InterceptedMailTransport {
3380 inner: Arc::clone(&mailer.transport),
3381 interceptor: (*interceptor).clone(),
3382 });
3383 }
3384
3385 let in_production = matches!(state.profile(), "prod" | "production");
3386 let transport_sends_mail = config.transport != Transport::Disabled;
3387
3388 if transport_sends_mail {
3392 let queue_handle = state.extension::<MailDeliveryQueueHandle>();
3393 if let Some(handle) = queue_handle.as_ref() {
3394 mailer.delivery_queue = Some(Arc::clone(handle.inner()));
3395 }
3396 }
3397
3398 if enforce_durable_guard && in_production && transport_sends_mail {
3399 let has_durable_queue = mailer.delivery_queue.is_some();
3400 if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
3401 return Err(AutumnError::service_unavailable_msg(
3402 "mail.deliver_later has no durable backend in prod: register a MailDeliveryQueueHandle on AppState or set mail.allow_in_process_deliver_later_in_production = true to opt into the in-process Tokio fallback",
3403 ));
3404 }
3405 if !has_durable_queue {
3406 tracing::warn!(
3407 "mail.deliver_later is using the in-process Tokio fallback in prod; this is acknowledged via mail.allow_in_process_deliver_later_in_production but is not durable across restarts or replicas"
3408 );
3409 }
3410 }
3411
3412 let base_url = config
3414 .unsubscribe_base_url
3415 .as_deref()
3416 .map(str::trim)
3417 .filter(|s| !s.is_empty());
3418 let mailto = config
3419 .unsubscribe_mailto
3420 .as_deref()
3421 .map(str::trim)
3422 .filter(|s| !s.is_empty());
3423 let unsubscribe_configured = base_url.is_some() || mailto.is_some();
3424
3425 let suppression: Option<Arc<dyn SuppressionStore>> = {
3428 let explicit = state
3429 .extension::<SuppressionStoreHandle>()
3430 .map(|handle| Arc::clone(handle.inner()));
3431 #[cfg(feature = "db")]
3432 let resolved = explicit.or_else(|| {
3433 state
3434 .pool()
3435 .map(|pool| Arc::new(db_suppression::DbSuppressionStore::new(pool.clone())) as _)
3436 });
3437 #[cfg(not(feature = "db"))]
3438 let resolved = explicit;
3439 resolved
3440 };
3441
3442 if transport_sends_mail
3447 && unsubscribe_config_fail_closed(
3448 enforce_durable_guard,
3449 in_production,
3450 has_list_unsubscribe_mailers(),
3451 unsubscribe_configured,
3452 )
3453 {
3454 return Err(AutumnError::service_unavailable_msg(
3455 "a #[mailer] declares list_unsubscribe but neither mail.unsubscribe_base_url nor mail.unsubscribe_mailto is configured: set at least one so RFC 8058 List-Unsubscribe headers can be emitted",
3456 ));
3457 }
3458
3459 if enforce_durable_guard
3463 && in_production
3464 && transport_sends_mail
3465 && has_list_unsubscribe_mailers()
3466 && base_url.is_some()
3467 && suppression.is_none()
3468 {
3469 return Err(AutumnError::service_unavailable_msg(
3470 "mail.unsubscribe_base_url is set but no suppression backend is available: configure a database pool or register a SuppressionStore so one-click unsubscribes can be persisted",
3471 ));
3472 }
3473
3474 if in_production
3478 && transport_sends_mail
3479 && has_list_unsubscribe_mailers()
3480 && base_url.is_some()
3481 && !config.mount_unsubscribe_endpoint
3482 {
3483 tracing::warn!(
3484 target: "mail",
3485 path = UNSUBSCRIBE_PATH,
3486 "list mail will advertise one-click unsubscribe URLs but the default endpoint is not mounted; call AppBuilder::mount_unsubscribe_endpoint() or serve the path yourself"
3487 );
3488 }
3489
3490 if unsubscribe_configured || suppression.is_some() {
3491 let signing_keys = Arc::new(crate::security::config::resolve_signing_keys(
3492 &state
3493 .extension::<crate::config::AutumnConfig>()
3494 .map(|c| c.security.signing_secret.clone())
3495 .unwrap_or_default(),
3496 ));
3497 let ttl_days = config.unsubscribe_token_ttl_days;
3498 let make_runtime = || UnsubscribeRuntime {
3499 base_url: base_url.map(str::to_owned),
3500 mailto: mailto.map(str::to_owned),
3501 signing_keys: Arc::clone(&signing_keys),
3502 ttl_days,
3503 suppression: suppression.clone(),
3504 };
3505 state.insert_extension(make_runtime());
3510 if transport_sends_mail {
3511 mailer.unsubscribe = Some(Arc::new(make_runtime()));
3512 }
3513 }
3514
3515 if transport_sends_mail {
3527 let handle = state
3528 .extension::<suppression::SuppressionStoreHandle>()
3529 .map_or_else(
3530 || {
3531 let handle = suppression::SuppressionStoreHandle::new(
3532 suppression::InMemorySuppressionStore::new(),
3533 );
3534 state.insert_extension(handle.clone());
3535 handle
3536 },
3537 |arc| (*arc).clone(),
3538 );
3539 mailer.suppression = Some(Arc::clone(handle.inner()));
3540 }
3541
3542 state.insert_extension(mailer);
3543 Ok(())
3544}
3545
3546pub(crate) fn install_mailer_with_factory<F>(
3561 state: &AppState,
3562 config: &MailConfig,
3563 queue_factory: Option<F>,
3564 enforce_durable_guard: bool,
3565) -> AutumnResult<()>
3566where
3567 F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
3568{
3569 let transport_sends_mail = config.transport != Transport::Disabled;
3573 if enforce_durable_guard
3574 && transport_sends_mail
3575 && let Some(factory) = queue_factory
3576 {
3577 let queue = factory(state)?;
3578 state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
3579 }
3580 install_mailer(state, config, enforce_durable_guard)
3581}
3582
3583#[derive(Deserialize)]
3586struct UnsubscribeParams {
3587 #[serde(default)]
3588 token: String,
3589}
3590
3591pub(crate) fn unsubscribe_router() -> axum::Router<AppState> {
3598 axum::Router::new().route(
3599 UNSUBSCRIBE_PATH,
3600 axum::routing::get(unsubscribe_get_handler).post(unsubscribe_post_handler),
3601 )
3602}
3603
3604async fn unsubscribe_post_handler(
3606 axum::extract::State(state): axum::extract::State<AppState>,
3607 axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
3608 body: String,
3609) -> Response {
3610 if !is_one_click_body(&body) {
3614 return (
3615 axum::http::StatusCode::BAD_REQUEST,
3616 "expected List-Unsubscribe=One-Click body",
3617 )
3618 .into_response();
3619 }
3620 let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
3621 return (
3622 axum::http::StatusCode::NOT_FOUND,
3623 "unsubscribe is not configured",
3624 )
3625 .into_response();
3626 };
3627 match unsubscribe::verify_token(&runtime.signing_keys, ¶ms.token, current_unix_time()) {
3628 Ok(decoded) => {
3629 let Some(store) = runtime.suppression.as_ref() else {
3630 tracing::error!(
3633 target: "mail",
3634 "unsubscribe POST received but no suppression backend is configured"
3635 );
3636 return (
3637 axum::http::StatusCode::SERVICE_UNAVAILABLE,
3638 "unsubscribe storage is not configured",
3639 )
3640 .into_response();
3641 };
3642 if let Err(error) = store.suppress(&decoded.subscriber, &decoded.list_id).await {
3643 tracing::error!(error = %error, "failed to record unsubscribe suppression");
3644 return (
3645 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3646 "could not process unsubscribe",
3647 )
3648 .into_response();
3649 }
3650 tracing::info!(
3651 target: "mail",
3652 list_id = %decoded.list_id,
3653 outcome = "unsubscribed",
3654 "recorded one-click unsubscribe"
3655 );
3656 (
3657 axum::http::StatusCode::OK,
3658 Html(unsubscribe_confirmation_html(&decoded.list_id)),
3659 )
3660 .into_response()
3661 }
3662 Err(error) => (
3663 axum::http::StatusCode::BAD_REQUEST,
3664 Html(unsubscribe_error_html(&error.to_string())),
3665 )
3666 .into_response(),
3667 }
3668}
3669
3670fn is_one_click_body(body: &str) -> bool {
3672 body.split('&').any(|pair| {
3673 let mut kv = pair.splitn(2, '=');
3674 let key = kv.next().unwrap_or("");
3675 let value = kv.next().unwrap_or("");
3676 key.eq_ignore_ascii_case("List-Unsubscribe") && value.eq_ignore_ascii_case("One-Click")
3677 })
3678}
3679
3680async fn unsubscribe_get_handler(
3682 axum::extract::State(state): axum::extract::State<AppState>,
3683 axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
3684) -> Response {
3685 let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
3686 return (
3687 axum::http::StatusCode::NOT_FOUND,
3688 "unsubscribe is not configured",
3689 )
3690 .into_response();
3691 };
3692 match unsubscribe::verify_token(&runtime.signing_keys, ¶ms.token, current_unix_time()) {
3693 Ok(decoded) => Html(unsubscribe_form_html(&decoded.list_id, ¶ms.token)).into_response(),
3694 Err(error) => (
3695 axum::http::StatusCode::BAD_REQUEST,
3696 Html(unsubscribe_error_html(&error.to_string())),
3697 )
3698 .into_response(),
3699 }
3700}
3701
3702fn unsubscribe_form_html(list_id: &str, token: &str) -> String {
3703 format!(
3706 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
3707 <body><h1>Unsubscribe</h1>\
3708 <p>Stop receiving <strong>{}</strong> emails?</p>\
3709 <form method=\"post\" action=\"?token={}\">\
3710 <input type=\"hidden\" name=\"List-Unsubscribe\" value=\"One-Click\">\
3711 <button type=\"submit\">Unsubscribe</button></form></body></html>",
3712 escape_html(list_id),
3713 escape_html(token),
3714 )
3715}
3716
3717fn unsubscribe_confirmation_html(list_id: &str) -> String {
3718 format!(
3719 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head>\
3720 <body><h1>You're unsubscribed</h1>\
3721 <p>You will no longer receive <strong>{}</strong> emails.</p></body></html>",
3722 escape_html(list_id),
3723 )
3724}
3725
3726fn unsubscribe_error_html(detail: &str) -> String {
3727 format!(
3728 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
3729 <body><h1>Unsubscribe link is not valid</h1><p>{}</p></body></html>",
3730 escape_html(detail),
3731 )
3732}
3733
3734pub mod suppression {
3765 use std::future::Future;
3766 use std::pin::Pin;
3767 use std::sync::Arc;
3768 use std::sync::atomic::{AtomicU64, Ordering};
3769
3770 use super::{MailError, canonical_subscriber};
3771
3772 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
3774 #[serde(rename_all = "snake_case")]
3775 pub enum SuppressionReason {
3776 HardBounce,
3778 Complaint,
3780 Manual,
3782 }
3783
3784 impl SuppressionReason {
3785 #[must_use]
3787 pub const fn as_str(self) -> &'static str {
3788 match self {
3789 Self::HardBounce => "hard_bounce",
3790 Self::Complaint => "complaint",
3791 Self::Manual => "manual",
3792 }
3793 }
3794 }
3795
3796 impl std::fmt::Display for SuppressionReason {
3797 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3798 f.write_str(self.as_str())
3799 }
3800 }
3801
3802 pub trait SuppressionStore: Send + Sync {
3809 fn is_suppressed<'a>(
3811 &'a self,
3812 address: &'a str,
3813 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
3814
3815 fn suppress<'a>(
3818 &'a self,
3819 address: &'a str,
3820 reason: SuppressionReason,
3821 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
3822
3823 fn unsuppress<'a>(
3826 &'a self,
3827 address: &'a str,
3828 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
3829 }
3830
3831 #[derive(Clone)]
3834 pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
3835
3836 impl SuppressionStoreHandle {
3837 #[must_use]
3839 pub fn new(store: impl SuppressionStore + 'static) -> Self {
3840 Self(Arc::new(store))
3841 }
3842
3843 #[must_use]
3845 pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
3846 Self(store)
3847 }
3848
3849 #[must_use]
3851 pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
3852 &self.0
3853 }
3854
3855 #[must_use]
3857 pub fn into_inner(self) -> Arc<dyn SuppressionStore> {
3858 self.0
3859 }
3860 }
3861
3862 impl std::fmt::Debug for SuppressionStoreHandle {
3863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3864 f.debug_struct("SuppressionStoreHandle")
3865 .finish_non_exhaustive()
3866 }
3867 }
3868
3869 #[derive(Debug, Default, Clone)]
3874 pub struct InMemorySuppressionStore {
3875 entries: Arc<std::sync::Mutex<std::collections::HashMap<String, SuppressionReason>>>,
3876 }
3877
3878 impl InMemorySuppressionStore {
3879 #[must_use]
3881 pub fn new() -> Self {
3882 Self::default()
3883 }
3884 }
3885
3886 impl SuppressionStore for InMemorySuppressionStore {
3887 fn is_suppressed<'a>(
3888 &'a self,
3889 address: &'a str,
3890 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
3891 Box::pin(async move {
3892 let key = canonical_subscriber(address);
3893 Ok(self
3894 .entries
3895 .lock()
3896 .unwrap_or_else(std::sync::PoisonError::into_inner)
3897 .contains_key(&key))
3898 })
3899 }
3900
3901 fn suppress<'a>(
3902 &'a self,
3903 address: &'a str,
3904 reason: SuppressionReason,
3905 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3906 Box::pin(async move {
3907 let key = canonical_subscriber(address);
3908 self.entries
3909 .lock()
3910 .unwrap_or_else(std::sync::PoisonError::into_inner)
3911 .insert(key, reason);
3912 Ok(())
3913 })
3914 }
3915
3916 fn unsuppress<'a>(
3917 &'a self,
3918 address: &'a str,
3919 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3920 Box::pin(async move {
3921 let key = canonical_subscriber(address);
3922 self.entries
3923 .lock()
3924 .unwrap_or_else(std::sync::PoisonError::into_inner)
3925 .remove(&key);
3926 Ok(())
3927 })
3928 }
3929 }
3930
3931 static SUPPRESSED_SKIPS: AtomicU64 = AtomicU64::new(0);
3933
3934 #[must_use]
3939 pub fn suppressed_skips() -> u64 {
3940 SUPPRESSED_SKIPS.load(Ordering::Relaxed)
3941 }
3942
3943 pub(crate) fn note_skip(canonical_address: &str) {
3945 SUPPRESSED_SKIPS.fetch_add(1, Ordering::Relaxed);
3946 tracing::info!(
3947 target: "mail",
3948 outcome = "skipped_suppressed",
3949 address = %canonical_address,
3950 "skipping suppressed recipient (hard bounce or complaint); \
3951 pass Mail::ignore_suppression() to override for critical mail"
3952 );
3953 }
3954
3955 #[cfg(feature = "inbound-mail")]
3992 pub async fn record_inbound(
3993 store: &dyn SuppressionStore,
3994 email: &crate::inbound_mail::InboundEmail,
3995 ) -> Result<(), MailError> {
3996 if email.is_bounce {
3997 if let Some(addr) = email.bounced_address.as_deref() {
4001 store.suppress(addr, SuppressionReason::HardBounce).await?;
4002 } else {
4003 tracing::warn!(
4004 target: "mail",
4005 "inbound bounce webhook set is_bounce with no bounced_address; nothing suppressed"
4006 );
4007 }
4008 return Ok(());
4009 }
4010 if let Some(addr) = email.complained_address.as_deref() {
4015 store.suppress(addr, SuppressionReason::Complaint).await?;
4016 } else if email
4017 .spam_report
4018 .as_ref()
4019 .and_then(|r| r.verdict.as_deref())
4020 .is_some_and(|v| v.eq_ignore_ascii_case("yes"))
4021 {
4022 tracing::warn!(
4023 target: "mail",
4024 "inbound spam verdict carries no outbound complainant address; \
4025 nothing suppressed (wire a real FBL/complaint source that \
4026 populates InboundEmail::complained_address)"
4027 );
4028 }
4029 Ok(())
4030 }
4031
4032 #[cfg(feature = "db")]
4033 pub use pg::PgSuppressionStore;
4034
4035 #[cfg(feature = "db")]
4036 mod pg {
4037 use std::future::Future;
4038 use std::pin::Pin;
4039
4040 use diesel::prelude::*;
4041 use diesel_async::AsyncPgConnection;
4042 use diesel_async::RunQueryDsl;
4043 use diesel_async::pooled_connection::deadpool::Pool;
4044
4045 use super::super::canonical_subscriber;
4046 use super::{MailError, SuppressionReason, SuppressionStore};
4047
4048 diesel::table! {
4049 mail_suppressions (address) {
4050 address -> Text,
4051 reason -> Text,
4052 suppressed_at -> Timestamptz,
4053 }
4054 }
4055
4056 #[derive(Insertable)]
4057 #[diesel(table_name = mail_suppressions)]
4058 struct NewSuppression<'a> {
4059 address: &'a str,
4060 reason: &'a str,
4061 }
4062
4063 #[derive(Clone)]
4083 pub struct PgSuppressionStore {
4084 pool: Pool<AsyncPgConnection>,
4085 }
4086
4087 impl PgSuppressionStore {
4088 #[must_use]
4090 pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
4091 Self { pool }
4092 }
4093 }
4094
4095 impl SuppressionStore for PgSuppressionStore {
4096 fn is_suppressed<'a>(
4097 &'a self,
4098 address: &'a str,
4099 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4100 Box::pin(async move {
4101 let key = canonical_subscriber(address);
4102 let mut conn = self.pool.get().await.map_err(|e| {
4103 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4104 })?;
4105 let count: i64 = mail_suppressions::table
4106 .filter(mail_suppressions::address.eq(&key))
4107 .count()
4108 .get_result(&mut conn)
4109 .await
4110 .map_err(|e| {
4111 MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4112 })?;
4113 Ok(count > 0)
4114 })
4115 }
4116
4117 fn suppress<'a>(
4118 &'a self,
4119 address: &'a str,
4120 reason: SuppressionReason,
4121 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4122 Box::pin(async move {
4123 let key = canonical_subscriber(address);
4124 let reason_str = reason.as_str();
4125 let mut conn = self.pool.get().await.map_err(|e| {
4126 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4127 })?;
4128 diesel::insert_into(mail_suppressions::table)
4129 .values(NewSuppression {
4130 address: &key,
4131 reason: reason_str,
4132 })
4133 .on_conflict(mail_suppressions::address)
4134 .do_update()
4135 .set((
4139 mail_suppressions::reason.eq(reason_str),
4140 mail_suppressions::suppressed_at.eq(diesel::dsl::now),
4141 ))
4142 .execute(&mut conn)
4143 .await
4144 .map_err(|e| {
4145 MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
4146 })?;
4147 Ok(())
4148 })
4149 }
4150
4151 fn unsuppress<'a>(
4152 &'a self,
4153 address: &'a str,
4154 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4155 Box::pin(async move {
4156 let key = canonical_subscriber(address);
4157 let mut conn = self.pool.get().await.map_err(|e| {
4158 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4159 })?;
4160 diesel::delete(
4161 mail_suppressions::table.filter(mail_suppressions::address.eq(&key)),
4162 )
4163 .execute(&mut conn)
4164 .await
4165 .map_err(|e| {
4166 MailError::RuntimeUnavailable(format!("suppression delete: {e}"))
4167 })?;
4168 Ok(())
4169 })
4170 }
4171 }
4172 }
4173}
4174
4175#[cfg(feature = "db")]
4177pub mod db_suppression {
4178 use std::future::Future;
4179 use std::pin::Pin;
4180
4181 use diesel::prelude::*;
4182 use diesel_async::AsyncPgConnection;
4183 use diesel_async::RunQueryDsl;
4184 use diesel_async::pooled_connection::deadpool::Pool;
4185
4186 use super::{MailError, SuppressionStore};
4187
4188 diesel::table! {
4189 mail_unsubscribes (id) {
4190 id -> Int8,
4191 subscriber -> Text,
4192 list_id -> Text,
4193 unsubscribed_at -> Timestamptz,
4194 }
4195 }
4196
4197 #[derive(Insertable)]
4198 #[diesel(table_name = mail_unsubscribes)]
4199 struct NewUnsubscribe<'a> {
4200 subscriber: &'a str,
4201 list_id: &'a str,
4202 }
4203
4204 #[derive(Clone)]
4209 pub struct DbSuppressionStore {
4210 pool: Pool<AsyncPgConnection>,
4211 }
4212
4213 impl DbSuppressionStore {
4214 #[must_use]
4216 pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
4217 Self { pool }
4218 }
4219 }
4220
4221 impl SuppressionStore for DbSuppressionStore {
4222 fn is_suppressed<'a>(
4223 &'a self,
4224 subscriber: &'a str,
4225 list_id: &'a str,
4226 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4227 Box::pin(async move {
4228 let mut conn =
4229 self.pool.get().await.map_err(|e| {
4230 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4231 })?;
4232 let count: i64 = mail_unsubscribes::table
4233 .filter(mail_unsubscribes::subscriber.eq(subscriber))
4234 .filter(mail_unsubscribes::list_id.eq(list_id))
4235 .count()
4236 .get_result(&mut conn)
4237 .await
4238 .map_err(|e| {
4239 MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4240 })?;
4241 Ok(count > 0)
4242 })
4243 }
4244
4245 fn suppress<'a>(
4246 &'a self,
4247 subscriber: &'a str,
4248 list_id: &'a str,
4249 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4250 Box::pin(async move {
4251 let mut conn =
4252 self.pool.get().await.map_err(|e| {
4253 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4254 })?;
4255 diesel::insert_into(mail_unsubscribes::table)
4256 .values(NewUnsubscribe {
4257 subscriber,
4258 list_id,
4259 })
4260 .on_conflict((mail_unsubscribes::subscriber, mail_unsubscribes::list_id))
4261 .do_nothing()
4262 .execute(&mut conn)
4263 .await
4264 .map_err(|e| {
4265 MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
4266 })?;
4267 Ok(())
4268 })
4269 }
4270 }
4271}
4272
4273#[cfg(test)]
4274mod tests {
4275 use super::*;
4276
4277 #[test]
4280 fn html_contains_style_block_is_case_insensitive() {
4281 assert!(html_contains_style_block("<STYLE>.a{}</STYLE>"));
4282 assert!(html_contains_style_block("<p>x</p><style>.a{}</style>"));
4283 assert!(!html_contains_style_block("<p style=\"color:red\">x</p>"));
4284 assert!(!html_contains_style_block("just plain text, no tags"));
4285 }
4286
4287 #[test]
4288 fn inline_css_applies_class_style_to_anchor() {
4289 let html = r#"<style>.btn{color:#fff;background:#06c}</style><a class="btn">Go</a>"#;
4292 let out = inline_css_html(html).expect("inlining succeeds");
4293 let anchor = out
4297 .split("<a")
4298 .nth(1)
4299 .expect("an <a> tag is present in the output");
4300 let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
4301 assert!(
4302 anchor_open.contains("style="),
4303 "anchor must gain an inline style attribute; got tag: {anchor_open}"
4304 );
4305 assert!(
4306 anchor_open.contains("#fff"),
4307 "anchor's inline style must carry the color rule; got tag: {anchor_open}"
4308 );
4309 assert!(
4310 anchor_open.contains("#06c") || anchor_open.contains("background"),
4311 "anchor's inline style must carry the background rule; got tag: {anchor_open}"
4312 );
4313 }
4314
4315 #[test]
4316 fn inline_css_applies_class_style_to_table() {
4317 let html = r#"<style>.wrap{width:600px;background:#eee}</style><table class="wrap"><tr><td>x</td></tr></table>"#;
4319 let out = inline_css_html(html).expect("inlining succeeds");
4320 let table = out
4321 .split("<table")
4322 .nth(1)
4323 .expect("a <table> tag is present in the output");
4324 let table_open = &table[..table.find('>').expect("table tag closes")];
4325 assert!(
4326 table_open.contains("style=") && table_open.contains("600px"),
4327 "table must carry an inline style with the width rule; got tag: {table_open}"
4328 );
4329 }
4330
4331 #[test]
4332 #[allow(
4333 clippy::literal_string_with_formatting_args,
4334 reason = "CSS rule braces are literal HTML, not format placeholders"
4335 )]
4336 fn inline_css_emits_outlook_width_height_attributes() {
4337 let html = r#"<style>table{width:600px}img{height:40px}</style><table><tr><td><img src="/x.png"></td></tr></table>"#;
4341 let out = inline_css_html(html).expect("inlining succeeds");
4342
4343 let table_open = {
4344 let table = out
4345 .split("<table")
4346 .nth(1)
4347 .expect("a <table> tag is present in the output");
4348 &table[..table.find('>').expect("table tag closes")]
4349 };
4350 assert!(
4352 table_open.contains("style=") && table_open.contains("600px"),
4353 "table must still carry the inline CSS width; got tag: {table_open}"
4354 );
4355 assert!(
4356 table_open.contains(r#"width="600""#),
4357 "table must gain the presentational HTML width attribute Outlook needs; got tag: {table_open}"
4358 );
4359
4360 let img_open = {
4361 let img = out
4362 .split("<img")
4363 .nth(1)
4364 .expect("an <img> tag is present in the output");
4365 &img[..img.find('>').expect("img tag closes")]
4366 };
4367 assert!(
4368 img_open.contains("style=") && img_open.contains("40px"),
4369 "img must still carry the inline CSS height; got tag: {img_open}"
4370 );
4371 assert!(
4372 img_open.contains(r#"height="40""#),
4373 "img must gain the presentational HTML height attribute Outlook needs; got tag: {img_open}"
4374 );
4375 }
4376
4377 #[test]
4378 fn inline_css_passthrough_without_style_block_is_byte_identical() {
4379 let html = r#"<p style="color:red">Hello</p><a href="/x">link</a>"#;
4381 let out = inline_css_html(html).expect("no-op inlining succeeds");
4382 assert_eq!(out, html, "no-<style> body must be returned unchanged");
4383 }
4384
4385 #[test]
4386 #[allow(
4387 clippy::literal_string_with_formatting_args,
4388 reason = "CSS rule braces are literal HTML, not format placeholders"
4389 )]
4390 fn inline_css_retains_link_stylesheet_tags() {
4391 let html = r#"<style>.x{color:red}</style><link rel="stylesheet" href="https://example.com/app.css"><p class="x">Hi</p>"#;
4397 let out = inline_css_html(html).expect("inlining succeeds");
4398 assert!(
4399 out.contains("<link") && out.contains(r#"rel="stylesheet""#),
4400 "the <link rel=\"stylesheet\"> tag must be preserved; got: {out}"
4401 );
4402 assert!(
4403 out.contains("app.css"),
4404 "the linked stylesheet href must be preserved; got: {out}"
4405 );
4406 let para = out
4407 .split("<p")
4408 .nth(1)
4409 .expect("a <p> tag is present in the output");
4410 let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
4411 assert!(
4412 para_open.contains("style=") && para_open.contains("red"),
4413 "the embedded rule must still be inlined onto the paragraph; got tag: {para_open}"
4414 );
4415 }
4416
4417 #[test]
4418 #[allow(
4419 clippy::literal_string_with_formatting_args,
4420 reason = "CSS rule braces are literal HTML, not format placeholders"
4421 )]
4422 fn inline_css_is_idempotent() {
4423 let html = r#"<style>.btn{color:#fff}p{margin:0}</style><a class="btn">Go</a><p>hi</p>"#;
4425 let once = inline_css_html(html).expect("first pass");
4426 let twice = inline_css_html(&once).expect("second pass");
4427 assert_eq!(once, twice, "inlining must be idempotent");
4428 }
4429
4430 #[test]
4431 #[allow(
4432 clippy::literal_string_with_formatting_args,
4433 reason = "CSS rule braces are literal HTML, not format placeholders"
4434 )]
4435 fn inline_css_retains_uninlinable_media_queries() {
4436 let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
4439 let out = inline_css_html(html).expect("inlining succeeds");
4440 assert!(
4441 out.contains("@media") && out.contains("max-width"),
4442 "the @media rule must be preserved in a retained <style> block; got: {out}"
4443 );
4444 assert!(
4446 out.contains("<a") && out.contains("style="),
4447 "the inlinable rule must still be inlined onto the anchor; got: {out}"
4448 );
4449 }
4450
4451 #[test]
4452 #[allow(
4453 clippy::literal_string_with_formatting_args,
4454 reason = "CSS rule braces are literal HTML, not format placeholders"
4455 )]
4456 fn inline_css_fragment_body_stays_a_fragment() {
4457 let html = r#"<style>.x{color:red}</style><p class="x">Hi</p>"#;
4462 let out = inline_css_html(html).expect("inlining succeeds");
4463 assert!(
4464 !out.to_ascii_lowercase().contains("<html")
4465 && !out.to_ascii_lowercase().contains("<body")
4466 && !out.to_ascii_lowercase().contains("<head"),
4467 "fragment body must not gain document wrappers; got: {out}"
4468 );
4469 let para = out
4470 .split("<p")
4471 .nth(1)
4472 .expect("a <p> tag is present in the output");
4473 let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
4474 assert!(
4475 para_open.contains("style=") && para_open.contains("red"),
4476 "the class rule must be inlined onto the paragraph; got tag: {para_open}"
4477 );
4478 }
4479
4480 #[test]
4481 #[allow(
4482 clippy::literal_string_with_formatting_args,
4483 reason = "CSS rule braces are literal HTML, not format placeholders"
4484 )]
4485 fn inline_css_fragment_body_retains_media_query_without_wrapping() {
4486 let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
4492 let out = inline_css_html(html).expect("inlining succeeds");
4493 assert!(
4494 !out.to_ascii_lowercase().contains("<html")
4495 && !out.to_ascii_lowercase().contains("<body")
4496 && !out.to_ascii_lowercase().contains("<head"),
4497 "fragment body must not gain document wrappers; got: {out}"
4498 );
4499 assert!(
4500 out.contains("@media") && out.contains("max-width"),
4501 "the retained @media block must survive the unwrap; got: {out}"
4502 );
4503 let anchor = out
4504 .split("<a")
4505 .nth(1)
4506 .expect("an <a> tag is present in the output");
4507 let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
4508 assert!(
4509 anchor_open.contains("style=") && anchor_open.contains("#fff"),
4510 "the inlinable rule must still be inlined onto the anchor; got tag: {anchor_open}"
4511 );
4512 }
4513
4514 #[test]
4515 #[allow(
4516 clippy::literal_string_with_formatting_args,
4517 reason = "CSS rule braces are literal HTML, not format placeholders"
4518 )]
4519 fn inline_css_full_document_body_stays_a_document() {
4520 let html = r#"<html><head><style>.x{color:red}</style></head><body><p class="x">Hi</p></body></html>"#;
4523 let out = inline_css_html(html).expect("inlining succeeds");
4524 assert!(
4525 out.to_ascii_lowercase().contains("<html")
4526 && out.to_ascii_lowercase().contains("<body"),
4527 "full-document body must retain its structure; got: {out}"
4528 );
4529 let para = out
4530 .split("<p")
4531 .nth(1)
4532 .expect("a <p> tag is present in the output");
4533 let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
4534 assert!(
4535 para_open.contains("style=") && para_open.contains("red"),
4536 "the class rule must be inlined onto the paragraph; got tag: {para_open}"
4537 );
4538 }
4539
4540 #[test]
4541 fn inline_css_stripped_style_renders_same_computed_styling() {
4542 let html = r#"<style>.btn{color:#fff;padding:8px}</style><a class="btn">Go</a>"#;
4547 let inlined = inline_css_html(html).expect("inlining succeeds");
4548
4549 let mut stripped = String::new();
4551 let mut rest = inlined.as_str();
4552 while let Some(start) = rest.to_ascii_lowercase().find("<style") {
4553 stripped.push_str(&rest[..start]);
4554 let after = &rest[start..];
4555 let end = after
4556 .to_ascii_lowercase()
4557 .find("</style>")
4558 .map_or(after.len(), |e| e + "</style>".len());
4559 rest = &after[end..];
4560 }
4561 stripped.push_str(rest);
4562
4563 let anchor = stripped
4565 .split("<a")
4566 .nth(1)
4567 .expect("anchor present after stripping <style>");
4568 let anchor_open = &anchor[..anchor.find('>').expect("anchor closes")];
4569 assert!(
4570 anchor_open.contains("style=") && anchor_open.contains("#fff"),
4571 "computed styling must be carried inline so a style-stripped copy looks identical; got: {anchor_open}"
4572 );
4573 }
4574
4575 async fn preview_body_for(preview: MailPreview) -> String {
4583 let state = crate::AppState::for_test();
4584 state.insert_extension(MailPreviewRegistry::new(vec![preview]));
4585 let mailer = Mailer::builder()
4586 .build()
4587 .expect("log-transport mailer builds");
4588 state.insert_extension(mailer);
4589
4590 let response = show_template_preview(&state, "test", "styled");
4591 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
4592 .await
4593 .expect("preview body collects");
4594 String::from_utf8(bytes.to_vec()).expect("preview body is utf-8")
4595 }
4596
4597 fn escaped_anchor_open_tag(body: &str) -> String {
4602 let after = body
4603 .split("<a")
4604 .nth(1)
4605 .expect("an <a> tag is present in the escaped preview body");
4606 let open = &after[..after.find(">").expect("anchor tag closes")];
4607 open.to_owned()
4608 }
4609
4610 #[tokio::test]
4611 #[allow(
4612 clippy::literal_string_with_formatting_args,
4613 reason = "CSS rule braces are literal HTML, not format placeholders"
4614 )]
4615 async fn preview_inlines_style_block_when_inlining_enabled() {
4616 let preview = MailPreview::new("test", "styled", || {
4619 Mail::builder()
4620 .to("user@example.com")
4621 .subject("Styled")
4622 .html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
4623 .inline_css(true)
4624 .build()
4625 .expect("preview mail builds")
4626 });
4627
4628 let body = preview_body_for(preview).await;
4629 let anchor = escaped_anchor_open_tag(&body);
4630 assert!(
4631 anchor.contains("style="),
4632 "preview must inline the <style> block onto the anchor; got tag: {anchor}"
4633 );
4634 assert!(
4635 anchor.contains("#ff0000"),
4636 "the .btn colour rule must be carried inline; got tag: {anchor}"
4637 );
4638 }
4639
4640 #[tokio::test]
4641 #[allow(
4642 clippy::literal_string_with_formatting_args,
4643 reason = "CSS rule braces are literal HTML, not format placeholders"
4644 )]
4645 async fn preview_leaves_style_block_raw_when_inlining_disabled() {
4646 let preview = MailPreview::new("test", "styled", || {
4649 Mail::builder()
4650 .to("user@example.com")
4651 .subject("Styled")
4652 .html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
4653 .inline_css(false)
4654 .build()
4655 .expect("preview mail builds")
4656 });
4657
4658 let body = preview_body_for(preview).await;
4659 let anchor = escaped_anchor_open_tag(&body);
4660 assert!(
4661 !anchor.contains("style="),
4662 "inlining is off, so the anchor must not gain an inline style; got tag: {anchor}"
4663 );
4664 assert!(
4665 body.contains("<style>"),
4666 "the raw <style> block must survive when inlining is off"
4667 );
4668 }
4669
4670 #[test]
4671 fn mail_builder_inline_css_sets_per_message_override() {
4672 let on = Mail::builder()
4673 .to("a@example.com")
4674 .subject("s")
4675 .html("<p>x</p>")
4676 .inline_css(true)
4677 .build()
4678 .expect("valid mail");
4679 assert_eq!(on.inline_css, Some(true));
4680
4681 let off = Mail::builder()
4682 .to("a@example.com")
4683 .subject("s")
4684 .html("<p>x</p>")
4685 .inline_css(false)
4686 .build()
4687 .expect("valid mail");
4688 assert_eq!(off.inline_css, Some(false));
4689
4690 let unset = Mail::builder()
4691 .to("a@example.com")
4692 .subject("s")
4693 .html("<p>x</p>")
4694 .build()
4695 .expect("valid mail");
4696 assert_eq!(
4697 unset.inline_css, None,
4698 "unset builder must defer to the mailer/config default"
4699 );
4700 }
4701
4702 #[test]
4703 fn mail_config_inline_css_defaults_off() {
4704 assert!(
4705 !MailConfig::default().inline_css,
4706 "inlining must default off so existing apps are unaffected"
4707 );
4708 }
4709
4710 fn pinned_render_eml_no_attachments(mail: &Mail) -> String {
4720 let mut out = String::new();
4721 if let Some(from) = &mail.from {
4722 out.push_str("From: ");
4723 out.push_str(from);
4724 out.push('\n');
4725 }
4726 for to in &mail.to {
4727 out.push_str("To: ");
4728 out.push_str(to);
4729 out.push('\n');
4730 }
4731 if let Some(reply_to) = &mail.reply_to {
4732 out.push_str("Reply-To: ");
4733 out.push_str(reply_to);
4734 out.push('\n');
4735 }
4736 out.push_str("Date: ");
4737 out.push_str("PINNED-DATE");
4738 out.push('\n');
4739 out.push_str("Message-Id: <");
4740 out.push_str("PINNED-ID");
4741 out.push_str("@autumn.local>\n");
4742 out.push_str("Subject: ");
4743 out.push_str(&mail.subject);
4744 out.push('\n');
4745 for (name, value) in &mail.extra_headers {
4746 out.push_str(name);
4747 out.push_str(": ");
4748 out.push_str(value);
4749 out.push('\n');
4750 }
4751 out.push_str("MIME-Version: 1.0\n");
4752 if mail.html.is_some() && mail.text.is_some() {
4753 out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
4754 if let Some(text) = &mail.text {
4755 out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
4756 out.push_str(text);
4757 out.push('\n');
4758 }
4759 if let Some(html) = &mail.html {
4760 out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
4761 out.push_str(html);
4762 out.push('\n');
4763 }
4764 out.push_str("--autumn-mail--\n");
4765 } else if let Some(html) = &mail.html {
4766 out.push_str("Content-Type: text/html; charset=utf-8\n\n");
4767 out.push_str(html);
4768 out.push('\n');
4769 } else if let Some(text) = &mail.text {
4770 out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
4771 out.push_str(text);
4772 out.push('\n');
4773 }
4774 out
4775 }
4776
4777 fn mask_nondeterministic(eml: &str) -> String {
4778 eml.lines()
4779 .map(|line| {
4780 if line.starts_with("Date: ") {
4781 "Date: PINNED-DATE"
4782 } else if line.starts_with("Message-Id: ") {
4783 "Message-Id: <PINNED-ID@autumn.local>"
4784 } else {
4785 line
4786 }
4787 })
4788 .collect::<Vec<_>>()
4789 .join("\n")
4790 }
4791
4792 #[test]
4793 fn render_eml_without_attachments_matches_pinned_shape() {
4794 let mails = [
4795 Mail::builder()
4796 .from("from@example.com")
4797 .to("user@example.com")
4798 .subject("Hi")
4799 .text("hello text")
4800 .html("<p>hello html</p>")
4801 .build()
4802 .expect("mail should build"),
4803 Mail::builder()
4804 .from("from@example.com")
4805 .to("user@example.com")
4806 .subject("Hi")
4807 .text("hello text only")
4808 .build()
4809 .expect("mail should build"),
4810 Mail::builder()
4811 .from("from@example.com")
4812 .to("user@example.com")
4813 .subject("Hi")
4814 .html("<p>hello html only</p>")
4815 .build()
4816 .expect("mail should build"),
4817 ];
4818 for mail in mails {
4819 let actual = mask_nondeterministic(&render_eml(&mail));
4820 let pinned = mask_nondeterministic(&pinned_render_eml_no_attachments(&mail));
4821 assert_eq!(
4822 actual, pinned,
4823 "render_eml must be byte-identical for attachment-less mail"
4824 );
4825 assert!(!actual.contains("multipart/mixed"));
4826 }
4827 }
4828
4829 #[test]
4830 fn lettre_message_without_attachments_has_no_mixed_part() {
4831 let mail = Mail::builder()
4832 .from("from@example.com")
4833 .to("user@example.com")
4834 .subject("Hi")
4835 .text("hello")
4836 .html("<p>hello</p>")
4837 .build()
4838 .expect("mail should build");
4839 let message = lettre_message(&mail).expect("lettre message should build");
4840 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
4841 assert!(formatted.contains("multipart/alternative"));
4842 assert!(!formatted.contains("multipart/mixed"));
4843 }
4844
4845 #[test]
4848 fn mail_builder_attach_preserves_order_and_count() {
4849 let mail = Mail::builder()
4850 .to("user@example.com")
4851 .subject("Hi")
4852 .text("hello")
4853 .attach("a.txt", "text/plain", b"aaa".to_vec())
4854 .attach("b.txt", "text/plain", b"bbb".to_vec())
4855 .attach("c.txt", "text/plain", b"ccc".to_vec())
4856 .build()
4857 .expect("mail should build");
4858 assert_eq!(mail.attachments.len(), 3);
4859 assert_eq!(
4860 mail.attachments
4861 .iter()
4862 .map(|a| a.filename.as_str())
4863 .collect::<Vec<_>>(),
4864 vec!["a.txt", "b.txt", "c.txt"]
4865 );
4866 assert_eq!(mail.attachments[1].content_type, "text/plain");
4867 assert_eq!(mail.attachments[1].bytes, b"bbb".to_vec());
4868 }
4869
4870 #[test]
4871 fn mail_serde_round_trips_attachments() {
4872 let mail = Mail::builder()
4873 .from("from@example.com")
4874 .to("user@example.com")
4875 .subject("Hi")
4876 .text("hello")
4877 .attach("invoice.pdf", "application/pdf", vec![0_u8, 1, 2, 255])
4878 .build()
4879 .expect("mail should build");
4880 let json = serde_json::to_string(&mail).expect("mail should serialize");
4881 let round_tripped: Mail = serde_json::from_str(&json).expect("mail should deserialize");
4882 assert_eq!(round_tripped, mail);
4883 }
4884
4885 #[test]
4886 fn mail_builder_rejects_control_chars_in_attachment_filename() {
4887 let err = Mail::builder()
4888 .to("user@example.com")
4889 .subject("Hi")
4890 .text("hello")
4891 .attach(
4892 "evil\r\nX-Injected: 1.pdf",
4893 "application/pdf",
4894 b"x".to_vec(),
4895 )
4896 .build()
4897 .expect_err("CRLF in filename should be rejected");
4898 assert!(err.to_string().contains("filename"));
4899
4900 let err = Mail::builder()
4901 .to("user@example.com")
4902 .subject("Hi")
4903 .text("hello")
4904 .attach("\0evil.pdf", "application/pdf", b"x".to_vec())
4905 .build()
4906 .expect_err("NUL in filename should be rejected");
4907 assert!(err.to_string().contains("filename"));
4908
4909 let err = Mail::builder()
4910 .to("user@example.com")
4911 .subject("Hi")
4912 .text("hello")
4913 .attach(" ", "application/pdf", b"x".to_vec())
4914 .build()
4915 .expect_err("empty filename should be rejected");
4916 assert!(err.to_string().contains("filename"));
4917 }
4918
4919 #[test]
4920 fn mail_builder_rejects_invalid_attachment_content_type() {
4921 let err = Mail::builder()
4922 .to("user@example.com")
4923 .subject("Hi")
4924 .text("hello")
4925 .attach("a.pdf", "not a mime type", b"x".to_vec())
4926 .build()
4927 .expect_err("invalid content type should be rejected");
4928 assert!(err.to_string().contains("content type"));
4929 }
4930
4931 #[test]
4932 fn mail_attachment_debug_hides_bytes() {
4933 let attachment = MailAttachment {
4934 filename: "secret.bin".to_owned(),
4935 content_type: "application/octet-stream".to_owned(),
4936 bytes: vec![1, 2, 3, 4, 5],
4937 };
4938 let debug = format!("{attachment:?}");
4939 assert!(debug.contains("secret.bin"));
4940 assert!(debug.contains('5'), "byte length should appear: {debug}");
4941 assert!(
4942 !debug.contains("[1, 2, 3, 4, 5]"),
4943 "raw byte values must not appear: {debug}"
4944 );
4945 }
4946
4947 fn blob_all_byte_values() -> Vec<u8> {
4950 (0_u8..=255).cycle().take(4096).collect()
4951 }
4952
4953 fn sha256_hex(bytes: &[u8]) -> String {
4954 use sha2::Digest as _;
4955 let mut hasher = sha2::Sha256::new();
4956 hasher.update(bytes);
4957 format!("{:x}", hasher.finalize())
4958 }
4959
4960 fn mixed_boundary(eml: &str) -> String {
4964 let line = eml
4965 .lines()
4966 .find(|line| line.starts_with("Content-Type: multipart/mixed;"))
4967 .expect("multipart/mixed Content-Type header present");
4968 content_type_boundary(line).expect("boundary parameter present")
4969 }
4970
4971 #[test]
4972 fn render_eml_with_attachment_emits_multipart_mixed() {
4973 let mail = Mail::builder()
4974 .from("from@example.com")
4975 .to("user@example.com")
4976 .subject("Invoice")
4977 .text("see attached")
4978 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
4979 .build()
4980 .expect("mail should build");
4981 let eml = render_eml(&mail);
4982 let boundary = mixed_boundary(&eml);
4983 assert!(eml.contains(&format!(
4984 "Content-Type: multipart/mixed; boundary=\"{boundary}\""
4985 )));
4986 assert!(eml.contains("Content-Disposition: attachment; filename=\"invoice.pdf\""));
4987 assert!(eml.contains("Content-Type: application/pdf"));
4988 assert!(eml.contains("Content-Transfer-Encoding: base64"));
4989 assert!(eml.contains(&format!("--{boundary}--")));
4990 }
4991
4992 #[test]
4993 fn render_eml_boundary_is_unpredictable_and_body_cannot_forge_it() {
4994 let mail = Mail::builder()
4999 .from("from@example.com")
5000 .to("user@example.com")
5001 .subject("Spoof attempt")
5002 .text("line one\n--autumn-mixed--\nX-Spoofed: header\nline two")
5003 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5004 .build()
5005 .expect("mail should build");
5006 let eml = render_eml(&mail);
5007 let parsed = parse_eml(&eml);
5008 assert_eq!(
5009 parsed.text.as_deref(),
5010 Some("line one\n--autumn-mixed--\nX-Spoofed: header\nline two"),
5011 "body content resembling the old fixed boundary must not truncate the message"
5012 );
5013 assert_eq!(parsed.attachments.len(), 1);
5014
5015 let other = render_eml(
5016 &Mail::builder()
5017 .from("from@example.com")
5018 .to("user@example.com")
5019 .subject("Second message")
5020 .text("hi")
5021 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5022 .build()
5023 .expect("mail should build"),
5024 );
5025 assert_ne!(
5026 mixed_boundary(&eml),
5027 mixed_boundary(&other),
5028 "boundary must vary per message, not be a fixed/predictable string"
5029 );
5030 }
5031
5032 #[test]
5033 fn render_eml_attachment_bytes_round_trip_sha256() {
5034 use base64::Engine as _;
5035 let blob = blob_all_byte_values();
5036 let expected_digest = sha256_hex(&blob);
5037 let mail = Mail::builder()
5038 .from("from@example.com")
5039 .to("user@example.com")
5040 .subject("Blob")
5041 .text("see attached")
5042 .attach("blob.bin", "application/octet-stream", blob)
5043 .build()
5044 .expect("mail should build");
5045 let eml = render_eml(&mail);
5046 let boundary = mixed_boundary(&eml);
5047
5048 let start = eml
5049 .find("Content-Transfer-Encoding: base64\n\n")
5050 .expect("base64 section present")
5051 + "Content-Transfer-Encoding: base64\n\n".len();
5052 let rest = &eml[start..];
5053 let end = rest
5054 .find(&format!("--{boundary}"))
5055 .expect("closing boundary present");
5056 let encoded: String = rest[..end].chars().filter(|c| !c.is_whitespace()).collect();
5057
5058 let decoded = base64::engine::general_purpose::STANDARD
5059 .decode(encoded)
5060 .expect("attachment body should be valid base64");
5061 assert_eq!(sha256_hex(&decoded), expected_digest);
5062 }
5063
5064 #[test]
5065 fn render_eml_preserves_attachment_order() {
5066 let mail = Mail::builder()
5067 .from("from@example.com")
5068 .to("user@example.com")
5069 .subject("Multi")
5070 .text("see attached")
5071 .attach("a.txt", "text/plain", b"a".to_vec())
5072 .attach("b.txt", "text/plain", b"b".to_vec())
5073 .build()
5074 .expect("mail should build");
5075 let eml = render_eml(&mail);
5076 let a_pos = eml.find("filename=\"a.txt\"").expect("a.txt present");
5077 let b_pos = eml.find("filename=\"b.txt\"").expect("b.txt present");
5078 assert!(a_pos < b_pos, "attachments must render in declared order");
5079 }
5080
5081 #[test]
5082 fn render_eml_with_attachment_nests_alternative_body() {
5083 let mail = Mail::builder()
5084 .from("from@example.com")
5085 .to("user@example.com")
5086 .subject("Both bodies")
5087 .text("plain")
5088 .html("<p>html</p>")
5089 .attach("a.txt", "text/plain", b"a".to_vec())
5090 .build()
5091 .expect("mail should build");
5092 let eml = render_eml(&mail);
5093 assert!(eml.contains("Content-Type: multipart/alternative; boundary=\"autumn-mail\""));
5094 assert!(eml.contains("plain"));
5095 assert!(eml.contains("<p>html</p>"));
5096 }
5097
5098 #[test]
5099 fn render_eml_blocks_filename_header_injection() {
5100 let mail = Mail {
5104 from: Some("from@example.com".to_owned()),
5105 reply_to: None,
5106 to: vec!["user@example.com".to_owned()],
5107 subject: "Hi".to_owned(),
5108 html: None,
5109 text: Some("hello".to_owned()),
5110 list_unsubscribe: None,
5111 extra_headers: Vec::new(),
5112 attachments: vec![MailAttachment {
5113 filename: "evil\r\nX-Injected: 1.pdf".to_owned(),
5114 content_type: "application/pdf".to_owned(),
5115 bytes: b"x".to_vec(),
5116 }],
5117 ignore_suppression: false,
5118 inline_css: None,
5119 };
5120 let eml = render_eml(&mail);
5121 assert!(
5122 !eml.lines().any(|line| line.starts_with("X-Injected")),
5123 "CRLF in filename must not inject a header: {eml}"
5124 );
5125 assert!(!eml.contains('\r'));
5126 }
5127
5128 #[test]
5129 fn render_eml_blocks_header_injection_in_all_deserialized_fields() {
5130 let mail = Mail {
5135 from: Some("from@example.com\r\nX-From-Injected: 1".to_owned()),
5136 reply_to: Some("reply@example.com\r\nX-Reply-Injected: 1".to_owned()),
5137 to: vec!["user@example.com\r\nX-To-Injected: 1".to_owned()],
5138 subject: "Hi\r\nX-Subject-Injected: 1".to_owned(),
5139 html: None,
5140 text: Some("hello".to_owned()),
5141 list_unsubscribe: None,
5142 extra_headers: vec![(
5143 "X-Custom\r\nX-Header-Injected".to_owned(),
5144 "1\r\nX-Value-Injected: 1".to_owned(),
5145 )],
5146 attachments: Vec::new(),
5147 ignore_suppression: false,
5148 inline_css: None,
5149 };
5150 let eml = render_eml(&mail);
5151 assert!(
5152 !eml.lines().any(|line| line.starts_with("X-From-Injected")
5153 || line.starts_with("X-Reply-Injected")
5154 || line.starts_with("X-To-Injected")
5155 || line.starts_with("X-Subject-Injected")
5156 || line.starts_with("X-Header-Injected")
5157 || line.starts_with("X-Value-Injected")),
5158 "CRLF in any header-bound field must not inject a standalone header line: {eml}"
5159 );
5160 assert!(!eml.contains('\r'));
5161 }
5162
5163 #[test]
5164 fn render_eml_falls_back_to_octet_stream_for_invalid_content_type() {
5165 let mail = Mail {
5169 from: Some("from@example.com".to_owned()),
5170 reply_to: None,
5171 to: vec!["user@example.com".to_owned()],
5172 subject: "Hi".to_owned(),
5173 html: None,
5174 text: Some("hello".to_owned()),
5175 list_unsubscribe: None,
5176 extra_headers: Vec::new(),
5177 attachments: vec![MailAttachment {
5178 filename: "file.bin".to_owned(),
5179 content_type: "not a mime type".to_owned(),
5180 bytes: b"x".to_vec(),
5181 }],
5182 ignore_suppression: false,
5183 inline_css: None,
5184 };
5185 let eml = render_eml(&mail);
5186 assert!(eml.contains("Content-Type: application/octet-stream"));
5187 assert!(!eml.contains("not a mime type"));
5188 }
5189
5190 #[test]
5191 fn render_eml_encodes_non_ascii_filename_rfc2231() {
5192 let mail = Mail {
5193 from: Some("from@example.com".to_owned()),
5194 reply_to: None,
5195 to: vec!["user@example.com".to_owned()],
5196 subject: "Hi".to_owned(),
5197 html: None,
5198 text: Some("hello".to_owned()),
5199 list_unsubscribe: None,
5200 extra_headers: Vec::new(),
5201 attachments: vec![MailAttachment {
5202 filename: "Résumé façade.pdf".to_owned(),
5203 content_type: "application/pdf".to_owned(),
5204 bytes: b"x".to_vec(),
5205 }],
5206 ignore_suppression: false,
5207 inline_css: None,
5208 };
5209 let eml = render_eml(&mail);
5210 assert!(eml.contains("filename*=UTF-8''"));
5211 let disposition_line = eml
5212 .lines()
5213 .find(|line| line.starts_with("Content-Disposition:"))
5214 .expect("Content-Disposition header present");
5215 assert!(disposition_line.is_ascii());
5216 }
5217
5218 #[test]
5219 fn content_disposition_params_table() {
5220 assert_eq!(content_disposition_params("a.txt"), "filename=\"a.txt\"");
5221 assert_eq!(
5222 content_disposition_params("weird\"na\\me.txt"),
5223 "filename=\"weird\\\"na\\\\me.txt\""
5224 );
5225 assert_eq!(
5226 content_disposition_params("evil\r\nX: 1"),
5227 "filename=\"evilX: 1\""
5228 );
5229 assert_eq!(content_disposition_params(""), "filename=\"attachment\"");
5230 assert_eq!(content_disposition_params(" "), "filename=\"attachment\"");
5231 let non_ascii = content_disposition_params("café.txt");
5232 assert!(non_ascii.contains("filename*=UTF-8''caf%C3%A9.txt"));
5233 assert!(non_ascii.is_ascii());
5234 }
5235
5236 #[test]
5237 fn render_eml_base64_lines_wrap_at_76() {
5238 let blob = blob_all_byte_values();
5239 let mail = Mail::builder()
5240 .from("from@example.com")
5241 .to("user@example.com")
5242 .subject("Blob")
5243 .text("see attached")
5244 .attach("blob.bin", "application/octet-stream", blob)
5245 .build()
5246 .expect("mail should build");
5247 let eml = render_eml(&mail);
5248 let boundary = mixed_boundary(&eml);
5249 let start = eml
5250 .find("Content-Transfer-Encoding: base64\n\n")
5251 .expect("base64 section present")
5252 + "Content-Transfer-Encoding: base64\n\n".len();
5253 let rest = &eml[start..];
5254 let end = rest
5255 .find(&format!("--{boundary}"))
5256 .expect("closing boundary present");
5257 for line in rest[..end].lines() {
5258 assert!(
5259 line.len() <= 76,
5260 "base64 line too long: {} chars",
5261 line.len()
5262 );
5263 }
5264 }
5265
5266 #[test]
5269 fn lettre_message_with_attachment_is_multipart_mixed() {
5270 let mail = Mail::builder()
5271 .from("from@example.com")
5272 .to("user@example.com")
5273 .subject("Invoice")
5274 .text("see attached")
5275 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5276 .build()
5277 .expect("mail should build");
5278 let message = lettre_message(&mail).expect("lettre message should build");
5279 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5280 assert!(formatted.contains("multipart/mixed"));
5281 assert!(formatted.contains("Content-Disposition: attachment"));
5282 assert!(formatted.contains("invoice.pdf"));
5283 assert!(formatted.contains("base64"));
5284 }
5285
5286 fn extract_boundary(text: &str) -> String {
5287 let marker = "boundary=\"";
5288 let start = text.find(marker).expect("boundary present") + marker.len();
5289 let rest = &text[start..];
5290 let end = rest.find('"').expect("boundary closing quote");
5291 rest[..end].to_owned()
5292 }
5293
5294 fn extract_attachment_base64(formatted_lf: &str, boundary: &str) -> String {
5295 let marker = format!("--{boundary}");
5296 for segment in formatted_lf.split(&marker).skip(1) {
5297 let segment = segment.trim_start_matches(['\n', '\r']);
5298 if segment.starts_with("--") {
5299 break;
5300 }
5301 let (headers, body) = split_headers_body(segment);
5302 if header_value(&headers, "Content-Disposition")
5303 .unwrap_or_default()
5304 .to_ascii_lowercase()
5305 .contains("attachment")
5306 {
5307 return body.chars().filter(|c| !c.is_whitespace()).collect();
5308 }
5309 }
5310 panic!("attachment part not found in: {formatted_lf}");
5311 }
5312
5313 #[test]
5314 fn lettre_message_attachment_round_trips_sha256() {
5315 use base64::Engine as _;
5316 let blob = blob_all_byte_values();
5317 let expected_digest = sha256_hex(&blob);
5318 let mail = Mail::builder()
5319 .from("from@example.com")
5320 .to("user@example.com")
5321 .subject("Blob")
5322 .text("see attached")
5323 .attach("blob.bin", "application/octet-stream", blob)
5324 .build()
5325 .expect("mail should build");
5326 let message = lettre_message(&mail).expect("lettre message should build");
5327 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5328 let normalized = formatted.replace("\r\n", "\n");
5329 let boundary = extract_boundary(&normalized);
5330 let encoded = extract_attachment_base64(&normalized, &boundary);
5331
5332 let decoded = base64::engine::general_purpose::STANDARD
5333 .decode(encoded)
5334 .expect("attachment body should be valid base64");
5335 assert_eq!(sha256_hex(&decoded), expected_digest);
5336 }
5337
5338 #[test]
5339 fn lettre_message_attachment_headers_ascii_and_injection_free() {
5340 for filename in ["evil\r\nX-Injected: 1.pdf", "Résumé façade.pdf"] {
5341 let mail = Mail {
5342 from: Some("from@example.com".to_owned()),
5343 reply_to: None,
5344 to: vec!["user@example.com".to_owned()],
5345 subject: "Hi".to_owned(),
5346 html: None,
5347 text: Some("hello".to_owned()),
5348 list_unsubscribe: None,
5349 extra_headers: Vec::new(),
5350 attachments: vec![MailAttachment {
5351 filename: filename.to_owned(),
5352 content_type: "application/pdf".to_owned(),
5353 bytes: b"x".to_vec(),
5354 }],
5355 ignore_suppression: false,
5356 inline_css: None,
5357 };
5358 let message = lettre_message(&mail).expect("lettre message should build");
5359 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5360 let header_section = formatted
5361 .split("\r\n\r\n")
5362 .next()
5363 .expect("header section present");
5364 assert!(
5365 header_section.is_ascii(),
5366 "headers must stay ASCII for filename {filename:?}: {header_section}"
5367 );
5368 assert!(
5369 !formatted.lines().any(|line| line.starts_with("X-Injected")),
5370 "CRLF in filename must not inject a header for {filename:?}"
5371 );
5372 }
5373 }
5374
5375 #[test]
5376 fn lettre_message_attachment_with_invalid_content_type_errors() {
5377 let mail = Mail {
5378 from: Some("from@example.com".to_owned()),
5379 reply_to: None,
5380 to: vec!["user@example.com".to_owned()],
5381 subject: "Hi".to_owned(),
5382 html: None,
5383 text: Some("hello".to_owned()),
5384 list_unsubscribe: None,
5385 extra_headers: Vec::new(),
5386 attachments: vec![MailAttachment {
5387 filename: "a.bin".to_owned(),
5388 content_type: "not a mime type".to_owned(),
5389 bytes: b"x".to_vec(),
5390 }],
5391 ignore_suppression: false,
5392 inline_css: None,
5393 };
5394 let err = lettre_message(&mail).expect_err("invalid content type should error");
5395 assert!(matches!(err, MailError::InvalidMessage(_)));
5396 }
5397
5398 #[test]
5401 fn parse_eml_extracts_attachment_list() {
5402 let mail = Mail::builder()
5403 .from("from@example.com")
5404 .to("user@example.com")
5405 .subject("Invoice")
5406 .text("plain")
5407 .html("<p>html</p>")
5408 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5409 .attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
5410 .build()
5411 .expect("mail should build");
5412 let eml = render_eml(&mail);
5413 let parsed = parse_eml(&eml);
5414 assert_eq!(parsed.attachments.len(), 2);
5415 assert_eq!(parsed.attachments[0].filename, "invoice.pdf");
5416 assert_eq!(parsed.attachments[0].content_type, "application/pdf");
5417 assert_eq!(parsed.attachments[1].filename, "receipt.csv");
5418 assert_eq!(parsed.html.as_deref(), Some("<p>html</p>"));
5419 assert_eq!(parsed.text.as_deref(), Some("plain"));
5420 }
5421
5422 #[test]
5423 fn extract_attachment_filename_handles_semicolon_in_quoted_filename() {
5424 assert_eq!(
5426 extract_attachment_filename(r#"attachment; filename="invoice;2026.pdf""#),
5427 "invoice;2026.pdf"
5428 );
5429 }
5430
5431 #[test]
5432 fn extract_attachment_filename_unescapes_quoted_pairs() {
5433 assert_eq!(
5434 extract_attachment_filename(r#"attachment; filename="weird\"na\\me.txt""#),
5435 "weird\"na\\me.txt"
5436 );
5437 }
5438
5439 #[test]
5440 fn extract_attachment_filename_is_case_insensitive_and_handles_language_tag() {
5441 assert_eq!(
5442 extract_attachment_filename("attachment; filename*=utf-8'en'r%C3%A9sum%C3%A9.pdf"),
5443 "résumé.pdf"
5444 );
5445 }
5446
5447 #[test]
5448 fn extract_attachment_filename_round_trips_through_dev_preview() {
5449 let mail = Mail::builder()
5450 .from("from@example.com")
5451 .to("user@example.com")
5452 .subject("Invoice")
5453 .text("plain")
5454 .attach(r#"a;b"c\d.txt"#, "text/plain", b"x".to_vec())
5455 .build()
5456 .expect("mail should build");
5457 let eml = render_eml(&mail);
5458 let parsed = parse_eml(&eml);
5459 assert_eq!(parsed.attachments.len(), 1);
5460 assert_eq!(parsed.attachments[0].filename, r#"a;b"c\d.txt"#);
5461 }
5462
5463 #[test]
5464 fn parse_multipart_mixed_does_not_misclassify_inline_as_attachment() {
5465 let (_, _, attachments) = parse_multipart_mixed(
5466 "--b\nContent-Disposition: inline; filename=\"my-attachment-notes.pdf\"\nContent-Type: text/plain\n\nhi\n--b--\n",
5467 "b",
5468 );
5469 assert!(
5470 attachments.is_empty(),
5471 "an `inline` disposition must not be classified as an attachment: {attachments:?}"
5472 );
5473 }
5474
5475 #[test]
5476 fn mail_deserializes_from_pre_attachments_json_shape() {
5477 let json = r#"{"from":null,"reply_to":null,"to":["a@example.com"],"subject":"hi","html":null,"text":"hello","list_unsubscribe":null,"extra_headers":[]}"#;
5482 let mail: Mail =
5483 serde_json::from_str(json).expect("pre-attachments JSON should deserialize");
5484 assert!(mail.attachments.is_empty());
5485 }
5486
5487 #[test]
5488 fn render_mail_detail_lists_attachments() {
5489 let mail = Mail::builder()
5490 .from("from@example.com")
5491 .to("user@example.com")
5492 .subject("Invoice")
5493 .text("plain")
5494 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5495 .attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
5496 .build()
5497 .expect("mail should build");
5498 let parsed = parse_eml(&render_eml(&mail));
5499 let detail = render_mail_detail(&parsed, "captured");
5500 assert!(detail.contains("Attachments (2)"));
5501 assert!(detail.contains("invoice.pdf"));
5502 assert!(detail.contains("receipt.csv"));
5503 }
5504
5505 #[test]
5506 fn render_mail_detail_without_attachments_omits_section() {
5507 let mail = Mail::builder()
5508 .from("from@example.com")
5509 .to("user@example.com")
5510 .subject("Plain")
5511 .text("plain")
5512 .build()
5513 .expect("mail should build");
5514 let parsed = parse_eml(&render_eml(&mail));
5515 let detail = render_mail_detail(&parsed, "captured");
5516 assert!(!detail.contains("Attachments"));
5517 }
5518
5519 #[test]
5520 fn mail_builder_rejects_missing_body() {
5521 let err = Mail::builder()
5522 .to("user@example.com")
5523 .subject("Hello")
5524 .build()
5525 .expect_err("body should be required");
5526 assert!(err.to_string().contains("html or text"));
5527 }
5528
5529 #[test]
5530 fn filename_sanitizer_keeps_safe_characters() {
5531 assert_eq!(
5532 sanitize_filename("Ada Lovelace <ada@example.com>"),
5533 "Ada_Lovelace__ada_example.com_"
5534 );
5535 }
5536
5537 #[test]
5538 fn transport_default_is_disabled() {
5539 assert_eq!(Transport::default(), Transport::Disabled);
5540 }
5541
5542 #[test]
5545 fn mail_defaults_have_no_unsubscribe_or_extra_headers() {
5546 let mail = Mail::builder()
5547 .to("user@example.com")
5548 .subject("Hi")
5549 .text("hello")
5550 .build()
5551 .expect("mail should build");
5552 assert_eq!(mail.list_unsubscribe, None);
5553 assert!(mail.extra_headers.is_empty());
5554 assert!(mail.attachments.is_empty());
5555 }
5556
5557 #[test]
5558 fn mail_builder_sets_list_unsubscribe_and_headers() {
5559 let mail = Mail::builder()
5560 .to("user@example.com")
5561 .subject("Hi")
5562 .text("hello")
5563 .list_unsubscribe("weekly_digest")
5564 .header("X-Custom", "1")
5565 .build()
5566 .expect("mail should build");
5567 assert_eq!(mail.list_unsubscribe.as_deref(), Some("weekly_digest"));
5568 assert_eq!(
5569 mail.extra_headers,
5570 vec![("X-Custom".to_owned(), "1".to_owned())]
5571 );
5572 }
5573
5574 fn test_keys() -> crate::security::config::ResolvedSigningKeys {
5577 crate::security::config::ResolvedSigningKeys::new(
5578 b"unit-test-signing-key-0123456789".to_vec(),
5579 vec![],
5580 )
5581 }
5582
5583 #[test]
5584 fn token_roundtrips_and_hides_subscriber() {
5585 use base64::Engine as _;
5586 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
5587 let keys = test_keys();
5588 let token =
5589 unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
5590 assert!(
5591 !token.contains("ada@example.com"),
5592 "raw subscriber must not appear in the token: {token}"
5593 );
5594 assert!(
5597 !token.contains(&engine.encode("ada@example.com")),
5598 "base64 of subscriber must not appear — the payload must be encrypted: {token}"
5599 );
5600 let decoded = unsubscribe::verify_token(&keys, &token, 1_000).expect("token should verify");
5601 assert_eq!(decoded.subscriber, "ada@example.com");
5602 assert_eq!(decoded.list_id, "weekly_digest");
5603 }
5604
5605 #[test]
5606 fn token_rejects_tamper_and_expiry() {
5607 use base64::Engine as _;
5608 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
5609 let keys = test_keys();
5610 let token =
5611 unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
5612 let mut blob = engine.decode(&token).expect("token is base64");
5614 let last = blob.len() - 1;
5615 blob[last] ^= 0x01;
5616 let tampered = engine.encode(&blob);
5617 assert_eq!(
5618 unsubscribe::verify_token(&keys, &tampered, 1_000),
5619 Err(unsubscribe::TokenError::BadSignature)
5620 );
5621 let short = unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 100);
5623 assert_eq!(
5624 unsubscribe::verify_token(&keys, &short, 200),
5625 Err(unsubscribe::TokenError::Expired)
5626 );
5627 }
5628
5629 #[test]
5630 fn token_verifies_under_rotated_previous_key() {
5631 let signer = crate::security::config::ResolvedSigningKeys::new(
5632 b"old-key-old-key-old-key-old-key!".to_vec(),
5633 vec![],
5634 );
5635 let token = unsubscribe::sign_token(&signer, "ada@example.com", "list", 4_000_000_000);
5636 let rotated = crate::security::config::ResolvedSigningKeys::new(
5637 b"new-key-new-key-new-key-new-key!".to_vec(),
5638 vec![b"old-key-old-key-old-key-old-key!".to_vec()],
5639 );
5640 assert!(unsubscribe::verify_token(&rotated, &token, 1_000).is_ok());
5641 }
5642
5643 #[test]
5644 fn unsubscribe_url_includes_token_and_path() {
5645 let url = unsubscribe::unsubscribe_url("https://app.example.com/", "TOK");
5646 assert_eq!(url, "https://app.example.com/_autumn/unsubscribe?token=TOK");
5647 }
5648
5649 #[tokio::test]
5652 async fn in_memory_suppression_transitions() {
5653 let store = InMemorySuppressionStore::new();
5654 assert!(!store.is_suppressed("a@x.com", "list").await.unwrap());
5655 store.suppress("a@x.com", "list").await.unwrap();
5656 assert!(store.is_suppressed("a@x.com", "list").await.unwrap());
5657 assert!(!store.is_suppressed("a@x.com", "other").await.unwrap());
5659 assert!(!store.is_suppressed("b@x.com", "list").await.unwrap());
5660 }
5661
5662 #[test]
5665 fn render_eml_emits_extra_headers() {
5666 let mail = Mail::builder()
5667 .from("from@example.com")
5668 .to("user@example.com")
5669 .subject("Hi")
5670 .text("hello")
5671 .header("List-Unsubscribe", "<https://x/u?token=t>, <mailto:u@x>")
5672 .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
5673 .build()
5674 .expect("mail should build");
5675 let eml = render_eml(&mail);
5676 assert!(eml.contains("List-Unsubscribe: <https://x/u?token=t>, <mailto:u@x>"));
5677 assert!(eml.contains("List-Unsubscribe-Post: List-Unsubscribe=One-Click"));
5678 }
5679
5680 #[test]
5681 fn render_eml_without_headers_has_no_unsubscribe() {
5682 let mail = Mail::builder()
5683 .from("from@example.com")
5684 .to("user@example.com")
5685 .subject("Hi")
5686 .text("hello")
5687 .build()
5688 .expect("mail should build");
5689 assert!(!render_eml(&mail).contains("List-Unsubscribe"));
5690 }
5691
5692 #[derive(Clone)]
5693 struct CapturingTransport {
5694 sent: Arc<std::sync::Mutex<Vec<Mail>>>,
5695 }
5696
5697 impl MailTransport for CapturingTransport {
5698 fn send<'a>(
5699 &'a self,
5700 mail: Mail,
5701 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
5702 Box::pin(async move {
5703 self.sent.lock().expect("sent lock").push(mail);
5704 Ok(())
5705 })
5706 }
5707 }
5708
5709 fn unsubscribe_runtime(
5710 suppression: Option<Arc<dyn SuppressionStore>>,
5711 ) -> Arc<UnsubscribeRuntime> {
5712 Arc::new(UnsubscribeRuntime {
5713 base_url: Some("https://app.example.com".to_owned()),
5714 mailto: Some("unsub@example.com".to_owned()),
5715 signing_keys: Arc::new(test_keys()),
5716 ttl_days: 30,
5717 suppression,
5718 })
5719 }
5720
5721 #[tokio::test]
5722 #[allow(clippy::significant_drop_tightening)]
5723 async fn send_adds_headers_for_list_mail() {
5724 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5725 let transport = CapturingTransport { sent: sent.clone() };
5726 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5727 let mail = Mail::builder()
5728 .from("from@example.com")
5729 .to("user@example.com")
5730 .subject("Digest")
5731 .text("hello")
5732 .list_unsubscribe("weekly_digest")
5733 .build()
5734 .unwrap();
5735 mailer.send(mail).await.unwrap();
5736 let captured = sent.lock().unwrap();
5737 assert_eq!(captured.len(), 1);
5738 let headers = &captured[0].extra_headers;
5739 assert!(headers.iter().any(|(n, v)| n == "List-Unsubscribe"
5740 && v.contains("/_autumn/unsubscribe?token=")
5741 && v.contains("mailto:unsub@example.com")));
5742 assert!(
5743 headers
5744 .iter()
5745 .any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
5746 );
5747 }
5748
5749 #[tokio::test]
5750 #[allow(clippy::significant_drop_tightening)]
5751 async fn send_replaces_manual_list_unsubscribe_with_generated_one_click() {
5752 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5756 let transport = CapturingTransport { sent: sent.clone() };
5757 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5758 let mail = Mail::builder()
5759 .from("from@example.com")
5760 .to("user@example.com")
5761 .subject("Digest")
5762 .text("hello")
5763 .header("List-Unsubscribe", "<mailto:old@example.com>")
5764 .list_unsubscribe("weekly_digest")
5765 .build()
5766 .unwrap();
5767 mailer.send(mail).await.unwrap();
5768 let captured = sent.lock().unwrap();
5769 assert_eq!(captured.len(), 1);
5770 let headers = &captured[0].extra_headers;
5771 let unsub: Vec<&String> = headers
5774 .iter()
5775 .filter(|(n, _)| n == "List-Unsubscribe")
5776 .map(|(_, v)| v)
5777 .collect();
5778 assert_eq!(unsub.len(), 1);
5779 assert!(unsub[0].contains("/_autumn/unsubscribe?token="));
5780 assert!(!unsub[0].contains("old@example.com"));
5781 assert!(
5782 headers
5783 .iter()
5784 .any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
5785 );
5786 }
5787
5788 #[tokio::test]
5789 async fn send_list_mail_rejects_invalid_recipient_before_delivery() {
5790 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5791 let transport = CapturingTransport { sent: sent.clone() };
5792 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5793 let mail = Mail::builder()
5796 .from("from@example.com")
5797 .to("good@example.com")
5798 .to("not a valid address")
5799 .subject("Digest")
5800 .text("hello")
5801 .list_unsubscribe("weekly_digest")
5802 .build()
5803 .unwrap();
5804 let result = mailer.send(mail).await;
5805 assert!(result.is_err(), "invalid recipient must fail the send");
5806 assert!(
5807 sent.lock().unwrap().is_empty(),
5808 "no recipient may be delivered when the list contains an invalid address"
5809 );
5810 }
5811
5812 #[tokio::test]
5813 async fn send_list_mail_suppression_error_fails_before_any_delivery() {
5814 struct FailingStore {
5816 fail_for: String,
5817 }
5818 impl SuppressionStore for FailingStore {
5819 fn is_suppressed<'a>(
5820 &'a self,
5821 subscriber: &'a str,
5822 _list_id: &'a str,
5823 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
5824 let fails = subscriber == self.fail_for;
5825 Box::pin(async move {
5826 if fails {
5827 Err(MailError::RuntimeUnavailable(
5828 "store unavailable".to_owned(),
5829 ))
5830 } else {
5831 Ok(false)
5832 }
5833 })
5834 }
5835 fn suppress<'a>(
5836 &'a self,
5837 _subscriber: &'a str,
5838 _list_id: &'a str,
5839 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
5840 Box::pin(async move { Ok(()) })
5841 }
5842 }
5843
5844 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5845 let transport = CapturingTransport { sent: sent.clone() };
5846 let store: Arc<dyn SuppressionStore> = Arc::new(FailingStore {
5847 fail_for: "second@example.com".to_owned(),
5848 });
5849 let mailer =
5850 Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
5851 let mail = Mail::builder()
5855 .from("from@example.com")
5856 .to("first@example.com")
5857 .to("second@example.com")
5858 .subject("Digest")
5859 .text("hello")
5860 .list_unsubscribe("weekly_digest")
5861 .build()
5862 .unwrap();
5863 let result = mailer.send(mail).await;
5864 assert!(
5865 result.is_err(),
5866 "suppression-store error must fail the send"
5867 );
5868 assert!(
5869 sent.lock().unwrap().is_empty(),
5870 "no recipient may be delivered when a later suppression lookup fails"
5871 );
5872 }
5873
5874 #[tokio::test]
5875 async fn send_skips_suppressed_recipient() {
5876 let store = Arc::new(InMemorySuppressionStore::new());
5877 store
5878 .suppress("user@example.com", "weekly_digest")
5879 .await
5880 .unwrap();
5881 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5882 let transport = CapturingTransport { sent: sent.clone() };
5883 let mailer =
5884 Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
5885 let mail = Mail::builder()
5886 .from("from@example.com")
5887 .to("user@example.com")
5888 .subject("Digest")
5889 .text("hello")
5890 .list_unsubscribe("weekly_digest")
5891 .build()
5892 .unwrap();
5893 mailer.send(mail).await.unwrap();
5894 assert!(
5895 sent.lock().unwrap().is_empty(),
5896 "suppressed recipient must be skipped"
5897 );
5898 }
5899
5900 #[tokio::test]
5901 #[allow(clippy::significant_drop_tightening)]
5902 async fn send_without_scope_is_unchanged() {
5903 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
5904 let transport = CapturingTransport { sent: sent.clone() };
5905 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
5906 let mail = Mail::builder()
5907 .from("from@example.com")
5908 .to("user@example.com")
5909 .subject("Reset")
5910 .text("hello")
5911 .build()
5912 .unwrap();
5913 mailer.send(mail).await.unwrap();
5914 let captured = sent.lock().unwrap();
5915 assert_eq!(captured.len(), 1);
5916 assert!(
5917 captured[0].extra_headers.is_empty(),
5918 "non-list mail must not gain headers"
5919 );
5920 }
5921
5922 #[test]
5925 fn fail_closed_only_in_prod_with_mailers_and_no_config() {
5926 assert!(unsubscribe_config_fail_closed(true, true, true, false));
5927 assert!(!unsubscribe_config_fail_closed(true, true, true, true));
5929 assert!(!unsubscribe_config_fail_closed(true, true, false, false));
5931 assert!(!unsubscribe_config_fail_closed(true, false, true, false));
5933 assert!(!unsubscribe_config_fail_closed(false, true, true, false));
5935 }
5936
5937 #[test]
5938 fn validate_rejects_non_positive_unsubscribe_ttl() {
5939 let ttl = |days: i64| MailConfig {
5940 unsubscribe_token_ttl_days: days,
5941 ..MailConfig::default()
5942 };
5943 assert!(ttl(0).validate(Some("dev")).is_err());
5944 assert!(ttl(-1).validate(Some("dev")).is_err());
5945 assert!(ttl(30).validate(Some("dev")).is_ok());
5946 }
5947
5948 #[test]
5949 fn unsubscribe_base_url_set_tracks_config() {
5950 let with = |base: Option<&str>, mailto: Option<&str>| MailConfig {
5951 unsubscribe_base_url: base.map(str::to_owned),
5952 unsubscribe_mailto: mailto.map(str::to_owned),
5953 ..MailConfig::default()
5954 };
5955 assert!(!with(None, None).unsubscribe_base_url_set());
5956 assert!(!with(None, Some("u@example.com")).unsubscribe_base_url_set());
5958 assert!(with(Some("https://x"), None).unsubscribe_base_url_set());
5959 assert!(!with(Some(" "), None).unsubscribe_base_url_set());
5960 }
5961
5962 #[test]
5963 fn should_mount_unsubscribe_endpoint_requires_opt_in_and_base_url() {
5964 let cfg = |base: Option<&str>, opt_in: bool| MailConfig {
5965 unsubscribe_base_url: base.map(str::to_owned),
5966 mount_unsubscribe_endpoint: opt_in,
5967 ..MailConfig::default()
5968 };
5969 assert!(!cfg(Some("https://x"), false).should_mount_unsubscribe_endpoint());
5971 assert!(cfg(Some("https://x"), true).should_mount_unsubscribe_endpoint());
5972 assert!(!cfg(None, true).should_mount_unsubscribe_endpoint());
5974 }
5975
5976 #[test]
5977 fn validate_rejects_malformed_mailto_in_prod() {
5978 let cfg = |mailto: &str| MailConfig {
5979 unsubscribe_mailto: Some(mailto.to_owned()),
5980 ..MailConfig::default()
5981 };
5982 assert!(
5983 cfg("unsubscribe example.com")
5984 .validate(Some("prod"))
5985 .is_err()
5986 );
5987 assert!(cfg("not-an-email").validate(Some("prod")).is_err());
5988 assert!(cfg("unsub@example.com").validate(Some("prod")).is_ok());
5989 assert!(
5991 cfg("mailto:unsub@example.com")
5992 .validate(Some("prod"))
5993 .is_ok()
5994 );
5995 assert!(cfg("whatever").validate(Some("dev")).is_ok());
5997 }
5998
5999 #[test]
6000 fn validate_requires_https_base_url_in_prod() {
6001 let cfg = |url: &str| MailConfig {
6002 unsubscribe_base_url: Some(url.to_owned()),
6003 ..MailConfig::default()
6004 };
6005 assert!(
6006 cfg("http://app.example.com")
6007 .validate(Some("prod"))
6008 .is_err()
6009 );
6010 assert!(
6011 cfg("https://app.example.com")
6012 .validate(Some("prod"))
6013 .is_ok()
6014 );
6015 assert!(cfg("http://localhost:3000").validate(Some("dev")).is_ok());
6017 assert!(cfg("https://").validate(Some("prod")).is_err());
6019 assert!(cfg("https:///path").validate(Some("prod")).is_err());
6020 assert!(
6022 cfg("https://app.example.com?t=acme")
6023 .validate(Some("prod"))
6024 .is_err()
6025 );
6026 assert!(
6027 cfg("https://app.example.com#x")
6028 .validate(Some("prod"))
6029 .is_err()
6030 );
6031 assert!(
6032 cfg("https://app.example.com/base")
6033 .validate(Some("prod"))
6034 .is_ok()
6035 );
6036 }
6037
6038 #[test]
6039 fn canonical_subscriber_strips_name_and_lowercases() {
6040 assert_eq!(
6041 canonical_subscriber("Ada Lovelace <Ada@Example.com>"),
6042 "ada@example.com"
6043 );
6044 assert_eq!(canonical_subscriber("USER@EXAMPLE.COM"), "user@example.com");
6045 }
6046
6047 #[test]
6048 fn mailto_only_runtime_does_not_support_one_click() {
6049 let runtime = UnsubscribeRuntime {
6050 base_url: None,
6051 mailto: Some("u@example.com".to_owned()),
6052 signing_keys: Arc::new(test_keys()),
6053 ttl_days: 30,
6054 suppression: None,
6055 };
6056 assert!(!runtime.supports_one_click());
6057 let header = runtime
6058 .list_unsubscribe_header("a@x.com", "list")
6059 .expect("mailto header");
6060 assert!(header.contains("mailto:u@example.com"));
6061 assert!(!header.contains("token="));
6062 }
6063
6064 #[test]
6065 fn mailto_value_with_scheme_is_not_double_prefixed() {
6066 let runtime = UnsubscribeRuntime {
6067 base_url: None,
6068 mailto: Some("mailto:u@example.com".to_owned()),
6069 signing_keys: Arc::new(test_keys()),
6070 ttl_days: 30,
6071 suppression: None,
6072 };
6073 let header = runtime
6074 .list_unsubscribe_header("a@x.com", "list")
6075 .expect("mailto header");
6076 assert!(header.contains("<mailto:u@example.com?subject=unsubscribe>"));
6077 assert!(!header.contains("mailto:mailto:"));
6078 }
6079
6080 #[test]
6081 fn one_click_body_detection() {
6082 assert!(is_one_click_body("List-Unsubscribe=One-Click"));
6083 assert!(is_one_click_body("foo=bar&List-Unsubscribe=One-Click"));
6084 assert!(is_one_click_body("list-unsubscribe=one-click")); assert!(!is_one_click_body(""));
6086 assert!(!is_one_click_body("List-Unsubscribe=Nope"));
6087 assert!(!is_one_click_body("something=else"));
6088 }
6089
6090 #[test]
6091 fn smtp_config_validation_rejects_whitespace_only_host() {
6092 let config = MailConfig {
6093 transport: Transport::Smtp,
6094 smtp: SmtpConfig {
6095 host: Some(" ".to_owned()),
6096 ..Default::default()
6097 },
6098 ..Default::default()
6099 };
6100
6101 let error = config
6102 .validate(Some("dev"))
6103 .expect_err("whitespace SMTP host should be rejected");
6104
6105 assert!(error.to_string().contains("mail.smtp.host is required"));
6106 }
6107
6108 #[test]
6109 fn transport_env_value_is_trimmed_and_case_insensitive() {
6110 assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
6111 assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
6112 }
6113
6114 #[test]
6115 fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
6116 assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
6117 assert_eq!(
6118 TlsMode::from_env_value(" START_TLS "),
6119 Some(TlsMode::StartTls)
6120 );
6121 assert_eq!(
6122 TlsMode::from_env_value(" disabled "),
6123 Some(TlsMode::Disabled)
6124 );
6125 }
6126
6127 #[test]
6128 fn file_transport_filename_is_unique_for_same_recipient() {
6129 let mail = Mail::builder()
6130 .to("Ada Lovelace <ada@example.com>")
6131 .subject("Hello")
6132 .text("body")
6133 .build()
6134 .expect("mail should build");
6135
6136 let first = file_transport_filename(&mail);
6137 let second = file_transport_filename(&mail);
6138
6139 assert_ne!(first, second);
6140 assert!(
6141 Path::new(&first)
6142 .extension()
6143 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
6144 );
6145 assert!(
6146 Path::new(&second)
6147 .extension()
6148 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
6149 );
6150 }
6151
6152 #[test]
6153 fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
6154 let missing_key = format!(
6155 "AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
6156 std::process::id(),
6157 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
6158 );
6159 let Err(error) = SmtpTransport::new(
6160 SmtpConfig {
6161 host: Some("smtp.example.com".to_owned()),
6162 port: Some(587),
6163 username: Some("mailer".to_owned()),
6164 password_env: Some(missing_key.clone()),
6165 tls: TlsMode::StartTls,
6166 },
6167 None,
6168 ) else {
6169 panic!("missing password env should fail at startup");
6170 };
6171
6172 let displayed = error.to_string();
6173 assert!(displayed.contains(&missing_key));
6174 assert!(displayed.contains("environment variable is not set"));
6175 }
6176
6177 #[test]
6178 fn smtp_password_env_error_never_embeds_the_secret_value() {
6179 let secret = "hunter2-super-secret-password";
6185 let error = std::env::VarError::NotUnicode(std::ffi::OsString::from(secret));
6186 assert!(error.to_string().contains(secret));
6189 assert!(format!("{error:?}").contains(secret));
6190
6191 let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
6192 let displayed = mail_error.to_string();
6193 let debugged = format!("{mail_error:?}");
6194 assert!(
6195 !displayed.contains(secret),
6196 "Display output leaked the SMTP password: {displayed}"
6197 );
6198 assert!(
6199 !debugged.contains(secret),
6200 "Debug output leaked the SMTP password: {debugged}"
6201 );
6202 assert!(displayed.contains("APP_SMTP_PASSWORD"));
6205 assert!(displayed.contains("environment variable contains non-unicode data"));
6206 }
6207
6208 #[test]
6209 fn smtp_password_env_error_redacts_missing_variable_details() {
6210 let error = std::env::VarError::NotPresent;
6211 let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
6212 let displayed = mail_error.to_string();
6213 assert!(displayed.contains("APP_SMTP_PASSWORD"));
6214 assert!(displayed.contains("environment variable is not set"));
6215 }
6216
6217 #[test]
6218 fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
6219 let Err(error) = SmtpTransport::new(
6220 SmtpConfig {
6221 host: Some("smtp.example.com".to_owned()),
6222 port: Some(587),
6223 username: Some("mailer".to_owned()),
6224 password_env: None,
6225 tls: TlsMode::StartTls,
6226 },
6227 None,
6228 ) else {
6229 panic!("missing password_env setting should fail at startup");
6230 };
6231
6232 assert!(error.to_string().contains("mail.smtp.password_env"));
6233 }
6234
6235 #[test]
6236 fn mailer_builder_rejects_invalid_default_from_address() {
6237 let Err(error) = Mailer::builder().from("not an email address").build() else {
6238 panic!("invalid default from should fail fast");
6239 };
6240
6241 match error {
6242 MailError::InvalidAddress { address, .. } => {
6243 assert_eq!(address, "not an email address");
6244 }
6245 other => panic!("expected invalid address error, got {other:?}"),
6246 }
6247 }
6248
6249 #[test]
6250 fn mailer_from_config_rejects_invalid_default_reply_to_address() {
6251 let config = MailConfig {
6252 transport: Transport::Smtp,
6253 from: Some("Autumn <noreply@example.com>".to_owned()),
6254 reply_to: Some("definitely not an address".to_owned()),
6255 smtp: SmtpConfig {
6256 host: Some("smtp.example.com".to_owned()),
6257 ..Default::default()
6258 },
6259 ..Default::default()
6260 };
6261
6262 let Err(error) = Mailer::from_config(&config) else {
6263 panic!("invalid configured reply-to should fail at construction");
6264 };
6265
6266 match error {
6267 MailError::InvalidAddress { address, .. } => {
6268 assert_eq!(address, "definitely not an address");
6269 }
6270 other => panic!("expected invalid address error, got {other:?}"),
6271 }
6272 }
6273
6274 #[test]
6275 fn try_deliver_later_returns_error_without_runtime() {
6276 let mailer = Mailer::builder().build().expect("mailer should build");
6277 let mail = Mail::builder()
6278 .to("user@example.com")
6279 .subject("Hello")
6280 .text("hello")
6281 .build()
6282 .expect("mail should build");
6283
6284 let error = mailer
6285 .try_deliver_later(mail)
6286 .expect_err("missing runtime should return an error");
6287
6288 assert!(error.to_string().contains("active Tokio runtime"));
6289 }
6290
6291 #[test]
6292 fn deliver_later_does_not_panic_without_runtime() {
6293 let mailer = Mailer::builder().build().expect("mailer should build");
6294 let mail = Mail::builder()
6295 .to("user@example.com")
6296 .subject("Hello")
6297 .text("hello")
6298 .build()
6299 .expect("mail should build");
6300
6301 mailer.deliver_later(mail);
6302 }
6303
6304 fn sample_smtp_config() -> MailConfig {
6305 MailConfig {
6306 transport: Transport::Smtp,
6307 from: Some("Autumn <noreply@example.com>".to_owned()),
6308 smtp: SmtpConfig {
6309 host: Some("smtp.example.com".to_owned()),
6310 ..Default::default()
6311 },
6312 ..Default::default()
6313 }
6314 }
6315
6316 fn sample_mail() -> Mail {
6317 Mail::builder()
6318 .to("user@example.com")
6319 .subject("Hi")
6320 .text("hello")
6321 .build()
6322 .expect("mail should build")
6323 }
6324
6325 struct NoopQueue;
6326
6327 impl MailDeliveryQueue for NoopQueue {
6328 fn enqueue<'a>(
6329 &'a self,
6330 _mail: Mail,
6331 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6332 Box::pin(async { Ok(()) })
6333 }
6334 }
6335
6336 #[test]
6337 fn install_mailer_rejects_in_process_fallback_in_prod_without_ack() {
6338 let state = crate::AppState::for_test().with_profile("prod");
6339 let config = sample_smtp_config();
6340
6341 let error = install_mailer(&state, &config, true)
6342 .expect_err("prod must reject in-process deliver_later fallback without ack");
6343
6344 let message = error.to_string();
6345 assert!(
6346 message.contains("allow_in_process_deliver_later_in_production"),
6347 "error should explain how to opt in: {message}"
6348 );
6349 }
6350
6351 #[test]
6352 fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
6353 let state = crate::AppState::for_test().with_profile("prod");
6354 let config = MailConfig {
6355 allow_in_process_deliver_later_in_production: true,
6356 ..sample_smtp_config()
6357 };
6358
6359 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
6360 }
6361
6362 #[test]
6363 fn install_mailer_allows_durable_queue_in_prod_without_ack() {
6364 let state = crate::AppState::for_test().with_profile("prod");
6365 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
6366 let config = sample_smtp_config();
6367
6368 install_mailer(&state, &config, true)
6369 .expect("a registered durable queue should satisfy the prod guard");
6370 }
6371
6372 #[test]
6373 fn install_mailer_does_not_require_ack_outside_production() {
6374 let state = crate::AppState::for_test().with_profile("dev");
6375 let config = sample_smtp_config();
6376
6377 install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
6378 }
6379
6380 #[test]
6381 fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
6382 let state = crate::AppState::for_test().with_profile("prod");
6383 let config = MailConfig::default();
6384
6385 install_mailer(&state, &config, true)
6386 .expect("disabled transport never sends mail so it should not need an ack");
6387 }
6388
6389 struct CapturingQueue {
6390 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
6391 }
6392
6393 impl MailDeliveryQueue for CapturingQueue {
6394 fn enqueue<'a>(
6395 &'a self,
6396 mail: Mail,
6397 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6398 let tx = self.tx.clone();
6399 Box::pin(async move {
6400 tx.send(mail)
6401 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
6402 Ok(())
6403 })
6404 }
6405 }
6406
6407 #[cfg(feature = "db")]
6408 struct FailingQueue {
6409 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
6410 }
6411
6412 #[cfg(feature = "db")]
6413 impl MailDeliveryQueue for FailingQueue {
6414 fn enqueue<'a>(
6415 &'a self,
6416 mail: Mail,
6417 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6418 let tx = self.tx.clone();
6419 Box::pin(async move {
6420 tx.send(mail)
6421 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
6422 Err(MailError::RuntimeUnavailable("queue offline".to_owned()))
6423 })
6424 }
6425 }
6426
6427 #[cfg(feature = "db")]
6428 async fn drain_after_commit_callbacks_for_test(
6429 registry: &std::sync::Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>>,
6430 ) {
6431 let callbacks: Vec<crate::db::CommitCallback> = {
6432 let mut reg = registry
6433 .lock()
6434 .unwrap_or_else(std::sync::PoisonError::into_inner);
6435 std::mem::take(&mut *reg)
6436 };
6437
6438 for cb in callbacks {
6439 if let Err(error) = cb().await {
6440 crate::db::record_after_commit_failure();
6441 tracing::error!("test drain: after_commit callback failed: {error}");
6442 }
6443 }
6444 }
6445
6446 #[cfg(feature = "db")]
6447 #[tokio::test]
6448 async fn deferred_deliver_later_queue_failure_increments_after_commit_counter() {
6449 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6450 let mailer = Mailer::builder()
6451 .delivery_queue(FailingQueue { tx })
6452 .build()
6453 .expect("mailer should build");
6454 let registry = std::sync::Arc::new(std::sync::Mutex::new(
6455 Vec::<crate::db::CommitCallback>::new(),
6456 ));
6457 let before =
6458 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
6459
6460 crate::db::AFTER_COMMIT_REGISTRY
6461 .scope(registry.clone(), async {
6462 mailer
6463 .try_deliver_later(sample_mail())
6464 .expect("registering deferred mail should succeed");
6465 })
6466 .await;
6467
6468 drain_after_commit_callbacks_for_test(®istry).await;
6469
6470 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6471 .await
6472 .expect("queue should be called within 1s")
6473 .expect("queue should receive the mail");
6474 assert_eq!(received.subject, "Hi");
6475
6476 let after =
6477 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
6478 assert!(
6479 after > before,
6480 "deferred durable mail handoff failures should count as after_commit failures"
6481 );
6482 }
6483
6484 #[tokio::test]
6485 async fn deliver_later_routes_through_configured_queue() {
6486 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6487
6488 let mailer = Mailer::builder()
6489 .delivery_queue(CapturingQueue { tx })
6490 .build()
6491 .expect("mailer should build");
6492
6493 mailer
6494 .try_deliver_later(sample_mail())
6495 .expect("scheduling onto the queue should succeed");
6496
6497 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6498 .await
6499 .expect("queue should receive within 1s")
6500 .expect("queue should receive the mail");
6501
6502 assert_eq!(received.subject, "Hi");
6503 }
6504
6505 #[tokio::test]
6506 async fn deliver_later_preserves_attachments_through_queue() {
6507 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6508
6509 let mailer = Mailer::builder()
6510 .delivery_queue(CapturingQueue { tx })
6511 .build()
6512 .expect("mailer should build");
6513
6514 let mail = Mail::builder()
6515 .to("user@example.com")
6516 .subject("Hi")
6517 .text("hello")
6518 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
6519 .build()
6520 .expect("mail should build");
6521
6522 mailer
6523 .try_deliver_later(mail.clone())
6524 .expect("scheduling onto the queue should succeed");
6525
6526 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6527 .await
6528 .expect("queue should receive within 1s")
6529 .expect("queue should receive the mail");
6530
6531 let mut expected = mail;
6536 expected.inline_css = Some(false);
6537 assert_eq!(received, expected);
6538 }
6539
6540 #[tokio::test]
6541 async fn deferred_enqueue_freezes_originating_inline_css_default() {
6542 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6549 let mailer = Mailer::builder()
6550 .inline_css(true)
6551 .delivery_queue(CapturingQueue { tx })
6552 .build()
6553 .expect("mailer should build");
6554
6555 let mail = Mail::builder()
6556 .to("user@example.com")
6557 .subject("Hi")
6558 .html(
6559 "<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
6560 )
6561 .build()
6562 .expect("mail should build");
6563 assert_eq!(mail.inline_css, None, "sample relies on the mailer default");
6564
6565 mailer
6566 .try_deliver_later(mail)
6567 .expect("scheduling onto the queue should succeed");
6568
6569 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6570 .await
6571 .expect("queue should receive within 1s")
6572 .expect("queue should receive the mail");
6573
6574 assert_eq!(
6575 received.inline_css,
6576 Some(true),
6577 "the originating mailer's inlining default must be frozen onto the enqueued job"
6578 );
6579 assert!(
6580 received
6581 .html
6582 .as_deref()
6583 .expect("html body")
6584 .contains("<style>"),
6585 "the body must be left un-inlined at enqueue time; inlining happens once at the consumer's send()"
6586 );
6587 }
6588
6589 #[tokio::test]
6590 async fn deferred_enqueue_preserves_explicit_inline_css_override() {
6591 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6594 let mailer = Mailer::builder()
6595 .inline_css(true)
6596 .delivery_queue(CapturingQueue { tx })
6597 .build()
6598 .expect("mailer should build");
6599
6600 let mail = Mail::builder()
6601 .to("user@example.com")
6602 .subject("Hi")
6603 .html(
6604 "<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
6605 )
6606 .inline_css(false)
6607 .build()
6608 .expect("mail should build");
6609
6610 mailer
6611 .try_deliver_later(mail)
6612 .expect("scheduling onto the queue should succeed");
6613
6614 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
6615 .await
6616 .expect("queue should receive within 1s")
6617 .expect("queue should receive the mail");
6618
6619 assert_eq!(
6620 received.inline_css,
6621 Some(false),
6622 "an explicit per-message override must be preserved through the queue, not overwritten by the mailer default"
6623 );
6624 }
6625
6626 #[tokio::test]
6627 async fn deliver_later_without_queue_sends_via_transport_directly() {
6628 use std::sync::Arc;
6631 use std::sync::atomic::{AtomicBool, Ordering};
6632
6633 struct TrackingSend(Arc<AtomicBool>);
6634 impl MailTransport for TrackingSend {
6635 fn send<'a>(
6636 &'a self,
6637 _mail: Mail,
6638 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6639 self.0.store(true, Ordering::SeqCst);
6640 Box::pin(async { Ok(()) })
6641 }
6642 }
6643
6644 let sent = Arc::new(AtomicBool::new(false));
6645 let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
6646
6647 mailer
6648 .try_deliver_later(sample_mail())
6649 .expect("should succeed without queue");
6650
6651 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6652 assert!(
6653 sent.load(Ordering::SeqCst),
6654 "mail should have been sent directly via transport"
6655 );
6656 }
6657
6658 #[cfg(feature = "db")]
6659 #[tokio::test]
6660 async fn deferred_deliver_later_without_queue_sends_after_commit() {
6661 use std::sync::Arc;
6664 use std::sync::atomic::{AtomicBool, Ordering};
6665
6666 struct TrackingSend(Arc<AtomicBool>);
6667 impl MailTransport for TrackingSend {
6668 fn send<'a>(
6669 &'a self,
6670 _mail: Mail,
6671 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6672 self.0.store(true, Ordering::SeqCst);
6673 Box::pin(async { Ok(()) })
6674 }
6675 }
6676
6677 let sent = Arc::new(AtomicBool::new(false));
6678 let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
6679 let registry = std::sync::Arc::new(std::sync::Mutex::new(
6680 Vec::<crate::db::CommitCallback>::new(),
6681 ));
6682
6683 crate::db::AFTER_COMMIT_REGISTRY
6684 .scope(registry.clone(), async {
6685 mailer
6686 .try_deliver_later(sample_mail())
6687 .expect("should succeed");
6688 })
6689 .await;
6690
6691 drain_after_commit_callbacks_for_test(®istry).await;
6692 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
6693
6694 assert!(
6695 sent.load(Ordering::SeqCst),
6696 "mail should have been sent after commit via direct transport"
6697 );
6698 }
6699
6700 #[tokio::test]
6701 async fn mailer_with_transport_starts_without_delivery_queue() {
6702 let mailer = Mailer::with_transport(NoopTransport);
6703 assert!(
6704 !mailer.has_durable_delivery_queue(),
6705 "with_transport should default to no durable queue"
6706 );
6707 mailer
6709 .send(sample_mail())
6710 .await
6711 .expect("noop transport should always succeed");
6712 }
6713
6714 struct NoopTransport;
6715 impl MailTransport for NoopTransport {
6716 fn send<'a>(
6717 &'a self,
6718 _mail: Mail,
6719 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6720 Box::pin(async { Ok(()) })
6721 }
6722 }
6723
6724 #[tokio::test]
6725 async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
6726 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
6731 let mailer = Mailer::builder()
6732 .transport(Transport::Disabled)
6733 .delivery_queue(CapturingQueue { tx })
6734 .build()
6735 .expect("mailer should build");
6736
6737 mailer
6738 .try_deliver_later(sample_mail())
6739 .expect("disabled transport should succeed as a no-op");
6740
6741 let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
6743 assert!(
6744 received.is_err(),
6745 "queue must not be invoked when transport is disabled"
6746 );
6747 }
6748
6749 #[tokio::test]
6750 async fn deliver_later_uses_in_process_fallback_when_no_queue() {
6751 let mailer = Mailer::builder().build().expect("mailer should build");
6754
6755 mailer
6756 .try_deliver_later(sample_mail())
6757 .expect("in-process fallback should still schedule");
6758 }
6759
6760 #[test]
6761 fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
6762 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
6763 let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
6764
6765 assert!(Arc::ptr_eq(handle.inner(), &arc));
6766 }
6767
6768 #[test]
6769 fn mail_delivery_queue_handle_debug_does_not_panic() {
6770 let handle = MailDeliveryQueueHandle::new(NoopQueue);
6771 let rendered = format!("{handle:?}");
6772 assert!(rendered.contains("MailDeliveryQueueHandle"));
6773 }
6774
6775 #[test]
6776 fn mailer_has_durable_delivery_queue_reflects_attachment() {
6777 let plain = Mailer::builder().build().expect("mailer should build");
6778 assert!(!plain.has_durable_delivery_queue());
6779
6780 let with_queue = Mailer::builder()
6781 .delivery_queue(NoopQueue)
6782 .build()
6783 .expect("mailer should build");
6784 assert!(with_queue.has_durable_delivery_queue());
6785 }
6786
6787 #[test]
6788 fn mailer_with_delivery_queue_post_build_attaches_queue() {
6789 let mailer = Mailer::builder()
6790 .build()
6791 .expect("mailer should build")
6792 .with_delivery_queue(NoopQueue);
6793
6794 assert!(mailer.has_durable_delivery_queue());
6795 }
6796
6797 #[test]
6798 fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
6799 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
6800 let mailer = Mailer::builder()
6801 .delivery_queue_arc(arc)
6802 .build()
6803 .expect("mailer should build");
6804
6805 assert!(mailer.has_durable_delivery_queue());
6806 }
6807
6808 #[test]
6809 fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
6810 let state = crate::AppState::for_test().with_profile("prod");
6813 let config = MailConfig {
6814 allow_in_process_deliver_later_in_production: true,
6815 ..sample_smtp_config()
6816 };
6817
6818 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
6819
6820 let installed = state
6821 .extension::<Mailer>()
6822 .expect("install_mailer should store a Mailer extension");
6823 assert!(
6824 !installed.has_durable_delivery_queue(),
6825 "no queue was registered, so installed mailer should fall back in-process"
6826 );
6827 }
6828
6829 #[test]
6830 fn install_mailer_attaches_registered_queue_to_mailer() {
6831 let state = crate::AppState::for_test().with_profile("prod");
6832 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
6833 let config = sample_smtp_config();
6834
6835 install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
6836
6837 let installed = state
6838 .extension::<Mailer>()
6839 .expect("install_mailer should store a Mailer extension");
6840 assert!(
6841 installed.has_durable_delivery_queue(),
6842 "registered queue handle should be attached to the installed mailer"
6843 );
6844 }
6845
6846 #[test]
6847 fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
6848 let state = crate::AppState::for_test().with_profile("prod");
6849 let config = sample_smtp_config();
6850 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
6851 let captured = Arc::clone(&factory_called);
6852
6853 let factory = move |_state: &crate::AppState| {
6854 captured.store(true, std::sync::atomic::Ordering::SeqCst);
6855 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
6856 };
6857
6858 install_mailer_with_factory(&state, &config, Some(factory), true)
6859 .expect("factory should produce a queue and satisfy the prod guard");
6860
6861 assert!(
6862 factory_called.load(std::sync::atomic::Ordering::SeqCst),
6863 "factory must run when enforce_durable_guard is true"
6864 );
6865 let installed = state
6866 .extension::<Mailer>()
6867 .expect("install_mailer should store a Mailer extension");
6868 assert!(
6869 installed.has_durable_delivery_queue(),
6870 "factory's queue should be wired into the installed Mailer"
6871 );
6872 }
6873
6874 #[test]
6875 fn install_mailer_with_factory_skips_factory_when_not_enforced() {
6876 let state = crate::AppState::for_test().with_profile("prod");
6877 let config = sample_smtp_config();
6878 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
6879 let captured = Arc::clone(&factory_called);
6880
6881 let factory = move |_state: &crate::AppState| {
6882 captured.store(true, std::sync::atomic::Ordering::SeqCst);
6883 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
6884 };
6885
6886 install_mailer_with_factory(&state, &config, Some(factory), false)
6887 .expect("static-build path should skip factory and install cleanly");
6888
6889 assert!(
6890 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
6891 "factory must be skipped when enforce_durable_guard is false"
6892 );
6893 }
6894
6895 #[test]
6896 fn install_mailer_with_factory_propagates_factory_errors() {
6897 let state = crate::AppState::for_test().with_profile("prod");
6898 let config = sample_smtp_config();
6899
6900 let factory = |_state: &crate::AppState| {
6901 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
6902 "queue offline",
6903 ))
6904 };
6905
6906 let error = install_mailer_with_factory(&state, &config, Some(factory), true)
6907 .expect_err("factory error should propagate");
6908 assert!(error.to_string().contains("queue offline"));
6909 }
6910
6911 #[test]
6912 fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
6913 let state = crate::AppState::for_test().with_profile("dev");
6918 let config = MailConfig::default(); let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
6920 let captured = Arc::clone(&factory_called);
6921
6922 let factory = move |_state: &crate::AppState| {
6923 captured.store(true, std::sync::atomic::Ordering::SeqCst);
6924 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
6925 "queue must not be reached",
6926 ))
6927 };
6928
6929 install_mailer_with_factory(&state, &config, Some(factory), true)
6930 .expect("disabled transport should bypass the factory entirely");
6931 assert!(
6932 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
6933 "factory must not run when transport = disabled"
6934 );
6935 }
6936
6937 #[test]
6938 fn install_mailer_with_factory_works_without_factory() {
6939 type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
6940 let state = crate::AppState::for_test().with_profile("dev");
6941 let config = sample_smtp_config();
6942 let no_factory: Option<FactoryFn> = None;
6943
6944 install_mailer_with_factory(&state, &config, no_factory, true)
6945 .expect("absent factory should be fine in non-prod");
6946 }
6947
6948 #[test]
6949 fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
6950 let state = crate::AppState::for_test().with_profile("prod");
6954 let config = sample_smtp_config();
6955
6956 install_mailer(&state, &config, false)
6957 .expect("static-build mode should install cleanly with no queue handle");
6958
6959 let installed = state
6960 .extension::<Mailer>()
6961 .expect("install_mailer should store a Mailer extension");
6962 assert!(
6963 !installed.has_durable_delivery_queue(),
6964 "no queue is expected when run_build_mode skips the factory"
6965 );
6966 }
6967
6968 #[test]
6969 fn install_mailer_skips_production_guard_when_not_enforced() {
6970 let state = crate::AppState::for_test().with_profile("prod");
6976 let config = sample_smtp_config();
6977
6978 install_mailer(&state, &config, false)
6979 .expect("static-build mode should not enforce the deliver_later guard");
6980 }
6981
6982 #[test]
6983 fn spawn_mail_delivery_inherits_parent_span() {
6984 use std::future::Future;
6985 use std::pin::Pin;
6986 use std::sync::{Arc, Mutex};
6987
6988 struct CapturingQueue(Arc<Mutex<Option<tracing::span::Id>>>);
6989 impl MailDeliveryQueue for CapturingQueue {
6990 fn enqueue<'a>(
6991 &'a self,
6992 _mail: Mail,
6993 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6994 let captured = self.0.clone();
6995 Box::pin(async move {
6996 *captured.lock().unwrap() = tracing::Span::current().id();
6997 Ok(())
6998 })
6999 }
7000 }
7001
7002 let captured_span_id: Arc<Mutex<Option<tracing::span::Id>>> = Arc::new(Mutex::new(None));
7003
7004 let mailer = Mailer::builder()
7005 .delivery_queue(CapturingQueue(captured_span_id.clone()))
7006 .build()
7007 .expect("mailer with queue should build");
7008 let mail = sample_mail();
7009
7010 tracing::subscriber::with_default(tracing_subscriber::registry(), || {
7015 let rt = tokio::runtime::Builder::new_current_thread()
7016 .enable_all()
7017 .build()
7018 .expect("build runtime");
7019
7020 let outer = tracing::info_span!("deliver_later_outer");
7021 let outer_id = outer.id();
7022
7023 rt.block_on(async {
7024 {
7025 let _guard = outer.enter();
7026 mailer
7027 .try_deliver_later(mail)
7028 .expect("deliver_later must not fail");
7029 }
7030
7031 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7032 });
7033
7034 let in_task = captured_span_id.lock().unwrap().clone();
7035 assert_eq!(
7036 in_task, outer_id,
7037 "delivery task must run inside the span that called deliver_later"
7038 );
7039 });
7040 }
7041
7042 #[tokio::test]
7043 async fn spawn_mail_delivery_logs_error_when_queue_fails() {
7044 use std::future::Future;
7045 use std::pin::Pin;
7046
7047 struct AlwaysFailQueue;
7048 impl MailDeliveryQueue for AlwaysFailQueue {
7049 fn enqueue<'a>(
7050 &'a self,
7051 _mail: Mail,
7052 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7053 Box::pin(async { Err(MailError::RuntimeUnavailable("always fails".to_owned())) })
7054 }
7055 }
7056
7057 let mailer = Mailer::builder()
7058 .delivery_queue(AlwaysFailQueue)
7059 .build()
7060 .expect("build");
7061
7062 mailer
7063 .try_deliver_later(sample_mail())
7064 .expect("should schedule");
7065
7066 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7067 }
7068
7069 #[tokio::test]
7070 async fn spawn_mail_delivery_logs_error_when_transport_fails() {
7071 use std::future::Future;
7072 use std::pin::Pin;
7073
7074 struct AlwaysFailTransport;
7075 impl MailTransport for AlwaysFailTransport {
7076 fn send<'a>(
7077 &'a self,
7078 _mail: Mail,
7079 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7080 Box::pin(async {
7081 Err(MailError::RuntimeUnavailable(
7082 "transport offline".to_owned(),
7083 ))
7084 })
7085 }
7086 }
7087
7088 let mailer = Mailer::with_transport(AlwaysFailTransport);
7089
7090 mailer
7091 .try_deliver_later(sample_mail())
7092 .expect("should schedule");
7093
7094 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7095 }
7096
7097 #[test]
7098 fn install_mailer_does_not_attach_queue_when_transport_disabled() {
7099 let state = crate::AppState::for_test().with_profile("dev");
7104 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
7105 let config = MailConfig::default(); install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
7108
7109 let installed = state
7110 .extension::<Mailer>()
7111 .expect("install_mailer should store a Mailer extension");
7112 assert!(
7113 !installed.has_durable_delivery_queue(),
7114 "disabled transport must suppress queue attachment so deliver_later is a no-op"
7115 );
7116 }
7117
7118 #[tokio::test]
7119 async fn intercepted_mail_transport_short_circuit_prevents_sync_execution() {
7120 use std::future::Future;
7121 use std::pin::Pin;
7122 use std::sync::atomic::{AtomicU32, Ordering};
7123
7124 static TRANSPORT_CALLS: AtomicU32 = AtomicU32::new(0);
7125
7126 struct CountingTransport;
7127 impl MailTransport for CountingTransport {
7128 fn send<'a>(
7129 &'a self,
7130 _mail: Mail,
7131 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7132 TRANSPORT_CALLS.fetch_add(1, Ordering::SeqCst);
7133 Box::pin(async move { Ok(()) })
7134 }
7135
7136 fn is_disabled(&self) -> bool {
7137 false
7138 }
7139 }
7140
7141 struct ShortCircuitMailInterceptor;
7142 impl crate::interceptor::MailInterceptor for ShortCircuitMailInterceptor {
7143 fn intercept<'a>(
7144 &'a self,
7145 _mail: &'a Mail,
7146 _next: Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>,
7147 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7148 Box::pin(async move {
7149 Err(MailError::RuntimeUnavailable(
7150 "blocked by interceptor".to_owned(),
7151 ))
7152 })
7153 }
7154 }
7155
7156 let transport = Arc::new(CountingTransport);
7157 let interceptor = Arc::new(ShortCircuitMailInterceptor);
7158 let intercepted = InterceptedMailTransport {
7159 inner: transport,
7160 interceptor,
7161 };
7162
7163 let mail = Mail::builder()
7164 .to("test@example.com")
7165 .subject("test")
7166 .text("body")
7167 .build()
7168 .unwrap();
7169
7170 TRANSPORT_CALLS.store(0, Ordering::SeqCst);
7171
7172 let res = intercepted.send(mail).await;
7173 assert!(res.is_err());
7174 assert_eq!(TRANSPORT_CALLS.load(Ordering::SeqCst), 0);
7175 }
7176
7177 #[tokio::test]
7178 #[allow(clippy::await_holding_lock)]
7179 async fn test_smtp_transport_circuit_breaker() {
7180 let _lock = crate::circuit_breaker::TEST_LOCK
7181 .lock()
7182 .unwrap_or_else(std::sync::PoisonError::into_inner);
7183 crate::circuit_breaker::global_registry().clear();
7184 let policy = crate::circuit_breaker::CircuitBreakerPolicy {
7185 failure_ratio_threshold: 0.5,
7186 sample_window: std::time::Duration::from_secs(10),
7187 minimum_sample_count: 3,
7188 open_duration: std::time::Duration::from_secs(60),
7189 half_open_trial_count: 2,
7190 };
7191 let breaker =
7192 crate::circuit_breaker::global_registry().get_or_create("smtp_mailer", policy);
7193
7194 assert_eq!(
7196 breaker.state(),
7197 crate::circuit_breaker::CircuitState::Closed
7198 );
7199
7200 let config = SmtpConfig {
7202 host: Some("127.0.0.1".to_string()),
7203 port: Some(9999), tls: TlsMode::Disabled,
7205 username: None,
7206 password_env: None,
7207 };
7208 let transport = SmtpTransport::new(config, None).unwrap();
7209
7210 let mail = Mail::builder()
7211 .from("sender@example.com")
7212 .to("test@example.com")
7213 .subject("test")
7214 .text("body")
7215 .build()
7216 .unwrap();
7217
7218 for _ in 0..3 {
7220 let res = transport.send(mail.clone()).await;
7221 assert!(res.is_err());
7222 }
7223
7224 assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
7225
7226 let res = transport.send(mail.clone()).await;
7228 assert!(res.is_err());
7229 let err_str = res.err().unwrap().to_string();
7230 assert!(
7231 err_str.contains("circuit breaker")
7232 || err_str.contains("open")
7233 || err_str.contains("Open")
7234 || err_str.contains("runtime unavailable")
7235 );
7236
7237 crate::circuit_breaker::global_registry().clear();
7238 }
7239
7240 #[test]
7241 fn validate_log_transport_in_prod_fails() {
7242 let cfg = MailConfig {
7243 transport: Transport::Log,
7244 ..MailConfig::default()
7245 };
7246 assert!(cfg.validate(Some("prod")).is_err());
7247 assert!(cfg.validate(Some("production")).is_err());
7248 let allowed = MailConfig {
7250 transport: Transport::Log,
7251 allow_log_in_production: true,
7252 ..MailConfig::default()
7253 };
7254 assert!(allowed.validate(Some("prod")).is_ok());
7255 }
7256
7257 #[test]
7258 fn validate_preview_outside_dev_fails() {
7259 let cfg = MailConfig {
7260 preview: true,
7261 ..MailConfig::default()
7262 };
7263 assert!(cfg.validate(Some("prod")).is_err());
7264 assert!(cfg.validate(Some("dev")).is_ok());
7265 assert!(cfg.validate(Some("development")).is_ok());
7266 }
7267
7268 #[test]
7269 fn is_valid_https_base_url_edge_cases() {
7270 assert!(is_valid_https_base_url("https://app.example.com"));
7271 assert!(is_valid_https_base_url("https://app.example.com/base"));
7272 assert!(!is_valid_https_base_url("http://app.example.com"));
7273 assert!(!is_valid_https_base_url("https://"));
7274 assert!(!is_valid_https_base_url("https:///path"));
7275 assert!(!is_valid_https_base_url("https://app.example.com?q=1"));
7276 assert!(!is_valid_https_base_url("https://app.example.com#frag"));
7277 assert!(!is_valid_https_base_url("https://host name.com"));
7278 assert!(!is_valid_https_base_url("https://app.example.com:abc"));
7280 assert!(!is_valid_https_base_url("https://@/base"));
7281 assert!(!is_valid_https_base_url("https://user@app.example.com"));
7282 assert!(is_valid_https_base_url("https://app.example.com:8443"));
7284 assert!(!is_valid_https_base_url("https://example.com/<x>"));
7287 assert!(!is_valid_https_base_url("https://example.com/a b"));
7288 assert!(!is_valid_https_base_url("https://example.com/a\r\nb"));
7289 assert!(!is_valid_https_base_url("https:/app.example.com"));
7292 assert!(!is_valid_https_base_url("https:app.example.com"));
7293 }
7294
7295 #[test]
7296 fn is_valid_mailto_address_edge_cases() {
7297 assert!(is_valid_mailto_address("unsub@example.com"));
7298 assert!(is_valid_mailto_address("mailto:unsub@example.com"));
7299 assert!(is_valid_mailto_address(
7300 "mailto:unsub@example.com?subject=hi"
7301 ));
7302 assert!(!is_valid_mailto_address("not-an-email"));
7303 assert!(!is_valid_mailto_address("missing@dot"));
7304 assert!(!is_valid_mailto_address("space @example.com"));
7305 assert!(!is_valid_mailto_address(""));
7306 assert!(!is_valid_mailto_address("@example.com")); assert!(!is_valid_mailto_address("local@")); assert!(!is_valid_mailto_address("https://unsub@example.com"));
7310 assert!(!is_valid_mailto_address("mailto:https://unsub@example.com"));
7311 assert!(!is_valid_mailto_address("unsub@https://example.com"));
7312 assert!(!is_valid_mailto_address(
7315 "mailto:unsub@example.com?subject=x\r\nBcc: victim@example.com"
7316 ));
7317 assert!(!is_valid_mailto_address("unsub@example.com\nBcc: v@x.com"));
7318 assert!(!is_valid_mailto_address(
7321 "unsub@example.com>,<bogus@example.com"
7322 ));
7323 assert!(!is_valid_mailto_address("a@x.com,b@x.com"));
7324 }
7325
7326 #[test]
7327 fn unsubscribe_header_mailto_drops_configured_query_no_injection() {
7328 let runtime = UnsubscribeRuntime {
7332 base_url: None,
7333 mailto: Some("mailto:u@example.com?subject=x\r\nBcc: v@x.com".to_owned()),
7334 signing_keys: Arc::new(test_keys()),
7335 ttl_days: 30,
7336 suppression: None,
7337 };
7338 let header = runtime
7339 .list_unsubscribe_header("a@x.com", "list")
7340 .expect("mailto header");
7341 assert_eq!(header, "<mailto:u@example.com?subject=unsubscribe>");
7342 assert!(!header.contains('\r') && !header.contains('\n'));
7343 assert!(!header.contains("Bcc"));
7344 }
7345
7346 #[test]
7347 fn unsubscribe_runtime_header_both_base_url_and_mailto() {
7348 let runtime = UnsubscribeRuntime {
7349 base_url: Some("https://app.example.com".to_owned()),
7350 mailto: Some("u@example.com".to_owned()),
7351 signing_keys: Arc::new(test_keys()),
7352 ttl_days: 30,
7353 suppression: None,
7354 };
7355 let header = runtime
7356 .list_unsubscribe_header("a@x.com", "list")
7357 .expect("header with both");
7358 assert!(header.contains("https://app.example.com/_autumn/unsubscribe?token="));
7359 assert!(header.contains("mailto:u@example.com?subject=unsubscribe"));
7360 assert!(runtime.supports_one_click());
7361 }
7362
7363 #[test]
7364 fn unsubscribe_runtime_header_neither_configured_returns_none() {
7365 let runtime = UnsubscribeRuntime {
7366 base_url: None,
7367 mailto: None,
7368 signing_keys: Arc::new(test_keys()),
7369 ttl_days: 30,
7370 suppression: None,
7371 };
7372 assert!(runtime.list_unsubscribe_header("a@x.com", "list").is_none());
7373 assert!(!runtime.supports_one_click());
7374 }
7375}