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 clippy::string_slice,
21 clippy::arithmetic_side_effects,
22 )
23)]
24
25use std::future::Future;
26use std::path::{Path, PathBuf};
27use std::pin::Pin;
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use axum::extract::FromRequestParts;
33use axum::response::{Html, IntoResponse, Response};
34use lettre::message::header::{ContentTransferEncoding, ContentType};
35use lettre::message::{
36 Attachment as LettreAttachment, Body as LettreBody, Mailbox, MultiPart, SinglePart,
37};
38use lettre::transport::smtp::authentication::Credentials;
39use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43use crate::{AppState, AutumnError, AutumnResult};
44
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum Transport {
49 Log,
51 File,
53 Smtp,
55 #[default]
57 Disabled,
58}
59
60impl Transport {
61 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
62 match value.trim().to_ascii_lowercase().as_str() {
63 "log" => Some(Self::Log),
64 "file" => Some(Self::File),
65 "smtp" => Some(Self::Smtp),
66 "disabled" => Some(Self::Disabled),
67 _ => None,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum TlsMode {
76 Disabled,
78 #[default]
80 StartTls,
81 Tls,
83}
84
85impl TlsMode {
86 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
87 match value.trim().to_ascii_lowercase().as_str() {
88 "disabled" => Some(Self::Disabled),
89 "starttls" | "start_tls" => Some(Self::StartTls),
90 "tls" => Some(Self::Tls),
91 _ => None,
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
98pub struct SmtpConfig {
99 #[serde(default)]
101 pub host: Option<String>,
102 #[serde(default)]
104 pub port: Option<u16>,
105 #[serde(default)]
107 pub username: Option<String>,
108 #[serde(default)]
110 pub password_env: Option<String>,
111 #[serde(default)]
113 pub tls: TlsMode,
114}
115
116impl Default for SmtpConfig {
117 fn default() -> Self {
118 Self {
119 host: None,
120 port: None,
121 username: None,
122 password_env: None,
123 tls: TlsMode::StartTls,
124 }
125 }
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
130#[allow(clippy::struct_excessive_bools)] pub struct MailConfig {
132 #[serde(default)]
134 pub transport: Transport,
135 #[serde(default)]
137 pub from: Option<String>,
138 #[serde(default)]
140 pub reply_to: Option<String>,
141 #[serde(default)]
143 pub allow_log_in_production: bool,
144 #[serde(default)]
148 pub allow_in_process_deliver_later_in_production: bool,
149 #[serde(default = "default_file_dir")]
151 pub file_dir: PathBuf,
152 #[serde(default)]
157 pub preview: bool,
158 #[serde(default)]
163 pub unsubscribe_base_url: Option<String>,
164 #[serde(default)]
167 pub unsubscribe_mailto: Option<String>,
168 #[serde(default = "default_unsubscribe_ttl_days")]
170 pub unsubscribe_token_ttl_days: i64,
171 #[serde(default)]
176 pub mount_unsubscribe_endpoint: bool,
177 #[serde(default)]
187 pub inline_css: bool,
188 #[serde(default)]
190 pub smtp: SmtpConfig,
191}
192
193fn is_valid_https_base_url(url: &str) -> bool {
198 if url
204 .chars()
205 .any(|c| c.is_control() || c.is_whitespace() || matches!(c, '<' | '>'))
206 {
207 return false;
208 }
209 match url.strip_prefix("https://") {
216 Some(rest) if !rest.is_empty() && !rest.starts_with('/') => {}
217 _ => return false,
218 }
219 let Ok(parsed) = ::url::Url::parse(url) else {
220 return false;
221 };
222 parsed.scheme() == "https"
228 && parsed.host_str().is_some_and(|h| !h.is_empty())
229 && parsed.username().is_empty()
230 && parsed.password().is_none()
231 && parsed.query().is_none()
232 && parsed.fragment().is_none()
233}
234
235fn is_valid_mailto_address(value: &str) -> bool {
238 if value
244 .chars()
245 .any(|c| c.is_control() || matches!(c, '<' | '>' | ','))
246 {
247 return false;
248 }
249 let address = value
250 .trim()
251 .strip_prefix("mailto:")
252 .unwrap_or_else(|| value.trim());
253 let address = address.split('?').next().unwrap_or("");
255 match address.split_once('@') {
256 Some((local, domain)) => {
257 !local.is_empty()
258 && !domain.is_empty()
259 && domain.contains('.')
260 && !address.contains(char::is_whitespace)
261 && !address.contains([':', '/'])
265 }
266 None => false,
267 }
268}
269
270const fn default_unsubscribe_ttl_days() -> i64 {
271 crate::mail::unsubscribe::DEFAULT_TOKEN_TTL_DAYS
272}
273
274impl Default for MailConfig {
275 fn default() -> Self {
276 Self {
277 transport: Transport::Disabled,
278 from: None,
279 reply_to: None,
280 allow_log_in_production: false,
281 allow_in_process_deliver_later_in_production: false,
282 file_dir: default_file_dir(),
283 preview: false,
284 unsubscribe_base_url: None,
285 unsubscribe_mailto: None,
286 unsubscribe_token_ttl_days: default_unsubscribe_ttl_days(),
287 mount_unsubscribe_endpoint: false,
288 inline_css: false,
289 smtp: SmtpConfig::default(),
290 }
291 }
292}
293
294impl MailConfig {
295 pub fn validate(&self, profile: Option<&str>) -> Result<(), crate::config::ConfigError> {
302 if matches!(profile, Some("prod" | "production"))
303 && self.transport == Transport::Log
304 && !self.allow_log_in_production
305 {
306 return Err(crate::config::ConfigError::Validation(
307 "mail.transport = \"log\" is disabled in prod; set mail.allow_log_in_production = true to acknowledge this explicitly".to_owned(),
308 ));
309 }
310
311 if self.transport == Transport::Smtp
312 && self.smtp.host.as_deref().map_or("", str::trim).is_empty()
313 {
314 return Err(crate::config::ConfigError::Validation(
315 "mail.smtp.host is required when mail.transport = \"smtp\"".to_owned(),
316 ));
317 }
318
319 if self.preview && !matches!(profile, Some("dev" | "development")) {
320 return Err(crate::config::ConfigError::Validation(
321 "mail.preview = true is only allowed in dev; refusing to mount /_autumn/mail outside the dev profile".to_owned(),
322 ));
323 }
324
325 if self.unsubscribe_token_ttl_days <= 0 {
326 return Err(crate::config::ConfigError::Validation(
327 "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(),
328 ));
329 }
330
331 if matches!(profile, Some("prod" | "production"))
332 && let Some(base) = self.unsubscribe_base_url.as_deref().map(str::trim)
333 && !base.is_empty()
334 && !is_valid_https_base_url(base)
335 {
336 return Err(crate::config::ConfigError::Validation(
337 "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(),
338 ));
339 }
340
341 if matches!(profile, Some("prod" | "production"))
342 && let Some(mailto) = self.unsubscribe_mailto.as_deref().map(str::trim)
343 && !mailto.is_empty()
344 && !is_valid_mailto_address(mailto)
345 {
346 return Err(crate::config::ConfigError::Validation(
347 "mail.unsubscribe_mailto must be a bare mailbox address (or mailto: URI) like unsubscribe@example.com".to_owned(),
348 ));
349 }
350
351 Ok(())
352 }
353
354 pub(crate) fn preview_routes_enabled(&self, profile: Option<&str>) -> bool {
355 matches!(profile, Some("dev" | "development"))
356 && (self.preview || self.transport == Transport::File)
357 }
358
359 pub(crate) fn unsubscribe_base_url_set(&self) -> bool {
362 self.unsubscribe_base_url
363 .as_deref()
364 .is_some_and(|s| !s.trim().is_empty())
365 }
366
367 pub(crate) fn should_mount_unsubscribe_endpoint(&self) -> bool {
371 self.mount_unsubscribe_endpoint && self.unsubscribe_base_url_set()
372 }
373}
374
375fn default_file_dir() -> PathBuf {
376 PathBuf::from("target/mail")
377}
378
379pub trait IntoMailBody {
381 fn into_mail_body(self) -> String;
383}
384
385impl IntoMailBody for String {
386 fn into_mail_body(self) -> String {
387 self
388 }
389}
390
391impl IntoMailBody for &str {
392 fn into_mail_body(self) -> String {
393 self.to_owned()
394 }
395}
396
397impl IntoMailBody for maud::Markup {
398 fn into_mail_body(self) -> String {
399 self.into_string()
400 }
401}
402
403pub const MAIL_LAYOUT_CONTENT_MARKER: &str = "{{ content }}";
409
410#[must_use]
416pub fn compose_layout(layout: &str, body: &str) -> String {
417 if layout.contains(MAIL_LAYOUT_CONTENT_MARKER) {
418 layout.replace(MAIL_LAYOUT_CONTENT_MARKER, body)
419 } else {
420 body.to_owned()
421 }
422}
423
424fn html_contains_style_block(html: &str) -> bool {
428 html.as_bytes()
429 .windows(6)
430 .any(|window| window.eq_ignore_ascii_case(b"<style"))
431}
432
433fn html_is_full_document(html: &str) -> bool {
440 let bytes = html.as_bytes();
441 bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<html"))
442 || bytes.windows(5).any(|w| w.eq_ignore_ascii_case(b"<body"))
443 || bytes
444 .windows(9)
445 .any(|w| w.eq_ignore_ascii_case(b"<!doctype"))
446}
447
448fn unwrap_synthetic_document(doc: &str) -> String {
466 let inner = doc
469 .strip_prefix("<html>")
470 .and_then(|rest| rest.strip_suffix("</html>"))
471 .unwrap_or(doc);
472 let (head, after_head) = match inner.strip_prefix("<head>") {
473 Some(rest) => match rest.split_once("</head>") {
474 Some(split) => split,
475 None => return doc.to_owned(),
477 },
478 None => ("", inner),
479 };
480 let body = after_head
481 .strip_prefix("<body>")
482 .map_or(after_head, |rest| {
483 rest.strip_suffix("</body>").unwrap_or(rest)
484 });
485 format!("{head}{body}")
486}
487
488fn inline_css_html(html: &str) -> Result<String, MailError> {
516 if !html_contains_style_block(html) {
519 return Ok(html.to_owned());
520 }
521 let inliner = css_inline::CSSInliner::options()
522 .keep_style_tags(true)
524 .keep_at_rules(true)
527 .remove_inlined_selectors(true)
530 .load_remote_stylesheets(false)
532 .keep_link_tags(true)
536 .apply_width_attributes(true)
541 .apply_height_attributes(true)
542 .build();
543 let is_fragment = !html_is_full_document(html);
549 let rendered = inliner
550 .inline(html)
551 .map_err(|error| MailError::CssInline(error.to_string()))?;
558 Ok(if is_fragment {
559 unwrap_synthetic_document(&rendered)
560 } else {
561 rendered
562 })
563}
564
565#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
571pub struct MailAttachment {
572 pub filename: String,
574 pub content_type: String,
576 pub bytes: Vec<u8>,
578}
579
580impl std::fmt::Debug for MailAttachment {
581 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
582 f.debug_struct("MailAttachment")
583 .field("filename", &self.filename)
584 .field("content_type", &self.content_type)
585 .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
586 .finish()
587 }
588}
589
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
592pub struct Mail {
593 pub from: Option<String>,
595 pub reply_to: Option<String>,
597 pub to: Vec<String>,
599 pub subject: String,
601 pub html: Option<String>,
603 pub text: Option<String>,
605 pub list_unsubscribe: Option<String>,
611 pub extra_headers: Vec<(String, String)>,
615 #[serde(default)]
617 pub attachments: Vec<MailAttachment>,
618 #[serde(default)]
624 pub ignore_suppression: bool,
625 #[serde(default)]
636 pub inline_css: Option<bool>,
637}
638
639#[cfg(feature = "reporting")]
654fn replayed_send(mail: &Mail) -> Option<Result<(), MailError>> {
655 use crate::capsule::effects::MailVerdict;
656
657 let Some(tape) = crate::capsule::effects::current_tape() else {
658 return crate::capsule::boundary::replaying().then(|| {
661 Err(MailError::RuntimeUnavailable(format!(
662 "mail to {} recipient(s) was sent during a capsule replay outside the replayed \
663 request; nothing was delivered",
664 mail.to.len()
665 )))
666 });
667 };
668 let body = capsule_body(mail);
671 let alternate_body = capsule_alternate_body(mail);
672 let attachments: Vec<_> = mail.attachments.iter().map(attachment_effect).collect();
673 let sent = crate::capsule::effects::SentMail {
674 to: &mail.to,
675 from: mail.from.as_deref(),
676 subject: &mail.subject,
677 body: &body,
678 alternate_body: &alternate_body,
679 reply_to: mail.reply_to.as_deref(),
680 list_unsubscribe: mail.list_unsubscribe.as_deref(),
681 extra_headers: &mail.extra_headers,
682 attachments: &attachments,
683 };
684 Some(match tape.next_mail(&sent) {
685 MailVerdict::Sent => Ok(()),
686 MailVerdict::Failed(error, kind) => Err(rebuild_mail_error(kind, error)),
693 MailVerdict::Diverged => Err(MailError::RuntimeUnavailable(format!(
696 "the replayed run sent mail ({:?} to {} recipient(s)) the capsule has no \
697 recording for; nothing was delivered",
698 mail.subject,
699 mail.to.len()
700 ))),
701 })
702}
703
704#[cfg(feature = "reporting")]
706fn reserve_mail_slot() -> Option<(std::sync::Arc<crate::capsule::CaptureScope>, usize)> {
707 let scope = crate::capsule::current_scope()?;
708 let index = scope.reserve_mail()?;
709 Some((scope, index))
710}
711
712#[cfg(feature = "reporting")]
714fn fill_mail_slot(
715 slot: Option<(std::sync::Arc<crate::capsule::CaptureScope>, usize)>,
716 mail: &Mail,
717 error: Option<&MailError>,
718) {
719 if let Some((scope, index)) = slot {
720 scope.fill_mail(index, mail_effect(mail, error));
721 }
722}
723
724#[cfg(not(feature = "reporting"))]
726const fn reserve_mail_slot() -> Option<()> {
727 None
728}
729
730#[cfg(not(feature = "reporting"))]
732const fn fill_mail_slot(_slot: Option<()>, _mail: &Mail, _error: Option<&MailError>) {}
733
734#[cfg(not(feature = "reporting"))]
736const fn replayed_send(_mail: &Mail) -> Option<Result<(), MailError>> {
737 None
738}
739
740#[cfg(feature = "reporting")]
742fn replaying_legacy_v3() -> bool {
743 crate::capsule::effects::current_tape().is_some_and(|tape| tape.legacy_v3())
744}
745
746#[cfg(not(feature = "reporting"))]
747const fn replaying_legacy_v3() -> bool {
748 false
749}
750
751#[cfg(feature = "reporting")]
757fn mail_effect(mail: &Mail, error: Option<&MailError>) -> crate::capsule::MailEffect {
758 crate::capsule::MailEffect {
759 to: mail.to.clone(),
760 from: mail.from.clone(),
761 subject: mail.subject.clone(),
762 body: capsule_body(mail),
763 alternate_body: capsule_alternate_body(mail),
764 reply_to: mail.reply_to.clone(),
765 list_unsubscribe: mail.list_unsubscribe.clone(),
766 extra_headers: mail.extra_headers.clone(),
767 attachments: mail.attachments.iter().map(attachment_effect).collect(),
768 error: error.map(ToString::to_string),
769 error_kind: error.map(mail_error_kind),
770 }
771}
772
773#[cfg(feature = "reporting")]
779fn capsule_alternate_body(mail: &Mail) -> crate::capsule::CapsuleBody {
780 let alternate = if mail.text.is_some() {
784 mail.html.as_ref()
785 } else {
786 None
787 };
788 alternate.map_or(crate::capsule::CapsuleBody::Absent, |body| {
789 crate::capsule::CapsuleBody::Text(body.clone())
790 })
791}
792
793#[cfg(feature = "reporting")]
795fn attachment_effect(attachment: &MailAttachment) -> crate::capsule::schema::MailAttachmentEffect {
796 use sha2::{Digest as _, Sha256};
797 let digest = Sha256::digest(&attachment.bytes);
798 crate::capsule::schema::MailAttachmentEffect {
799 filename: attachment.filename.clone(),
800 content_type: attachment.content_type.clone(),
801 len: attachment.bytes.len(),
802 sha256: digest.iter().fold(String::new(), |mut out, byte| {
803 use std::fmt::Write as _;
804 let _ = write!(out, "{byte:02x}");
805 out
806 }),
807 }
808}
809
810#[cfg(feature = "reporting")]
815const fn mail_error_kind(error: &MailError) -> crate::capsule::schema::MailErrorKind {
816 use crate::capsule::schema::MailErrorKind as Kind;
817 match error {
818 MailError::InvalidMessage(_) => Kind::InvalidMessage,
819 MailError::RuntimeUnavailable(_) => Kind::RuntimeUnavailable,
820 MailError::InvalidAddress { .. } => Kind::InvalidAddress,
821 MailError::Build(_) => Kind::Build,
822 MailError::Smtp(_) => Kind::Smtp,
823 MailError::Io(_) => Kind::Io,
824 MailError::AllRecipientsSuppressed => Kind::AllRecipientsSuppressed,
825 MailError::CssInline(_) => Kind::CssInline,
826 MailError::NoDurableQueueInProduction => Kind::NoDurableQueueInProduction,
827 MailError::ReplayedFailure(_) => Kind::Other,
828 }
829}
830
831#[cfg(feature = "reporting")]
838fn rebuild_mail_error(
839 kind: Option<crate::capsule::schema::MailErrorKind>,
840 text: String,
841) -> MailError {
842 use crate::capsule::schema::MailErrorKind as Kind;
843 match kind {
844 Some(Kind::AllRecipientsSuppressed) => MailError::AllRecipientsSuppressed,
845 Some(Kind::NoDurableQueueInProduction) => MailError::NoDurableQueueInProduction,
846 Some(Kind::InvalidMessage) => {
847 MailError::InvalidMessage(strip_mail_prefix(&text, "invalid mail message: "))
848 }
849 Some(Kind::RuntimeUnavailable) => {
850 MailError::RuntimeUnavailable(strip_mail_prefix(&text, "mail runtime unavailable: "))
851 }
852 Some(Kind::CssInline) => MailError::CssInline(strip_mail_prefix(
853 &text,
854 "failed to inline CSS into HTML mail body: ",
855 )),
856 Some(Kind::Build | Kind::Smtp | Kind::Io | Kind::InvalidAddress | Kind::Other) | None => {
857 MailError::ReplayedFailure(text)
858 }
859 }
860}
861
862#[cfg(feature = "reporting")]
865fn strip_mail_prefix(text: &str, prefix: &str) -> String {
866 text.strip_prefix(prefix).unwrap_or(text).to_owned()
867}
868
869#[cfg(feature = "reporting")]
875fn capsule_body(mail: &Mail) -> crate::capsule::CapsuleBody {
876 mail.text
877 .as_ref()
878 .or(mail.html.as_ref())
879 .map_or(crate::capsule::CapsuleBody::Absent, |body| {
880 crate::capsule::CapsuleBody::Text(body.clone())
881 })
882}
883
884#[cfg(feature = "reporting")]
889fn record_send(mail: &Mail, error: Option<&MailError>) {
890 fill_mail_slot(reserve_mail_slot(), mail, error);
891}
892
893#[cfg(not(feature = "reporting"))]
895const fn record_send(_mail: &Mail, _error: Option<&MailError>) {}
896
897pub const MAIL_PREVIEW_PATH: &str = "/_autumn/mail";
899
900const MAIL_PREVIEW_MESSAGE_PATH: &str = "/_autumn/mail/messages/{message_id}";
901const MAIL_PREVIEW_TEMPLATE_PATH: &str = "/_autumn/mail/previews/{mailer}/{method}";
902
903#[derive(Clone)]
905pub struct MailPreview {
906 mailer: &'static str,
907 method: &'static str,
908 render: fn() -> Mail,
909}
910
911impl std::fmt::Debug for MailPreview {
912 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
913 f.debug_struct("MailPreview")
914 .field("mailer", &self.mailer)
915 .field("method", &self.method)
916 .finish_non_exhaustive()
917 }
918}
919
920impl MailPreview {
921 #[must_use]
923 pub const fn new(mailer: &'static str, method: &'static str, render: fn() -> Mail) -> Self {
924 Self {
925 mailer,
926 method,
927 render,
928 }
929 }
930
931 #[must_use]
933 pub const fn mailer(&self) -> &'static str {
934 self.mailer
935 }
936
937 #[must_use]
939 pub const fn method(&self) -> &'static str {
940 self.method
941 }
942
943 pub fn render(&self) -> Result<Mail, MailPreviewError> {
950 std::panic::catch_unwind(|| (self.render)()).map_err(|_| {
951 MailPreviewError::PreviewPanicked {
952 mailer: self.mailer,
953 method: self.method,
954 }
955 })
956 }
957}
958
959#[derive(Debug, Clone, Default)]
961pub struct MailPreviewRegistry {
962 previews: Arc<Vec<MailPreview>>,
963}
964
965impl MailPreviewRegistry {
966 #[must_use]
968 pub fn new(previews: Vec<MailPreview>) -> Self {
969 Self {
970 previews: Arc::new(previews),
971 }
972 }
973
974 #[must_use]
976 pub fn previews(&self) -> &[MailPreview] {
977 &self.previews
978 }
979
980 fn find(&self, mailer: &str, method: &str) -> Option<MailPreview> {
981 self.previews
982 .iter()
983 .find(|preview| preview.mailer == mailer && preview.method == method)
984 .cloned()
985 }
986}
987
988#[derive(Debug, Error)]
990pub enum MailPreviewError {
991 #[error("mail preview file IO failed: {0}")]
993 Io(#[from] std::io::Error),
994 #[error("captured mail message not found: {0}")]
996 NotFound(String),
997 #[error("invalid captured mail message id: {0}")]
999 InvalidMessageId(String),
1000 #[error("mail preview {mailer}::{method} panicked while rendering")]
1002 PreviewPanicked {
1003 mailer: &'static str,
1005 method: &'static str,
1007 },
1008}
1009
1010impl Mail {
1011 #[must_use]
1013 pub fn builder() -> MailBuilder {
1014 MailBuilder::default()
1015 }
1016
1017 fn with_defaults(mut self, defaults: &MailerDefaults) -> Self {
1018 if self.from.is_none() {
1019 self.from.clone_from(&defaults.from);
1020 }
1021 if self.reply_to.is_none() {
1022 self.reply_to.clone_from(&defaults.reply_to);
1023 }
1024 self
1025 }
1026}
1027
1028#[derive(Debug, Clone, Default)]
1030pub struct MailBuilder {
1031 from: Option<String>,
1032 reply_to: Option<String>,
1033 to: Vec<String>,
1034 subject: Option<String>,
1035 html: Option<String>,
1036 text: Option<String>,
1037 html_layout: Option<String>,
1038 text_layout: Option<String>,
1039 list_unsubscribe: Option<String>,
1040 extra_headers: Vec<(String, String)>,
1041 attachments: Vec<MailAttachment>,
1042 ignore_suppression: bool,
1043 inline_css: Option<bool>,
1044}
1045
1046impl MailBuilder {
1047 #[must_use]
1049 pub fn from(mut self, from: impl Into<String>) -> Self {
1050 self.from = Some(from.into());
1051 self
1052 }
1053
1054 #[must_use]
1056 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
1057 self.reply_to = Some(reply_to.into());
1058 self
1059 }
1060
1061 #[must_use]
1063 pub fn to(mut self, to: impl Into<String>) -> Self {
1064 self.to.push(to.into());
1065 self
1066 }
1067
1068 #[must_use]
1070 pub fn subject(mut self, subject: impl Into<String>) -> Self {
1071 self.subject = Some(subject.into());
1072 self
1073 }
1074
1075 #[must_use]
1077 pub fn html(mut self, html: impl IntoMailBody) -> Self {
1078 self.html = Some(html.into_mail_body());
1079 self
1080 }
1081
1082 #[must_use]
1084 pub fn text(mut self, text: impl IntoMailBody) -> Self {
1085 self.text = Some(text.into_mail_body());
1086 self
1087 }
1088
1089 #[must_use]
1096 pub fn list_unsubscribe(mut self, scope: impl Into<String>) -> Self {
1097 self.list_unsubscribe = Some(scope.into());
1098 self
1099 }
1100
1101 #[must_use]
1109 pub const fn ignore_suppression(mut self) -> Self {
1110 self.ignore_suppression = true;
1111 self
1112 }
1113
1114 #[must_use]
1129 pub const fn inline_css(mut self, enabled: bool) -> Self {
1130 self.inline_css = Some(enabled);
1131 self
1132 }
1133
1134 #[must_use]
1136 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1137 self.extra_headers.push((name.into(), value.into()));
1138 self
1139 }
1140
1141 #[must_use]
1155 pub fn attach(
1156 mut self,
1157 filename: impl Into<String>,
1158 content_type: impl Into<String>,
1159 bytes: impl Into<Vec<u8>>,
1160 ) -> Self {
1161 self.attachments.push(MailAttachment {
1162 filename: filename.into(),
1163 content_type: content_type.into(),
1164 bytes: bytes.into(),
1165 });
1166 self
1167 }
1168
1169 #[must_use]
1180 pub fn layout(
1181 mut self,
1182 html_layout: impl IntoMailBody,
1183 text_layout: impl IntoMailBody,
1184 ) -> Self {
1185 self.html_layout = Some(html_layout.into_mail_body());
1186 self.text_layout = Some(text_layout.into_mail_body());
1187 self
1188 }
1189
1190 pub fn build(self) -> Result<Mail, MailError> {
1196 if self.to.is_empty() {
1197 return Err(MailError::InvalidMessage(
1198 "mail must have at least one recipient".to_owned(),
1199 ));
1200 }
1201 let subject = self
1202 .subject
1203 .filter(|s| !s.trim().is_empty())
1204 .ok_or_else(|| MailError::InvalidMessage("mail subject is required".to_owned()))?;
1205 if self.html.is_none() && self.text.is_none() {
1206 return Err(MailError::InvalidMessage(
1207 "mail must include html or text body".to_owned(),
1208 ));
1209 }
1210 for attachment in &self.attachments {
1211 if attachment.filename.trim().is_empty()
1212 || attachment.filename.chars().any(char::is_control)
1213 {
1214 return Err(MailError::InvalidMessage(format!(
1215 "attachment filename {:?} must be non-empty and free of control characters",
1216 attachment.filename
1217 )));
1218 }
1219 if let Err(error) = ContentType::parse(&attachment.content_type) {
1220 return Err(MailError::InvalidMessage(format!(
1221 "attachment {:?} has invalid content type {:?}: {error}",
1222 attachment.filename, attachment.content_type
1223 )));
1224 }
1225 }
1226 let html = match (self.html, self.html_layout) {
1231 (Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
1232 (html, _) => html, };
1234 let text = match (self.text, self.text_layout) {
1235 (Some(body), Some(layout)) => Some(compose_layout(&layout, &body)),
1236 (text, _) => text, };
1238 Ok(Mail {
1239 from: self.from,
1240 reply_to: self.reply_to,
1241 to: self.to,
1242 subject,
1243 html,
1244 text,
1245 list_unsubscribe: self.list_unsubscribe,
1246 extra_headers: self.extra_headers,
1247 attachments: self.attachments,
1248 ignore_suppression: self.ignore_suppression,
1249 inline_css: self.inline_css,
1250 })
1251 }
1252}
1253
1254#[derive(Debug, Error)]
1256pub enum MailError {
1257 #[error("invalid mail message: {0}")]
1259 InvalidMessage(String),
1260 #[error("mail runtime unavailable: {0}")]
1262 RuntimeUnavailable(String),
1263 #[error("invalid mail address {address:?}: {source}")]
1265 InvalidAddress {
1266 address: String,
1268 source: lettre::address::AddressError,
1270 },
1271 #[error("failed to build mail message: {0}")]
1273 Build(#[from] lettre::error::Error),
1274 #[error("smtp send failed: {0}")]
1276 Smtp(#[from] lettre::transport::smtp::Error),
1277 #[error("file mail transport failed: {0}")]
1279 Io(#[from] std::io::Error),
1280 #[error("all recipients are on the mail suppression list; nothing was sent")]
1285 AllRecipientsSuppressed,
1286 #[error("failed to inline CSS into HTML mail body: {0}")]
1293 CssInline(String),
1294 #[error(
1301 "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"
1302 )]
1303 NoDurableQueueInProduction,
1304 #[error("{0}")]
1313 ReplayedFailure(String),
1314}
1315
1316pub trait MailTransport: Send + Sync {
1318 fn send<'a>(
1320 &'a self,
1321 mail: Mail,
1322 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1323
1324 fn is_disabled(&self) -> bool {
1334 false
1335 }
1336}
1337
1338pub trait MailDeliveryQueue: Send + Sync {
1348 fn enqueue<'a>(
1350 &'a self,
1351 mail: Mail,
1352 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1353}
1354
1355#[derive(Clone)]
1361pub struct MailDeliveryQueueHandle(Arc<dyn MailDeliveryQueue>);
1362
1363impl MailDeliveryQueueHandle {
1364 #[must_use]
1366 pub fn new(queue: impl MailDeliveryQueue + 'static) -> Self {
1367 Self(Arc::new(queue))
1368 }
1369
1370 #[must_use]
1372 pub fn from_arc(queue: Arc<dyn MailDeliveryQueue>) -> Self {
1373 Self(queue)
1374 }
1375
1376 #[must_use]
1378 pub fn inner(&self) -> &Arc<dyn MailDeliveryQueue> {
1379 &self.0
1380 }
1381}
1382
1383impl std::fmt::Debug for MailDeliveryQueueHandle {
1384 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1385 f.debug_struct("MailDeliveryQueueHandle").finish()
1386 }
1387}
1388
1389pub const UNSUBSCRIBE_PATH: &str = "/_autumn/unsubscribe";
1393
1394#[derive(Debug)]
1400pub struct MailerListUnsubscribeDescriptor {
1401 pub mailer: &'static str,
1403 pub scope: &'static str,
1405}
1406
1407inventory::collect!(MailerListUnsubscribeDescriptor);
1408
1409#[must_use]
1411pub fn registered_list_unsubscribe_scopes() -> Vec<&'static MailerListUnsubscribeDescriptor> {
1412 inventory::iter::<MailerListUnsubscribeDescriptor>
1413 .into_iter()
1414 .collect()
1415}
1416
1417#[must_use]
1420pub fn has_list_unsubscribe_mailers() -> bool {
1421 inventory::iter::<MailerListUnsubscribeDescriptor>
1422 .into_iter()
1423 .next()
1424 .is_some()
1425}
1426
1427#[must_use]
1430#[allow(clippy::fn_params_excessive_bools)]
1431pub(crate) const fn unsubscribe_config_fail_closed(
1432 enforce: bool,
1433 in_production: bool,
1434 has_list_mailers: bool,
1435 unsubscribe_configured: bool,
1436) -> bool {
1437 enforce && in_production && has_list_mailers && !unsubscribe_configured
1438}
1439
1440pub mod unsubscribe {
1452 use aes_gcm::aead::{Aead, KeyInit};
1453 use aes_gcm::{Aes256Gcm, Nonce};
1454 use base64::Engine as _;
1455 use hmac::{Hmac, Mac};
1456 use sha2::Sha256;
1457
1458 use crate::security::config::ResolvedSigningKeys;
1459
1460 pub const DEFAULT_TOKEN_TTL_DAYS: i64 = 30;
1462
1463 const ENGINE: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1464 const TOKEN_VERSION: u8 = 1;
1466 const NONCE_LEN: usize = 12;
1468 const KEY_CONTEXT: &[u8] = b"autumn:unsubscribe-token:v1";
1471
1472 #[derive(Debug, Clone, PartialEq, Eq)]
1474 pub struct Unsubscribed {
1475 pub subscriber: String,
1477 pub list_id: String,
1479 }
1480
1481 #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1483 pub enum TokenError {
1484 #[error("unsubscribe token is malformed")]
1486 Malformed,
1487 #[error("unsubscribe token signature is invalid")]
1489 BadSignature,
1490 #[error("unsubscribe token has expired")]
1492 Expired,
1493 }
1494
1495 #[allow(
1498 clippy::expect_used,
1499 reason = "infallible: HMAC accepts any key length"
1500 )]
1501 fn derive_key(signing_key: &[u8]) -> [u8; 32] {
1502 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(signing_key)
1503 .expect("HMAC accepts any key length");
1504 mac.update(KEY_CONTEXT);
1505 let bytes = mac.finalize().into_bytes();
1506 let mut key = [0u8; 32];
1507 key.copy_from_slice(&bytes);
1508 key
1509 }
1510
1511 fn plaintext(subscriber: &str, list_id: &str, expiry_unix: i64) -> String {
1513 format!(
1514 "{}.{}.{expiry_unix}",
1515 ENGINE.encode(subscriber.as_bytes()),
1516 ENGINE.encode(list_id.as_bytes()),
1517 )
1518 }
1519
1520 #[must_use]
1529 #[allow(
1530 clippy::expect_used,
1531 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"
1532 )]
1533 pub fn sign_token(
1534 keys: &ResolvedSigningKeys,
1535 subscriber: &str,
1536 list_id: &str,
1537 expiry_unix: i64,
1538 ) -> String {
1539 let key = derive_key(&keys.current);
1540 let cipher = Aes256Gcm::new_from_slice(&key).expect("derived key is always 32 bytes");
1541 let mut nonce_bytes = [0u8; NONCE_LEN];
1542 getrandom::fill(&mut nonce_bytes).expect("OS RNG failed");
1543 let ciphertext = cipher
1544 .encrypt(
1545 <&Nonce<_>>::from(&nonce_bytes),
1546 plaintext(subscriber, list_id, expiry_unix).as_bytes(),
1547 )
1548 .expect("AES-GCM encryption cannot fail for valid inputs");
1549 let mut blob = Vec::with_capacity(ciphertext.len().saturating_add(1 + NONCE_LEN));
1550 blob.push(TOKEN_VERSION);
1551 blob.extend_from_slice(&nonce_bytes);
1552 blob.extend_from_slice(&ciphertext);
1553 ENGINE.encode(blob)
1554 }
1555
1556 #[allow(
1564 clippy::indexing_slicing,
1565 reason = "blob.len() is checked to be >= 1 + NONCE_LEN above, so these indices are in bounds"
1566 )]
1567 pub fn verify_token(
1568 keys: &ResolvedSigningKeys,
1569 token: &str,
1570 now_unix: i64,
1571 ) -> Result<Unsubscribed, TokenError> {
1572 let blob = ENGINE.decode(token).map_err(|_| TokenError::Malformed)?;
1573 if blob.len() < 1 + NONCE_LEN {
1574 return Err(TokenError::Malformed);
1575 }
1576 if blob[0] != TOKEN_VERSION {
1577 return Err(TokenError::Malformed);
1578 }
1579 let nonce = <&Nonce<_>>::from(&blob[1..=NONCE_LEN]);
1580 let ciphertext = &blob[1 + NONCE_LEN..];
1581 let payload = std::iter::once(&keys.current)
1584 .chain(keys.previous.iter())
1585 .find_map(|signing_key| {
1586 let key = derive_key(signing_key);
1587 let cipher = Aes256Gcm::new_from_slice(&key).ok()?;
1590 cipher.decrypt(nonce, ciphertext).ok()
1591 })
1592 .ok_or(TokenError::BadSignature)?;
1593 let payload = String::from_utf8(payload).map_err(|_| TokenError::Malformed)?;
1594 let mut parts = payload.split('.');
1595 let subscriber_b64 = parts.next().ok_or(TokenError::Malformed)?;
1596 let list_b64 = parts.next().ok_or(TokenError::Malformed)?;
1597 let expiry_s = parts.next().ok_or(TokenError::Malformed)?;
1598 if parts.next().is_some() {
1599 return Err(TokenError::Malformed);
1600 }
1601 let expiry: i64 = expiry_s.parse().map_err(|_| TokenError::Malformed)?;
1602 if now_unix > expiry {
1603 return Err(TokenError::Expired);
1604 }
1605 let subscriber = decode_field(subscriber_b64)?;
1606 let list_id = decode_field(list_b64)?;
1607 Ok(Unsubscribed {
1608 subscriber,
1609 list_id,
1610 })
1611 }
1612
1613 fn decode_field(encoded: &str) -> Result<String, TokenError> {
1614 let bytes = ENGINE.decode(encoded).map_err(|_| TokenError::Malformed)?;
1615 String::from_utf8(bytes).map_err(|_| TokenError::Malformed)
1616 }
1617
1618 #[must_use]
1620 pub fn unsubscribe_url(base_url: &str, token: &str) -> String {
1621 format!(
1622 "{}{}?token={token}",
1623 base_url.trim_end_matches('/'),
1624 super::UNSUBSCRIBE_PATH,
1625 )
1626 }
1627}
1628
1629pub trait SuppressionStore: Send + Sync {
1636 fn is_suppressed<'a>(
1638 &'a self,
1639 subscriber: &'a str,
1640 list_id: &'a str,
1641 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
1642
1643 fn is_suppressed_many<'a>(
1655 &'a self,
1656 subscribers: &'a [&'a str],
1657 list_id: &'a str,
1658 ) -> Pin<
1659 Box<dyn Future<Output = Result<std::collections::HashSet<String>, MailError>> + Send + 'a>,
1660 > {
1661 Box::pin(async move {
1662 let mut suppressed = std::collections::HashSet::new();
1663 for &subscriber in subscribers {
1664 if self.is_suppressed(subscriber, list_id).await? {
1665 suppressed.insert(subscriber.to_owned());
1666 }
1667 }
1668 Ok(suppressed)
1669 })
1670 }
1671
1672 fn suppress<'a>(
1674 &'a self,
1675 subscriber: &'a str,
1676 list_id: &'a str,
1677 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
1678}
1679
1680#[derive(Clone)]
1682pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
1683
1684impl SuppressionStoreHandle {
1685 #[must_use]
1687 pub fn new(store: impl SuppressionStore + 'static) -> Self {
1688 Self(Arc::new(store))
1689 }
1690
1691 #[must_use]
1693 pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
1694 Self(store)
1695 }
1696
1697 #[must_use]
1699 pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
1700 &self.0
1701 }
1702}
1703
1704impl std::fmt::Debug for SuppressionStoreHandle {
1705 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1706 f.debug_struct("SuppressionStoreHandle").finish()
1707 }
1708}
1709
1710#[derive(Debug, Default, Clone)]
1715pub struct InMemorySuppressionStore {
1716 suppressed: Arc<std::sync::Mutex<std::collections::HashSet<(String, String)>>>,
1717}
1718
1719impl InMemorySuppressionStore {
1720 #[must_use]
1722 pub fn new() -> Self {
1723 Self::default()
1724 }
1725}
1726
1727impl SuppressionStore for InMemorySuppressionStore {
1728 fn is_suppressed<'a>(
1729 &'a self,
1730 subscriber: &'a str,
1731 list_id: &'a str,
1732 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
1733 Box::pin(async move {
1734 let key = (subscriber.to_owned(), list_id.to_owned());
1735 let suppressed = self
1736 .suppressed
1737 .lock()
1738 .unwrap_or_else(std::sync::PoisonError::into_inner)
1739 .contains(&key);
1740 Ok(suppressed)
1741 })
1742 }
1743
1744 fn suppress<'a>(
1745 &'a self,
1746 subscriber: &'a str,
1747 list_id: &'a str,
1748 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1749 Box::pin(async move {
1750 let key = (subscriber.to_owned(), list_id.to_owned());
1751 self.suppressed
1752 .lock()
1753 .unwrap_or_else(std::sync::PoisonError::into_inner)
1754 .insert(key);
1755 Ok(())
1756 })
1757 }
1758}
1759
1760pub struct UnsubscribeRuntime {
1767 pub base_url: Option<String>,
1769 pub mailto: Option<String>,
1771 pub signing_keys: Arc<crate::security::config::ResolvedSigningKeys>,
1773 pub ttl_days: i64,
1775 pub suppression: Option<Arc<dyn SuppressionStore>>,
1777}
1778
1779impl std::fmt::Debug for UnsubscribeRuntime {
1780 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1781 f.debug_struct("UnsubscribeRuntime")
1782 .field("base_url", &self.base_url)
1783 .field("mailto", &self.mailto)
1784 .field("ttl_days", &self.ttl_days)
1785 .field("has_suppression", &self.suppression.is_some())
1786 .finish_non_exhaustive()
1787 }
1788}
1789
1790impl UnsubscribeRuntime {
1791 #[must_use]
1795 pub fn list_unsubscribe_header(&self, subscriber: &str, list_id: &str) -> Option<String> {
1796 let mut entries: Vec<String> = Vec::new();
1797 if let Some(base) = self.base_url.as_deref().filter(|s| !s.trim().is_empty()) {
1798 let expiry = current_unix_time().saturating_add(self.ttl_days.saturating_mul(86_400));
1799 let token = unsubscribe::sign_token(&self.signing_keys, subscriber, list_id, expiry);
1800 entries.push(format!("<{}>", unsubscribe::unsubscribe_url(base, &token)));
1801 }
1802 if let Some(mailto) = self.mailto.as_deref().filter(|s| !s.trim().is_empty()) {
1803 let trimmed = mailto.trim();
1809 let address = trimmed.strip_prefix("mailto:").unwrap_or(trimmed);
1810 let address = address.split('?').next().unwrap_or(address);
1811 entries.push(format!("<mailto:{address}?subject=unsubscribe>"));
1812 }
1813 if entries.is_empty() {
1814 None
1815 } else {
1816 Some(entries.join(", "))
1817 }
1818 }
1819
1820 #[must_use]
1824 pub fn supports_one_click(&self) -> bool {
1825 self.base_url
1826 .as_deref()
1827 .is_some_and(|s| !s.trim().is_empty())
1828 }
1829}
1830
1831fn current_unix_time() -> i64 {
1834 i64::try_from(crate::time::clock_unix_secs(&crate::time::AmbientClock)).unwrap_or(i64::MAX)
1835}
1836
1837#[derive(Debug, Clone, Default)]
1838struct MailerDefaults {
1839 from: Option<String>,
1840 reply_to: Option<String>,
1841}
1842
1843#[derive(Clone)]
1845pub struct Mailer {
1846 defaults: Arc<MailerDefaults>,
1847 transport: Arc<dyn MailTransport>,
1848 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
1849 unsubscribe: Option<Arc<UnsubscribeRuntime>>,
1850 suppression: Option<Arc<dyn suppression::SuppressionStore>>,
1854 inline_css_default: bool,
1857 block_deliver_later_without_durable_queue: bool,
1863}
1864
1865impl Mailer {
1866 #[must_use]
1868 pub fn builder() -> MailerBuilder {
1869 MailerBuilder::default()
1870 }
1871
1872 pub fn from_config(config: &MailConfig) -> Result<Self, MailError> {
1878 Self::from_config_inner(config, None)
1879 }
1880
1881 pub(crate) fn from_config_inner(
1882 config: &MailConfig,
1883 resilience: Option<Arc<crate::config::ResilienceConfig>>,
1884 ) -> Result<Self, MailError> {
1885 let mut builder = Self::builder()
1886 .transport(config.transport)
1887 .inline_css(config.inline_css)
1888 .resilience_config(resilience);
1889 if let Some(from) = &config.from {
1890 builder = builder.from(from.clone());
1891 }
1892 if let Some(reply_to) = &config.reply_to {
1893 builder = builder.reply_to(reply_to.clone());
1894 }
1895 if config.transport == Transport::File {
1896 builder = builder.file_dir(config.file_dir.clone());
1897 }
1898 if config.transport == Transport::Smtp {
1899 builder = builder.smtp(config.smtp.clone());
1900 }
1901 builder.build()
1902 }
1903
1904 #[must_use]
1906 pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
1907 Self {
1908 defaults: Arc::new(MailerDefaults::default()),
1909 transport: Arc::new(transport),
1910 delivery_queue: None,
1911 unsubscribe: None,
1912 suppression: None,
1913 inline_css_default: false,
1914 block_deliver_later_without_durable_queue: false,
1915 }
1916 }
1917
1918 #[must_use]
1920 pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
1921 self.delivery_queue = Some(Arc::new(queue));
1922 self
1923 }
1924
1925 #[must_use]
1928 pub fn with_unsubscribe(mut self, runtime: Arc<UnsubscribeRuntime>) -> Self {
1929 self.unsubscribe = Some(runtime);
1930 self
1931 }
1932
1933 #[must_use]
1938 pub fn with_suppression(mut self, store: suppression::SuppressionStoreHandle) -> Self {
1939 self.suppression = Some(store.into_inner());
1940 self
1941 }
1942
1943 #[must_use]
1945 pub fn has_durable_delivery_queue(&self) -> bool {
1946 self.delivery_queue.is_some()
1947 }
1948
1949 #[must_use]
1955 pub fn is_disabled(&self) -> bool {
1956 self.transport.is_disabled()
1957 }
1958
1959 pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
1984 let mail = mail.with_defaults(&self.defaults);
1985
1986 if let Some(answer) = replayed_send(&mail) {
1992 return answer;
1993 }
1994 let slot = reserve_mail_slot();
2004 let recorded = mail.clone();
2005 let result = self.send_inner(mail).await;
2006 fill_mail_slot(slot, &recorded, result.as_ref().err());
2007 result
2008 }
2009
2010 async fn send_inner(&self, mail: Mail) -> Result<(), MailError> {
2012 let mut mail = mail;
2013
2014 self.apply_css_inlining(&mut mail)?;
2020
2021 if !mail.ignore_suppression
2027 && let Some(store) = self.suppression.as_ref()
2028 && !mail.to.is_empty()
2029 {
2030 let mut kept: Vec<String> = Vec::with_capacity(mail.to.len());
2031 for recipient in &mail.to {
2032 if store.is_suppressed(recipient).await? {
2036 suppression::note_skip(&canonical_subscriber(recipient));
2037 } else {
2038 kept.push(recipient.clone());
2039 }
2040 }
2041 if kept.is_empty() {
2042 return Err(MailError::AllRecipientsSuppressed);
2043 }
2044 mail.to = kept;
2045 }
2046
2047 if let Some(list_id) = mail.list_unsubscribe.clone() {
2048 if let Some(runtime) = self.unsubscribe.clone() {
2049 return self.send_list_mail(mail, list_id, &runtime).await;
2050 }
2051 tracing::warn!(
2055 target: "mail",
2056 list_id = %list_id,
2057 "sending list mail without an unsubscribe runtime: no List-Unsubscribe headers or suppression applied (set mail.unsubscribe_base_url / mail.unsubscribe_mailto)"
2058 );
2059 }
2060 self.transport.send(mail).await
2061 }
2062
2063 fn apply_css_inlining(&self, mail: &mut Mail) -> Result<(), MailError> {
2072 let enabled = mail.inline_css.unwrap_or(self.inline_css_default);
2073 if !enabled {
2074 return Ok(());
2075 }
2076 let Some(html) = mail.html.as_deref() else {
2077 return Ok(());
2078 };
2079 match inline_css_html(html) {
2080 Ok(inlined) => {
2081 mail.html = Some(inlined);
2082 Ok(())
2083 }
2084 Err(error) => {
2085 tracing::warn!(
2088 target: "mail",
2089 error = %error,
2090 "CSS inlining failed; HTML body left un-inlined"
2091 );
2092 Err(error)
2093 }
2094 }
2095 }
2096
2097 const fn freeze_inline_css_default(&self, mail: &mut Mail) {
2112 if mail.inline_css.is_none() {
2113 mail.inline_css = Some(self.inline_css_default);
2114 }
2115 }
2116
2117 async fn send_list_mail(
2120 &self,
2121 mail: Mail,
2122 list_id: String,
2123 runtime: &UnsubscribeRuntime,
2124 ) -> Result<(), MailError> {
2125 let mut candidates: Vec<(String, String)> = Vec::with_capacity(mail.to.len());
2145 for recipient in &mail.to {
2146 parse_mailbox(recipient)?;
2147 candidates.push((recipient.clone(), canonical_subscriber(recipient)));
2148 }
2149
2150 let suppressed = if let Some(store) = runtime.suppression.as_ref() {
2151 let subscribers: Vec<&str> = candidates
2152 .iter()
2153 .map(|(_, subscriber)| subscriber.as_str())
2154 .collect();
2155 store.is_suppressed_many(&subscribers, &list_id).await?
2156 } else {
2157 std::collections::HashSet::new()
2158 };
2159
2160 let mut deliveries: Vec<(String, String)> = Vec::with_capacity(candidates.len());
2161 for (recipient, subscriber) in candidates {
2162 if suppressed.contains(&subscriber) {
2163 tracing::info!(
2164 target: "mail",
2165 list_id = %list_id,
2166 outcome = "skipped_suppressed",
2167 "skipping suppressed list-unsubscribe recipient"
2168 );
2169 continue;
2170 }
2171 deliveries.push((recipient, subscriber));
2172 }
2173
2174 for (recipient, subscriber) in deliveries {
2175 let mut per_recipient = mail.clone();
2176 per_recipient.to = vec![recipient];
2177 if let Some(value) = runtime.list_unsubscribe_header(&subscriber, &list_id) {
2178 per_recipient.extra_headers.retain(|(name, _)| {
2184 !name.eq_ignore_ascii_case("List-Unsubscribe")
2185 && !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
2186 });
2187 per_recipient
2188 .extra_headers
2189 .push(("List-Unsubscribe".to_owned(), value));
2190 if runtime.supports_one_click() {
2193 per_recipient.extra_headers.push((
2194 "List-Unsubscribe-Post".to_owned(),
2195 "List-Unsubscribe=One-Click".to_owned(),
2196 ));
2197 }
2198 }
2199 self.transport.send(per_recipient).await?;
2200 }
2201 Ok(())
2202 }
2203
2204 pub fn deliver_later(&self, mail: Mail) {
2221 if let Err(error) = self.try_deliver_later(mail) {
2222 tracing::error!(error = %error, "background mail delivery was not scheduled");
2223 }
2224 }
2225
2226 pub fn deliver_later_eager(&self, mail: Mail) {
2234 if let Err(error) = self.try_deliver_later_eager(mail) {
2235 tracing::error!(error = %error, "background mail delivery was not scheduled");
2236 }
2237 }
2238
2239 pub(crate) fn prepare_deferred(&self, mail: Mail) -> Mail {
2246 let mut mail = mail.with_defaults(&self.defaults);
2247 self.freeze_inline_css_default(&mut mail);
2248 mail
2249 }
2250
2251 pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
2265 if self.transport.is_disabled() {
2266 return self.disabled_deliver_later(mail);
2267 }
2268 if self.block_deliver_later_without_durable_queue && self.delivery_queue.is_none() {
2269 return self.refused_deliver_later(mail);
2270 }
2271 let mail = self.prepare_deferred(mail);
2272
2273 #[cfg(feature = "db")]
2276 if crate::db::AFTER_COMMIT_REGISTRY.try_with(|_| ()).is_ok() {
2277 if let Some(answer) = replayed_send(&mail) {
2281 return answer;
2282 }
2283 record_send(&mail, None);
2284 let mailer = self.clone();
2285 let deferred = mail.clone();
2286 let mut f_opt: Option<(Self, Mail)> = Some((mailer, deferred));
2287 let deliver_span = tracing::Span::current();
2291
2292 crate::db::AFTER_COMMIT_REGISTRY
2293 .try_with(|registry| {
2294 #[allow(
2295 clippy::expect_used,
2296 reason = "unreachable: try_with closure body runs at most once"
2297 )]
2298 let (m, m_mail) = f_opt.take().expect("once");
2299 let span = deliver_span.clone();
2300 let boxed: crate::db::CommitCallback = Box::new(move || {
2301 Box::pin(tracing::Instrument::instrument(
2302 async move {
2303 if let Some(queue) = m.delivery_queue.clone() {
2304 queue.enqueue(m_mail).await.map_err(|e| {
2305 crate::AutumnError::internal_server_error_msg(e.to_string())
2306 })
2307 } else {
2308 m.spawn_unrecorded_delivery(m_mail).map_err(|e| {
2310 crate::AutumnError::internal_server_error_msg(e.to_string())
2311 })
2312 }
2313 },
2314 span,
2315 ))
2316 });
2317 registry
2318 .lock()
2319 .unwrap_or_else(std::sync::PoisonError::into_inner)
2320 .push(boxed);
2321 })
2322 .ok();
2323
2324 if f_opt.is_none() {
2325 return Ok(());
2327 }
2328 }
2329
2330 self.spawn_mail_delivery(mail)
2332 }
2333
2334 pub fn try_deliver_later_eager(&self, mail: Mail) -> Result<(), MailError> {
2344 if self.transport.is_disabled() {
2345 return self.disabled_deliver_later(mail);
2346 }
2347 if self.block_deliver_later_without_durable_queue && self.delivery_queue.is_none() {
2348 return self.refused_deliver_later(mail);
2349 }
2350 let mail = self.prepare_deferred(mail);
2351 self.spawn_mail_delivery(mail)
2352 }
2353
2354 fn spawn_mail_delivery(&self, mail: Mail) -> Result<(), MailError> {
2355 if let Some(answer) = replayed_send(&mail) {
2362 return answer;
2363 }
2364 record_send(&mail, None);
2368 self.spawn_unrecorded_delivery(mail)
2369 }
2370
2371 #[allow(
2375 clippy::unnecessary_wraps,
2376 reason = "the replayed answer is the recorded `Result`"
2377 )]
2378 fn disabled_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
2379 if replaying_legacy_v3() {
2381 return Ok(());
2382 }
2383 let mail = self.prepare_deferred(mail);
2384 if let Some(answer) = replayed_send(&mail) {
2385 return answer;
2386 }
2387 record_send(&mail, None);
2388 Ok(())
2389 }
2390
2391 fn refused_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
2395 let mail = self.prepare_deferred(mail);
2396 if let Some(answer) = replayed_send(&mail) {
2397 return answer;
2398 }
2399 let error = MailError::NoDurableQueueInProduction;
2400 record_send(&mail, Some(&error));
2401 Err(error)
2402 }
2403
2404 fn spawn_unrecorded_delivery(&self, mail: Mail) -> Result<(), MailError> {
2406 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
2410 MailError::RuntimeUnavailable(
2411 "deliver_later requires an active Tokio runtime".to_owned(),
2412 )
2413 })?;
2414 let parent_span = tracing::Span::current();
2415 if let Some(queue) = self.delivery_queue.clone() {
2416 handle.spawn(tracing::Instrument::instrument(
2417 async move {
2418 if let Err(error) = queue.enqueue(mail).await {
2419 tracing::error!(error = %error, "durable mail enqueue failed");
2420 }
2421 },
2422 parent_span,
2423 ));
2424 } else {
2425 let mailer = self.clone();
2426 handle.spawn(tracing::Instrument::instrument(
2427 async move {
2428 if let Err(error) = mailer.send(mail).await {
2429 tracing::error!(error = %error, "background mail delivery failed");
2430 }
2431 },
2432 parent_span,
2433 ));
2434 }
2435 Ok(())
2436 }
2437}
2438
2439impl FromRequestParts<AppState> for Mailer {
2440 type Rejection = AutumnError;
2441
2442 async fn from_request_parts(
2443 _parts: &mut http::request::Parts,
2444 state: &AppState,
2445 ) -> Result<Self, Self::Rejection> {
2446 state
2447 .extension::<Self>()
2448 .as_deref()
2449 .cloned()
2450 .ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
2451 }
2452}
2453
2454#[derive(Clone)]
2456pub struct MailerBuilder {
2457 transport: Transport,
2458 from: Option<String>,
2459 reply_to: Option<String>,
2460 file_dir: PathBuf,
2461 smtp: Option<SmtpConfig>,
2462 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
2463 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2464 inline_css: bool,
2465}
2466
2467impl Default for MailerBuilder {
2468 fn default() -> Self {
2469 Self {
2470 transport: Transport::Log,
2471 from: None,
2472 reply_to: None,
2473 file_dir: default_file_dir(),
2474 smtp: None,
2475 delivery_queue: None,
2476 resilience_config: None,
2477 inline_css: false,
2478 }
2479 }
2480}
2481
2482impl MailerBuilder {
2483 #[must_use]
2485 pub const fn transport(mut self, transport: Transport) -> Self {
2486 self.transport = transport;
2487 self
2488 }
2489
2490 #[must_use]
2492 pub fn from(mut self, from: impl Into<String>) -> Self {
2493 self.from = Some(from.into());
2494 self
2495 }
2496
2497 #[must_use]
2499 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
2500 self.reply_to = Some(reply_to.into());
2501 self
2502 }
2503
2504 #[must_use]
2506 pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
2507 self.file_dir = dir.as_ref().to_path_buf();
2508 self
2509 }
2510
2511 #[must_use]
2513 pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
2514 self.smtp = Some(smtp);
2515 self
2516 }
2517
2518 #[must_use]
2521 pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
2522 self.delivery_queue = Some(Arc::new(queue));
2523 self
2524 }
2525
2526 #[must_use]
2528 pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
2529 self.delivery_queue = Some(queue);
2530 self
2531 }
2532
2533 #[must_use]
2534 pub fn resilience_config(mut self, rc: Option<Arc<crate::config::ResilienceConfig>>) -> Self {
2535 self.resilience_config = rc;
2536 self
2537 }
2538
2539 #[must_use]
2543 pub const fn inline_css(mut self, enabled: bool) -> Self {
2544 self.inline_css = enabled;
2545 self
2546 }
2547
2548 pub fn build(self) -> Result<Mailer, MailError> {
2554 if let Some(from) = &self.from {
2555 parse_mailbox(from)?;
2556 }
2557 if let Some(reply_to) = &self.reply_to {
2558 parse_mailbox(reply_to)?;
2559 }
2560
2561 let transport: Arc<dyn MailTransport> = match self.transport {
2562 Transport::Log => Arc::new(LogTransport),
2563 Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
2564 Transport::Disabled => Arc::new(DisabledTransport),
2565 Transport::Smtp => Arc::new(SmtpTransport::new(
2566 self.smtp.unwrap_or_default(),
2567 self.resilience_config.clone(),
2568 )?),
2569 };
2570
2571 Ok(Mailer {
2572 defaults: Arc::new(MailerDefaults {
2573 from: self.from,
2574 reply_to: self.reply_to,
2575 }),
2576 transport,
2577 delivery_queue: self.delivery_queue,
2578 unsubscribe: None,
2579 suppression: None,
2580 inline_css_default: self.inline_css,
2581 block_deliver_later_without_durable_queue: false,
2582 })
2583 }
2584}
2585
2586struct DisabledTransport;
2587
2588impl MailTransport for DisabledTransport {
2589 fn send<'a>(
2590 &'a self,
2591 _mail: Mail,
2592 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2593 Box::pin(async { Ok(()) })
2594 }
2595
2596 fn is_disabled(&self) -> bool {
2597 true
2598 }
2599}
2600
2601struct LogTransport;
2602
2603impl MailTransport for LogTransport {
2604 fn send<'a>(
2605 &'a self,
2606 mail: Mail,
2607 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2608 Box::pin(async move {
2609 tracing::info!(
2610 from = ?mail.from,
2611 reply_to = ?mail.reply_to,
2612 to = ?mail.to,
2613 subject = %mail.subject,
2614 text = ?mail.text,
2615 html = ?mail.html,
2616 attachments = mail.attachments.len(),
2617 "mail captured by log transport"
2618 );
2619 Ok(())
2620 })
2621 }
2622}
2623
2624struct FileTransport {
2625 dir: PathBuf,
2626}
2627
2628static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
2629
2630impl MailTransport for FileTransport {
2631 fn send<'a>(
2632 &'a self,
2633 mail: Mail,
2634 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2635 Box::pin(async move {
2636 tokio::fs::create_dir_all(&self.dir).await?;
2637 let filename = file_transport_filename(&mail);
2638 let path = self.dir.join(filename);
2639 let mut file = tokio::fs::OpenOptions::new()
2640 .write(true)
2641 .create_new(true)
2642 .open(path)
2643 .await?;
2644 let eml = render_eml(&mail);
2645 tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
2646 tokio::io::AsyncWriteExt::flush(&mut file).await?;
2647 file.sync_all().await?;
2648 Ok(())
2649 })
2650 }
2651}
2652
2653struct SmtpTransport {
2654 inner: AsyncSmtpTransport<Tokio1Executor>,
2655 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2656}
2657
2658impl SmtpTransport {
2659 fn new(
2660 config: SmtpConfig,
2661 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
2662 ) -> Result<Self, MailError> {
2663 let host = config
2664 .host
2665 .filter(|host| !host.trim().is_empty())
2666 .ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
2667 let mut builder = match config.tls {
2668 TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
2669 TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
2670 TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
2671 };
2672 if let Some(port) = config.port {
2673 builder = builder.port(port);
2674 }
2675 if let Some(username) = config.username {
2676 let password_env = config.password_env.ok_or_else(|| {
2677 MailError::InvalidMessage(
2678 "mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
2679 )
2680 })?;
2681 let password = std::env::var(&password_env)
2682 .map_err(|error| smtp_password_env_error(&password_env, &error))?;
2683 builder = builder.credentials(Credentials::new(username, password));
2684 }
2685 Ok(Self {
2686 inner: builder.build(),
2687 resilience_config,
2688 })
2689 }
2690}
2691
2692fn smtp_password_env_error(password_env: &str, error: &std::env::VarError) -> MailError {
2699 let reason = match error {
2700 std::env::VarError::NotPresent => "environment variable is not set",
2701 std::env::VarError::NotUnicode(_) => "environment variable contains non-unicode data",
2702 };
2703 MailError::InvalidMessage(format!(
2704 "mail.smtp.password_env={password_env:?} could not be resolved: {reason}"
2705 ))
2706}
2707
2708impl MailTransport for SmtpTransport {
2709 fn send<'a>(
2710 &'a self,
2711 mail: Mail,
2712 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2713 Box::pin(async move {
2714 let breaker = self.resilience_config.as_ref().map_or_else(
2715 || {
2716 crate::circuit_breaker::global_registry().get_or_create(
2717 "smtp_mailer",
2718 crate::circuit_breaker::CircuitBreakerPolicy::default(),
2719 )
2720 },
2721 |rc| {
2722 let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(
2723 rc,
2724 "smtp_mailer",
2725 );
2726 crate::circuit_breaker::global_registry()
2727 .get_or_create_with_config("smtp_mailer", policy)
2728 },
2729 );
2730
2731 let Ok(guard) = breaker.admit() else {
2732 return Err(MailError::RuntimeUnavailable(
2733 "smtp mailer circuit breaker is open".to_owned(),
2734 ));
2735 };
2736
2737 let message = lettre_message(&mail)?;
2738 let res = self.inner.send(message).await;
2739 if res.is_ok() {
2740 guard.success();
2741 } else {
2742 guard.failure();
2743 }
2744
2745 res.map(|_| ()).map_err(Into::into)
2746 })
2747 }
2748}
2749
2750fn sanitize_filename(value: &str) -> String {
2751 value
2752 .chars()
2753 .map(|ch| {
2754 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
2755 ch
2756 } else {
2757 '_'
2758 }
2759 })
2760 .collect()
2761}
2762
2763const RFC2231_ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
2767 .remove(b'!')
2768 .remove(b'#')
2769 .remove(b'$')
2770 .remove(b'&')
2771 .remove(b'+')
2772 .remove(b'-')
2773 .remove(b'.')
2774 .remove(b'^')
2775 .remove(b'_')
2776 .remove(b'`')
2777 .remove(b'|')
2778 .remove(b'~');
2779
2780fn strip_header_controls(value: &str) -> String {
2786 value.chars().filter(|ch| !ch.is_control()).collect()
2787}
2788
2789fn quote_header_value(value: &str) -> String {
2790 let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
2791 format!("\"{escaped}\"")
2792}
2793
2794fn content_disposition_params(filename: &str) -> String {
2800 let mut clean = strip_header_controls(filename);
2801 if clean.trim().is_empty() {
2802 "attachment".clone_into(&mut clean);
2803 }
2804 if clean.is_ascii() {
2805 format!("filename={}", quote_header_value(&clean))
2806 } else {
2807 let fallback: String = clean
2808 .chars()
2809 .map(|ch| if ch.is_ascii() { ch } else { '_' })
2810 .collect();
2811 let encoded = percent_encoding::utf8_percent_encode(&clean, RFC2231_ATTR_CHAR);
2812 format!(
2813 "filename={}; filename*=UTF-8''{encoded}",
2814 quote_header_value(&fallback)
2815 )
2816 }
2817}
2818
2819fn base64_wrap76(bytes: &[u8]) -> String {
2821 use base64::Engine as _;
2822 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
2823 if encoded.len() <= 76 {
2824 return encoded;
2825 }
2826 let newlines = encoded.len().div_ceil(76).saturating_sub(1);
2828 let mut wrapped = String::with_capacity(encoded.len().saturating_add(newlines));
2829 for chunk in encoded.as_bytes().chunks(76) {
2830 if !wrapped.is_empty() {
2831 wrapped.push('\n');
2832 }
2833 #[allow(
2834 clippy::expect_used,
2835 reason = "infallible: base64 output is always ASCII"
2836 )]
2837 let chunk_str = std::str::from_utf8(chunk).expect("base64 output is always ASCII");
2838 wrapped.push_str(chunk_str);
2839 }
2840 wrapped
2841}
2842
2843fn file_transport_filename(mail: &Mail) -> String {
2844 let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
2845 format!(
2846 "{}-{}-{:016x}-{}.eml",
2847 crate::time::ambient_now().format("%Y%m%d%H%M%S%6f"),
2848 std::process::id(),
2849 sequence,
2850 sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
2851 )
2852}
2853
2854fn render_eml(mail: &Mail) -> String {
2855 let mut out = String::new();
2856 if let Some(from) = &mail.from {
2857 out.push_str("From: ");
2858 out.push_str(&strip_header_controls(from));
2859 out.push('\n');
2860 }
2861 for to in &mail.to {
2862 out.push_str("To: ");
2863 out.push_str(&strip_header_controls(to));
2864 out.push('\n');
2865 }
2866 if let Some(reply_to) = &mail.reply_to {
2867 out.push_str("Reply-To: ");
2868 out.push_str(&strip_header_controls(reply_to));
2869 out.push('\n');
2870 }
2871 out.push_str("Date: ");
2872 out.push_str(&crate::time::ambient_now().to_rfc2822());
2873 out.push('\n');
2874 out.push_str("Message-Id: <");
2875 out.push_str(&uuid::Uuid::new_v4().to_string());
2876 out.push_str("@autumn.local>\n");
2877 out.push_str("Subject: ");
2878 out.push_str(&strip_header_controls(&mail.subject));
2879 out.push('\n');
2880 for (name, value) in &mail.extra_headers {
2881 out.push_str(&strip_header_controls(name));
2882 out.push_str(": ");
2883 out.push_str(&strip_header_controls(value));
2884 out.push('\n');
2885 }
2886 out.push_str("MIME-Version: 1.0\n");
2887 if mail.attachments.is_empty() {
2888 render_eml_bodies(mail, &mut out);
2889 } else {
2890 use std::fmt::Write as _;
2896 let boundary = format!("autumn-mixed-{}", uuid::Uuid::new_v4().simple());
2897 let _ = write!(
2898 out,
2899 "Content-Type: multipart/mixed; boundary=\"{boundary}\"\n\n"
2900 );
2901 let _ = writeln!(out, "--{boundary}");
2902 render_eml_bodies(mail, &mut out);
2903 for attachment in &mail.attachments {
2904 let _ = writeln!(out, "--{boundary}");
2905 out.push_str("Content-Type: ");
2906 let content_type = strip_header_controls(&attachment.content_type);
2907 if ContentType::parse(&content_type).is_ok() {
2908 out.push_str(&content_type);
2909 } else {
2910 out.push_str("application/octet-stream");
2911 }
2912 out.push('\n');
2913 out.push_str("Content-Disposition: attachment; ");
2914 out.push_str(&content_disposition_params(&attachment.filename));
2915 out.push('\n');
2916 out.push_str("Content-Transfer-Encoding: base64\n\n");
2917 out.push_str(&base64_wrap76(&attachment.bytes));
2918 out.push('\n');
2919 }
2920 let _ = writeln!(out, "--{boundary}--");
2921 }
2922 out
2923}
2924
2925fn render_eml_bodies(mail: &Mail, out: &mut String) {
2930 if mail.html.is_some() && mail.text.is_some() {
2931 out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
2932 if let Some(text) = &mail.text {
2933 out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
2934 out.push_str(text);
2935 out.push('\n');
2936 }
2937 if let Some(html) = &mail.html {
2938 out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
2939 out.push_str(html);
2940 out.push('\n');
2941 }
2942 out.push_str("--autumn-mail--\n");
2943 } else if let Some(html) = &mail.html {
2944 out.push_str("Content-Type: text/html; charset=utf-8\n\n");
2945 out.push_str(html);
2946 out.push('\n');
2947 } else if let Some(text) = &mail.text {
2948 out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
2949 out.push_str(text);
2950 out.push('\n');
2951 }
2952}
2953
2954#[derive(Debug, Clone)]
2955struct ParsedMail {
2956 headers: Vec<(String, String)>,
2957 to: Vec<String>,
2958 subject: String,
2959 date: Option<String>,
2960 html: Option<String>,
2961 text: Option<String>,
2962 attachments: Vec<ParsedAttachment>,
2963 raw: String,
2964}
2965
2966impl ParsedMail {
2967 fn header_value(&self, name: &str) -> Option<&str> {
2968 self.headers
2969 .iter()
2970 .find(|(header, _)| header.eq_ignore_ascii_case(name))
2971 .map(|(_, value)| value.as_str())
2972 }
2973}
2974
2975#[derive(Debug, Clone)]
2978struct ParsedAttachment {
2979 filename: String,
2980 content_type: String,
2981}
2982
2983#[derive(Debug, Clone)]
2984struct CapturedMailSummary {
2985 id: String,
2986 to: Vec<String>,
2987 subject: String,
2988 timestamp: String,
2989 modified: SystemTime,
2990}
2991
2992pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
2993where
2994 S: Clone + Send + Sync + 'static,
2995 AppState: axum::extract::FromRef<S>,
2996{
2997 let file_dir = Arc::new(file_dir);
2998 axum::Router::new()
2999 .route(
3000 MAIL_PREVIEW_PATH,
3001 axum::routing::get({
3002 let file_dir = Arc::clone(&file_dir);
3003 move |axum::extract::State(state): axum::extract::State<AppState>| {
3004 let file_dir = Arc::clone(&file_dir);
3005 async move { list_mail_preview(file_dir, state).await }
3006 }
3007 }),
3008 )
3009 .route(
3010 MAIL_PREVIEW_MESSAGE_PATH,
3011 axum::routing::get({
3012 let file_dir = Arc::clone(&file_dir);
3013 move |axum::extract::Path(message_id): axum::extract::Path<String>| {
3014 let file_dir = Arc::clone(&file_dir);
3015 async move { show_captured_mail(file_dir, message_id).await }
3016 }
3017 }),
3018 )
3019 .route(
3020 MAIL_PREVIEW_TEMPLATE_PATH,
3021 axum::routing::get(
3022 |axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
3023 axum::extract::State(state): axum::extract::State<AppState>| async move {
3024 show_template_preview(&state, &mailer, &method)
3025 },
3026 ),
3027 )
3028}
3029
3030async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
3031 match captured_messages(&file_dir).await {
3032 Ok(messages) => {
3033 let previews = state
3034 .extension::<MailPreviewRegistry>()
3035 .map(|registry| registry.previews().to_vec())
3036 .unwrap_or_default();
3037 html_response(render_mail_index(&messages, &previews, &file_dir))
3038 }
3039 Err(error) => preview_error_response(&error),
3040 }
3041}
3042
3043async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
3044 match read_captured_message(&file_dir, &message_id).await {
3045 Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
3046 Err(error) => preview_error_response(&error),
3047 }
3048}
3049
3050fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
3051 let preview = state
3052 .extension::<MailPreviewRegistry>()
3053 .and_then(|registry| registry.find(mailer, method));
3054 let Some(preview) = preview else {
3055 return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
3056 };
3057
3058 match preview.render() {
3059 Ok(mail) => {
3060 let mut mail = apply_preview_unsubscribe_headers(state, mailer, mail);
3061 if let Some(m) = state.extension::<Mailer>() {
3065 let _ = m.apply_css_inlining(&mut mail);
3068 }
3069 let raw = render_eml(&mail);
3070 let parsed = parse_eml(&raw);
3071 html_response(render_mail_detail(&parsed, "Template preview"))
3072 }
3073 Err(error) => preview_error_response(&error),
3074 }
3075}
3076
3077fn apply_preview_unsubscribe_headers(state: &AppState, mailer_label: &str, mut mail: Mail) -> Mail {
3081 let scope = mail.list_unsubscribe.clone().or_else(|| {
3082 registered_list_unsubscribe_scopes()
3083 .into_iter()
3084 .find(|descriptor| descriptor.mailer == mailer_label)
3085 .map(|descriptor| descriptor.scope.to_owned())
3086 });
3087 let Some(scope) = scope else {
3088 return mail;
3089 };
3090 mail.list_unsubscribe = Some(scope.clone());
3091 let recipient = mail.to.first().map_or_else(
3092 || "subscriber@example.com".to_owned(),
3093 |to| canonical_subscriber(to),
3094 );
3095 let (header, one_click) = state.extension::<UnsubscribeRuntime>().map_or_else(
3099 || {
3100 let sample = UnsubscribeRuntime {
3101 base_url: Some("https://example.com".to_owned()),
3102 mailto: None,
3103 signing_keys: Arc::new(crate::security::config::resolve_signing_keys(
3104 &crate::security::config::SigningSecretConfig::default(),
3105 )),
3106 ttl_days: unsubscribe::DEFAULT_TOKEN_TTL_DAYS,
3107 suppression: None,
3108 };
3109 (
3110 sample.list_unsubscribe_header(&recipient, &scope),
3111 sample.supports_one_click(),
3112 )
3113 },
3114 |runtime| {
3115 (
3116 runtime.list_unsubscribe_header(&recipient, &scope),
3117 runtime.supports_one_click(),
3118 )
3119 },
3120 );
3121 if let Some(value) = header {
3122 mail.extra_headers.retain(|(name, _)| {
3125 !name.eq_ignore_ascii_case("List-Unsubscribe")
3126 && !name.eq_ignore_ascii_case("List-Unsubscribe-Post")
3127 });
3128 mail.extra_headers
3129 .push(("List-Unsubscribe".to_owned(), value));
3130 if one_click {
3131 mail.extra_headers.push((
3132 "List-Unsubscribe-Post".to_owned(),
3133 "List-Unsubscribe=One-Click".to_owned(),
3134 ));
3135 }
3136 }
3137 mail
3138}
3139
3140async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
3141 let mut entries = match tokio::fs::read_dir(dir).await {
3142 Ok(entries) => entries,
3143 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
3144 Err(error) => return Err(error.into()),
3145 };
3146
3147 let mut messages = Vec::new();
3148 while let Some(entry) = entries.next_entry().await? {
3149 let path = entry.path();
3150 if !path
3151 .extension()
3152 .and_then(|ext| ext.to_str())
3153 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
3154 {
3155 continue;
3156 }
3157 let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
3158 continue;
3159 };
3160 let metadata = entry.metadata().await?;
3161 let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
3162 let raw = tokio::fs::read_to_string(&path).await?;
3163 let parsed = parse_eml(&raw);
3164 messages.push(CapturedMailSummary {
3165 id: id.to_owned(),
3166 to: parsed.to,
3167 subject: parsed.subject,
3168 timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
3169 modified,
3170 });
3171 }
3172
3173 messages.sort_by(|left, right| {
3174 right
3175 .modified
3176 .cmp(&left.modified)
3177 .then_with(|| right.id.cmp(&left.id))
3178 });
3179 Ok(messages)
3180}
3181
3182async fn read_captured_message(
3183 dir: &Path,
3184 message_id: &str,
3185) -> Result<ParsedMail, MailPreviewError> {
3186 if !valid_message_id(message_id) {
3187 return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
3188 }
3189 let path = dir.join(message_id);
3190 let raw = match tokio::fs::read_to_string(&path).await {
3191 Ok(raw) => raw,
3192 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3193 return Err(MailPreviewError::NotFound(message_id.to_owned()));
3194 }
3195 Err(error) => return Err(error.into()),
3196 };
3197 Ok(parse_eml(&raw))
3198}
3199
3200fn valid_message_id(message_id: &str) -> bool {
3201 !message_id.is_empty()
3202 && Path::new(message_id)
3203 .extension()
3204 .and_then(|ext| ext.to_str())
3205 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
3206 && !message_id.contains('/')
3207 && !message_id.contains('\\')
3208 && !message_id.contains("..")
3209}
3210
3211fn parse_eml(raw: &str) -> ParsedMail {
3212 let normalized = raw.replace("\r\n", "\n");
3213 let (headers, body) = split_headers_body(&normalized);
3214 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
3215 let (html, text, attachments) = parse_mail_body(&content_type, body);
3216 let to = header_values(&headers, "To");
3217 let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
3218 let date = header_value(&headers, "Date");
3219
3220 ParsedMail {
3221 headers,
3222 to,
3223 subject,
3224 date,
3225 html,
3226 text,
3227 attachments,
3228 raw: raw.to_owned(),
3229 }
3230}
3231
3232fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
3233 let Some((header_block, body)) = raw.split_once("\n\n") else {
3234 return (parse_header_block(raw), "");
3235 };
3236 (parse_header_block(header_block), body)
3237}
3238
3239fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
3240 let mut headers = Vec::new();
3241 let mut current: Option<(String, String)> = None;
3242
3243 for line in header_block.lines() {
3244 if line.starts_with(' ') || line.starts_with('\t') {
3245 if let Some((_, value)) = current.as_mut() {
3246 value.push(' ');
3247 value.push_str(line.trim());
3248 }
3249 continue;
3250 }
3251 if let Some(header) = current.take() {
3252 headers.push(header);
3253 }
3254 if let Some((name, value)) = line.split_once(':') {
3255 current = Some((name.trim().to_owned(), value.trim().to_owned()));
3256 }
3257 }
3258 if let Some(header) = current {
3259 headers.push(header);
3260 }
3261 headers
3262}
3263
3264fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
3265 headers
3266 .iter()
3267 .find(|(header, _)| header.eq_ignore_ascii_case(name))
3268 .map(|(_, value)| value.clone())
3269}
3270
3271fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
3272 headers
3273 .iter()
3274 .filter(|(header, _)| header.eq_ignore_ascii_case(name))
3275 .map(|(_, value)| value.clone())
3276 .collect()
3277}
3278
3279fn parse_mail_body(
3280 content_type: &str,
3281 body: &str,
3282) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
3283 let lower = content_type.to_ascii_lowercase();
3284 if lower.contains("multipart/mixed")
3285 && let Some(boundary) = content_type_boundary(content_type)
3286 {
3287 return parse_multipart_mixed(body, &boundary);
3288 }
3289
3290 if lower.contains("multipart/alternative")
3291 && let Some(boundary) = content_type_boundary(content_type)
3292 {
3293 let (html, text) = parse_multipart_alternative(body, &boundary);
3294 return (html, text, Vec::new());
3295 }
3296
3297 if lower.contains("text/html") {
3298 (Some(trim_body(body)), None, Vec::new())
3299 } else {
3300 (None, Some(trim_body(body)), Vec::new())
3301 }
3302}
3303
3304fn parse_multipart_mixed(
3309 body: &str,
3310 boundary: &str,
3311) -> (Option<String>, Option<String>, Vec<ParsedAttachment>) {
3312 let marker = format!("--{boundary}");
3313 let mut html = None;
3314 let mut text = None;
3315 let mut attachments = Vec::new();
3316
3317 for segment in body.split(&marker).skip(1) {
3318 let segment = segment.trim_start_matches(['\n', '\r']);
3319 if segment.starts_with("--") {
3320 break;
3321 }
3322 let (headers, part_body) = split_headers_body(segment);
3323 let disposition = header_value(&headers, "Content-Disposition").unwrap_or_default();
3324 let part_content_type = header_value(&headers, "Content-Type").unwrap_or_default();
3325 let disposition_type = split_mime_params(&disposition)
3326 .first()
3327 .copied()
3328 .unwrap_or("");
3329 if disposition_type.eq_ignore_ascii_case("attachment") {
3330 attachments.push(ParsedAttachment {
3331 filename: extract_attachment_filename(&disposition),
3332 content_type: content_type_without_params(&part_content_type),
3333 });
3334 } else {
3335 let (nested_html, nested_text, _) = parse_mail_body(&part_content_type, part_body);
3336 html = html.or(nested_html);
3337 text = text.or(nested_text);
3338 }
3339 }
3340
3341 (html, text, attachments)
3342}
3343
3344fn split_mime_params(value: &str) -> Vec<&str> {
3348 let mut parts = Vec::new();
3349 let mut rest = value;
3350 while let Some(sep) = unquoted_semicolon(rest) {
3356 let Some((param, after)) = rest.split_at_checked(sep) else {
3357 break;
3358 };
3359 parts.push(param.trim());
3360 rest = after.strip_prefix(';').unwrap_or(after);
3361 }
3362 parts.push(rest.trim());
3363 parts
3364}
3365
3366fn unquoted_semicolon(value: &str) -> Option<usize> {
3369 let mut in_quotes = false;
3370 let mut escaped = false;
3371 for (i, ch) in value.char_indices() {
3372 if escaped {
3373 escaped = false;
3374 continue;
3375 }
3376 match ch {
3377 '\\' if in_quotes => escaped = true,
3378 '"' => in_quotes = !in_quotes,
3379 ';' if !in_quotes => return Some(i),
3380 _ => {}
3381 }
3382 }
3383 None
3384}
3385
3386fn unescape_quoted_string(value: &str) -> String {
3389 let mut result = String::with_capacity(value.len());
3390 let mut chars = value.chars();
3391 while let Some(ch) = chars.next() {
3392 if ch == '\\'
3393 && let Some(next) = chars.next()
3394 {
3395 result.push(next);
3396 } else {
3397 result.push(ch);
3398 }
3399 }
3400 result
3401}
3402
3403fn extract_attachment_filename(disposition: &str) -> String {
3409 let params = split_mime_params(disposition);
3410 if let Some(value) = params.iter().skip(1).find_map(|part| {
3411 let (key, val) = part.split_once('=')?;
3412 key.trim().eq_ignore_ascii_case("filename*").then_some(val)
3413 }) {
3414 let encoded = value.splitn(3, '\'').nth(2).unwrap_or(value);
3415 return percent_encoding::percent_decode_str(encoded)
3416 .decode_utf8_lossy()
3417 .into_owned();
3418 }
3419 if let Some(value) = params.iter().skip(1).find_map(|part| {
3420 let (key, val) = part.split_once('=')?;
3421 key.trim()
3422 .eq_ignore_ascii_case("filename")
3423 .then_some(val.trim())
3424 }) {
3425 if let Some(inner) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
3426 return unescape_quoted_string(inner);
3427 }
3428 return value.to_owned();
3429 }
3430 "attachment".to_owned()
3431}
3432
3433fn content_type_without_params(content_type: &str) -> String {
3434 content_type
3435 .split(';')
3436 .next()
3437 .unwrap_or(content_type)
3438 .trim()
3439 .to_owned()
3440}
3441
3442fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
3443 let marker = format!("--{boundary}");
3444 let mut html = None;
3445 let mut text = None;
3446
3447 for segment in body.split(&marker).skip(1) {
3448 let segment = segment.trim_start_matches(['\n', '\r']);
3449 if segment.starts_with("--") {
3450 break;
3451 }
3452 let (headers, part_body) = split_headers_body(segment);
3453 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
3454 if content_type.to_ascii_lowercase().contains("text/html") {
3455 html = Some(trim_body(part_body));
3456 } else if content_type.to_ascii_lowercase().contains("text/plain") {
3457 text = Some(trim_body(part_body));
3458 }
3459 }
3460
3461 (html, text)
3462}
3463
3464fn content_type_boundary(content_type: &str) -> Option<String> {
3465 content_type.split(';').find_map(|part| {
3466 let part = part.trim();
3467 let (name, value) = part.split_once('=')?;
3468 if !name.trim().eq_ignore_ascii_case("boundary") {
3469 return None;
3470 }
3471 Some(value.trim().trim_matches('"').to_owned())
3472 })
3473}
3474
3475fn trim_body(body: &str) -> String {
3476 body.trim_matches(['\r', '\n']).to_owned()
3477}
3478
3479fn format_system_time(time: SystemTime) -> String {
3480 let datetime: chrono::DateTime<chrono::Utc> = time.into();
3481 datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
3482}
3483
3484fn render_mail_index(
3485 messages: &[CapturedMailSummary],
3486 previews: &[MailPreview],
3487 file_dir: &Path,
3488) -> String {
3489 let mut body = String::new();
3490 body.push_str("<h1>Autumn Mail</h1>");
3491 body.push_str("<section><h2>Captured messages</h2>");
3492 if messages.is_empty() {
3493 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>");
3494 body.push_str(&escape_html(&file_dir.display().to_string()));
3495 body.push_str("</code>.</p>");
3496 } else {
3497 body.push_str(
3498 "<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
3499 );
3500 for message in messages {
3501 body.push_str("<tr><td>");
3502 body.push_str(&escape_html(&message.timestamp));
3503 body.push_str("</td><td>");
3504 body.push_str(&escape_html(&message.to.join(", ")));
3505 body.push_str("</td><td><a href=\"");
3506 body.push_str(MAIL_PREVIEW_PATH);
3507 body.push_str("/messages/");
3508 body.push_str(&escape_html(&message.id));
3509 body.push_str("\">");
3510 body.push_str(&escape_html(&message.subject));
3511 body.push_str("</a></td></tr>");
3512 }
3513 body.push_str("</tbody></table>");
3514 }
3515 body.push_str("</section><section><h2>Template previews</h2>");
3516 if previews.is_empty() {
3517 body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
3518 } else {
3519 body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
3520 for preview in previews {
3521 body.push_str("<tr><td>");
3522 body.push_str(&escape_html(preview.mailer()));
3523 body.push_str("</td><td><a href=\"");
3524 body.push_str(MAIL_PREVIEW_PATH);
3525 body.push_str("/previews/");
3526 body.push_str(&escape_html(preview.mailer()));
3527 body.push('/');
3528 body.push_str(&escape_html(preview.method()));
3529 body.push_str("\">");
3530 body.push_str(&escape_html(preview.method()));
3531 body.push_str("</a></td></tr>");
3532 }
3533 body.push_str("</tbody></table>");
3534 }
3535 body.push_str("</section>");
3536 render_mail_preview_layout("Autumn Mail", &body)
3537}
3538
3539fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
3540 let mut body = String::new();
3541 body.push_str("<p><a href=\"");
3542 body.push_str(MAIL_PREVIEW_PATH);
3543 body.push_str("\">Back to mail</a></p><h1>");
3544 body.push_str(&escape_html(&parsed.subject));
3545 body.push_str("</h1><p class=\"muted\">");
3546 body.push_str(&escape_html(label));
3547 body.push_str("</p>");
3548
3549 if let Some(html) = &parsed.html {
3550 body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
3551 body.push_str(&escape_html(html));
3552 body.push_str("\"></iframe>");
3553 } else {
3554 body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
3555 }
3556
3557 body.push_str("<details><summary>Plain text</summary><pre>");
3558 body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
3559 body.push_str("</pre></details>");
3560
3561 if !parsed.attachments.is_empty() {
3562 body.push_str("<details open><summary>Attachments (");
3563 body.push_str(&parsed.attachments.len().to_string());
3564 body.push_str(")</summary><ul>");
3565 for attachment in &parsed.attachments {
3566 body.push_str("<li>");
3567 body.push_str(&escape_html(&attachment.filename));
3568 body.push_str(" <span class=\"muted\">(");
3569 body.push_str(&escape_html(&attachment.content_type));
3570 body.push_str(")</span></li>");
3571 }
3572 body.push_str("</ul></details>");
3573 }
3574
3575 body.push_str("<details><summary>Headers</summary><dl>");
3576 for header in [
3577 "From",
3578 "To",
3579 "Reply-To",
3580 "Subject",
3581 "Date",
3582 "Message-Id",
3583 "List-Unsubscribe",
3584 "List-Unsubscribe-Post",
3585 ] {
3586 if let Some(value) = parsed.header_value(header) {
3587 body.push_str("<dt>");
3588 body.push_str(header);
3589 body.push_str("</dt><dd>");
3590 body.push_str(&escape_html(value));
3591 body.push_str("</dd>");
3592 }
3593 }
3594 body.push_str("</dl></details>");
3595
3596 body.push_str("<details><summary>Raw .eml</summary><pre>");
3597 body.push_str(&escape_html(&parsed.raw));
3598 body.push_str("</pre></details>");
3599
3600 render_mail_preview_layout(&parsed.subject, &body)
3601}
3602
3603fn render_mail_preview_layout(title: &str, body: &str) -> String {
3604 format!(
3605 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
3606 escape_html(title),
3607 MAIL_PREVIEW_CSS,
3608 body
3609 )
3610}
3611
3612const MAIL_PREVIEW_CSS: &str = r#"
3613body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
3614h1{margin:0 0 16px;font-size:28px}
3615h2{margin:28px 0 12px;font-size:18px}
3616table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
3617th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
3618th{background:#edf2f7;color:#394b59;font-weight:650}
3619a{color:#0b63ce;text-decoration:none}
3620a:hover{text-decoration:underline}
3621.empty,.muted{color:#52616f}
3622code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
3623pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
3624iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
3625details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
3626summary{cursor:pointer;font-weight:650}
3627dt{font-weight:650;margin-top:8px}
3628dd{margin:2px 0 8px}
3629"#;
3630
3631fn html_response(html: String) -> Response {
3632 Html(html).into_response()
3633}
3634
3635fn preview_error_response(error: &MailPreviewError) -> Response {
3636 let status = match error {
3637 MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
3638 http::StatusCode::NOT_FOUND
3639 }
3640 MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
3641 http::StatusCode::INTERNAL_SERVER_ERROR
3642 }
3643 };
3644 (
3645 status,
3646 Html(render_mail_preview_layout(
3647 "Mail preview error",
3648 &format!(
3649 "<h1>Mail preview error</h1><p>{}</p>",
3650 escape_html(&error.to_string())
3651 ),
3652 )),
3653 )
3654 .into_response()
3655}
3656
3657fn escape_html(value: &str) -> String {
3658 let mut escaped = String::with_capacity(value.len());
3659 for ch in value.chars() {
3660 match ch {
3661 '&' => escaped.push_str("&"),
3662 '<' => escaped.push_str("<"),
3663 '>' => escaped.push_str(">"),
3664 '"' => escaped.push_str("""),
3665 '\'' => escaped.push_str("'"),
3666 _ => escaped.push(ch),
3667 }
3668 }
3669 escaped
3670}
3671
3672fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
3673 address.parse().map_err(|source| MailError::InvalidAddress {
3674 address: address.to_owned(),
3675 source,
3676 })
3677}
3678
3679fn canonical_subscriber(recipient: &str) -> String {
3685 parse_mailbox(recipient).map_or_else(
3686 |_| recipient.trim().to_ascii_lowercase(),
3687 |mailbox| mailbox.email.to_string().to_ascii_lowercase(),
3688 )
3689}
3690
3691enum MailBodyPart {
3697 Single(SinglePart),
3698 Multi(MultiPart),
3699}
3700
3701fn lettre_body_part(mail: &Mail) -> Result<MailBodyPart, MailError> {
3702 match (&mail.text, &mail.html) {
3703 (Some(text), Some(html)) => Ok(MailBodyPart::Multi(
3704 MultiPart::alternative()
3705 .singlepart(SinglePart::plain(text.clone()))
3706 .singlepart(SinglePart::html(html.clone())),
3707 )),
3708 (Some(text), None) => Ok(MailBodyPart::Single(SinglePart::plain(text.clone()))),
3709 (None, Some(html)) => Ok(MailBodyPart::Single(SinglePart::html(html.clone()))),
3710 (None, None) => Err(MailError::InvalidMessage(
3711 "mail must include html or text body".to_owned(),
3712 )),
3713 }
3714}
3715
3716fn lettre_attachment_part(attachment: &MailAttachment) -> Result<SinglePart, MailError> {
3717 let content_type = ContentType::parse(&attachment.content_type).map_err(|error| {
3718 MailError::InvalidMessage(format!(
3719 "attachment {:?} has invalid content type {:?}: {error}",
3720 attachment.filename, attachment.content_type
3721 ))
3722 })?;
3723 #[allow(
3727 clippy::expect_used,
3728 reason = "infallible: base64 encoding is always valid for any byte buffer"
3729 )]
3730 let body =
3731 LettreBody::new_with_encoding(attachment.bytes.clone(), ContentTransferEncoding::Base64)
3732 .expect("base64 encoding is always valid for any byte buffer");
3733 Ok(LettreAttachment::new(attachment.filename.clone()).body(body, content_type))
3734}
3735
3736fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
3737 let from = mail
3738 .from
3739 .as_deref()
3740 .ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
3741 let mut builder = Message::builder().from(parse_mailbox(from)?);
3742 for to in &mail.to {
3743 builder = builder.to(parse_mailbox(to)?);
3744 }
3745 if let Some(reply_to) = &mail.reply_to {
3746 builder = builder.reply_to(parse_mailbox(reply_to)?);
3747 }
3748 builder = builder.subject(mail.subject.clone());
3749
3750 for (name, value) in &mail.extra_headers {
3751 use lettre::message::header::{HeaderName, HeaderValue};
3752 match HeaderName::new_from_ascii(name.clone()) {
3753 Ok(header_name) => {
3754 builder = builder.raw_header(HeaderValue::new(header_name, value.clone()));
3755 }
3756 Err(error) => {
3757 tracing::warn!(
3758 header_name = %name,
3759 error = %error,
3760 "skipping mail header with invalid name"
3761 );
3762 }
3763 }
3764 }
3765
3766 let body_part = lettre_body_part(mail)?;
3767
3768 if mail.attachments.is_empty() {
3769 return Ok(match body_part {
3770 MailBodyPart::Multi(multi) => builder.multipart(multi)?,
3771 MailBodyPart::Single(single) => builder.singlepart(single)?,
3772 });
3773 }
3774
3775 let mut mixed = match body_part {
3776 MailBodyPart::Multi(multi) => MultiPart::mixed().multipart(multi),
3777 MailBodyPart::Single(single) => MultiPart::mixed().singlepart(single),
3778 };
3779 for attachment in &mail.attachments {
3780 mixed = mixed.singlepart(lettre_attachment_part(attachment)?);
3781 }
3782 Ok(builder.multipart(mixed)?)
3783}
3784
3785struct InterceptedMailTransport {
3786 inner: Arc<dyn MailTransport>,
3787 interceptor: Arc<dyn crate::interceptor::MailInterceptor>,
3788}
3789
3790impl MailTransport for InterceptedMailTransport {
3791 fn send<'a>(
3792 &'a self,
3793 mail: Mail,
3794 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3795 Box::pin(async move {
3796 let inner = Arc::clone(&self.inner);
3797 let mail_for_next = mail.clone();
3798 let next = Box::pin(async move { inner.send(mail_for_next).await });
3799 self.interceptor.intercept(&mail, next).await
3800 })
3801 }
3802
3803 fn is_disabled(&self) -> bool {
3804 self.inner.is_disabled()
3805 }
3806}
3807
3808#[cfg(feature = "reporting")]
3813pub(crate) fn install_replay_mailer(state: &AppState, config: &MailConfig, builder_queue: bool) {
3814 let installed = state.extension::<Mailer>();
3820 let disabled = installed.as_ref().map_or_else(
3821 || config.transport == Transport::Disabled,
3822 |mailer| mailer.is_disabled(),
3823 );
3824 let durable = installed.as_ref().map_or_else(
3829 || !disabled && (builder_queue || state.extension::<MailDeliveryQueueHandle>().is_some()),
3830 |mailer| mailer.has_durable_delivery_queue(),
3831 );
3832 let mut mailer = Mailer::with_transport(ReplayTransport { disabled });
3833 if durable {
3834 mailer.delivery_queue = Some(Arc::new(ReplayTransport { disabled }));
3835 }
3836 if let Some(installed) = installed {
3837 mailer.defaults = Arc::clone(&installed.defaults);
3838 mailer.inline_css_default = installed.inline_css_default;
3839 } else {
3840 mailer.defaults = Arc::new(MailerDefaults {
3841 from: config.from.clone(),
3842 reply_to: config.reply_to.clone(),
3843 });
3844 mailer.inline_css_default = config.inline_css;
3845 }
3846 state.insert_extension(mailer);
3847}
3848
3849#[cfg(feature = "reporting")]
3854struct ReplayTransport {
3855 disabled: bool,
3856}
3857
3858#[cfg(feature = "reporting")]
3859impl MailDeliveryQueue for ReplayTransport {
3860 fn enqueue<'a>(
3861 &'a self,
3862 mail: Mail,
3863 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3864 self.send(mail)
3865 }
3866}
3867
3868#[cfg(feature = "reporting")]
3869impl MailTransport for ReplayTransport {
3870 fn send<'a>(
3871 &'a self,
3872 mail: Mail,
3873 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
3874 Box::pin(async move {
3877 Err(MailError::RuntimeUnavailable(format!(
3878 "mail to {} recipient(s) was sent outside the capsule's replay scope; nothing was \
3879 delivered",
3880 mail.to.len()
3881 )))
3882 })
3883 }
3884
3885 fn is_disabled(&self) -> bool {
3886 self.disabled
3887 }
3888}
3889
3890#[allow(clippy::too_many_lines)]
3910pub(crate) fn install_mailer(
3911 state: &AppState,
3912 config: &MailConfig,
3913 enforce_durable_guard: bool,
3914) -> AutumnResult<()> {
3915 let resilience = state
3916 .extension::<crate::config::AutumnConfig>()
3917 .map(|c| Arc::new(c.resilience.clone()));
3918 let mut mailer =
3919 Mailer::from_config_inner(config, resilience).map_err(AutumnError::service_unavailable)?;
3920
3921 if let Some(interceptor) = state.extension::<Arc<dyn crate::interceptor::MailInterceptor>>() {
3922 mailer.transport = Arc::new(InterceptedMailTransport {
3923 inner: Arc::clone(&mailer.transport),
3924 interceptor: (*interceptor).clone(),
3925 });
3926 }
3927
3928 let in_production = matches!(state.profile(), "prod" | "production");
3929 let transport_sends_mail = config.transport != Transport::Disabled;
3930
3931 if transport_sends_mail {
3935 let queue_handle = state.extension::<MailDeliveryQueueHandle>();
3936 if let Some(handle) = queue_handle.as_ref() {
3937 mailer.delivery_queue = Some(Arc::clone(handle.inner()));
3938 }
3939 }
3940
3941 if enforce_durable_guard && in_production && transport_sends_mail {
3942 let has_durable_queue = mailer.delivery_queue.is_some();
3943 if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
3944 tracing::warn!(
3949 "mail.deliver_later has no durable backend in prod: deliver_later/deliver_later_eager will fail if called; \
3950 register a MailDeliveryQueueHandle on AppState, or set mail.allow_in_process_deliver_later_in_production = true \
3951 to opt into the non-durable in-process Tokio fallback. Apps that only use mail.send() are unaffected."
3952 );
3953 mailer.block_deliver_later_without_durable_queue = true;
3954 } else if !has_durable_queue {
3955 tracing::warn!(
3956 "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"
3957 );
3958 }
3959 }
3960
3961 let base_url = config
3963 .unsubscribe_base_url
3964 .as_deref()
3965 .map(str::trim)
3966 .filter(|s| !s.is_empty());
3967 let mailto = config
3968 .unsubscribe_mailto
3969 .as_deref()
3970 .map(str::trim)
3971 .filter(|s| !s.is_empty());
3972 let unsubscribe_configured = base_url.is_some() || mailto.is_some();
3973
3974 let suppression: Option<Arc<dyn SuppressionStore>> = {
3977 let explicit = state
3978 .extension::<SuppressionStoreHandle>()
3979 .map(|handle| Arc::clone(handle.inner()));
3980 #[cfg(feature = "db")]
3981 let resolved = explicit.or_else(|| {
3982 state
3983 .pool()
3984 .map(|pool| Arc::new(db_suppression::DbSuppressionStore::new(pool.clone())) as _)
3985 });
3986 #[cfg(not(feature = "db"))]
3987 let resolved = explicit;
3988 resolved
3989 };
3990
3991 if transport_sends_mail
3996 && unsubscribe_config_fail_closed(
3997 enforce_durable_guard,
3998 in_production,
3999 has_list_unsubscribe_mailers(),
4000 unsubscribe_configured,
4001 )
4002 {
4003 return Err(AutumnError::service_unavailable_msg(
4004 "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",
4005 ));
4006 }
4007
4008 if enforce_durable_guard
4012 && in_production
4013 && transport_sends_mail
4014 && has_list_unsubscribe_mailers()
4015 && base_url.is_some()
4016 && suppression.is_none()
4017 {
4018 return Err(AutumnError::service_unavailable_msg(
4019 "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",
4020 ));
4021 }
4022
4023 if in_production
4027 && transport_sends_mail
4028 && has_list_unsubscribe_mailers()
4029 && base_url.is_some()
4030 && !config.mount_unsubscribe_endpoint
4031 {
4032 tracing::warn!(
4033 target: "mail",
4034 path = UNSUBSCRIBE_PATH,
4035 "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"
4036 );
4037 }
4038
4039 if unsubscribe_configured || suppression.is_some() {
4040 let signing_keys = Arc::new(crate::security::config::resolve_signing_keys(
4041 &state
4042 .extension::<crate::config::AutumnConfig>()
4043 .map(|c| c.security.signing_secret.clone())
4044 .unwrap_or_default(),
4045 ));
4046 let ttl_days = config.unsubscribe_token_ttl_days;
4047 let make_runtime = || UnsubscribeRuntime {
4048 base_url: base_url.map(str::to_owned),
4049 mailto: mailto.map(str::to_owned),
4050 signing_keys: Arc::clone(&signing_keys),
4051 ttl_days,
4052 suppression: suppression.clone(),
4053 };
4054 state.insert_extension(make_runtime());
4059 if transport_sends_mail {
4060 mailer.unsubscribe = Some(Arc::new(make_runtime()));
4061 }
4062 }
4063
4064 if transport_sends_mail {
4076 let handle = state
4077 .extension::<suppression::SuppressionStoreHandle>()
4078 .map_or_else(
4079 || {
4080 let handle = suppression::SuppressionStoreHandle::new(
4081 suppression::InMemorySuppressionStore::new(),
4082 );
4083 state.insert_extension(handle.clone());
4084 handle
4085 },
4086 |arc| (*arc).clone(),
4087 );
4088 mailer.suppression = Some(Arc::clone(handle.inner()));
4089 }
4090
4091 state.insert_extension(mailer);
4092 Ok(())
4093}
4094
4095pub(crate) fn install_mailer_with_factory<F>(
4110 state: &AppState,
4111 config: &MailConfig,
4112 queue_factory: Option<F>,
4113 enforce_durable_guard: bool,
4114) -> AutumnResult<()>
4115where
4116 F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
4117{
4118 let transport_sends_mail = config.transport != Transport::Disabled;
4122 if enforce_durable_guard
4123 && transport_sends_mail
4124 && let Some(factory) = queue_factory
4125 {
4126 let queue = factory(state)?;
4127 state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
4128 }
4129 install_mailer(state, config, enforce_durable_guard)
4130}
4131
4132#[derive(Deserialize)]
4135struct UnsubscribeParams {
4136 #[serde(default)]
4137 token: String,
4138}
4139
4140pub(crate) fn unsubscribe_router() -> axum::Router<AppState> {
4147 axum::Router::new().route(
4148 UNSUBSCRIBE_PATH,
4149 axum::routing::get(unsubscribe_get_handler).post(unsubscribe_post_handler),
4150 )
4151}
4152
4153async fn unsubscribe_post_handler(
4155 axum::extract::State(state): axum::extract::State<AppState>,
4156 axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
4157 body: String,
4158) -> Response {
4159 if !is_one_click_body(&body) {
4163 return (
4164 axum::http::StatusCode::BAD_REQUEST,
4165 "expected List-Unsubscribe=One-Click body",
4166 )
4167 .into_response();
4168 }
4169 let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
4170 return (
4171 axum::http::StatusCode::NOT_FOUND,
4172 "unsubscribe is not configured",
4173 )
4174 .into_response();
4175 };
4176 match unsubscribe::verify_token(&runtime.signing_keys, ¶ms.token, current_unix_time()) {
4177 Ok(decoded) => {
4178 let Some(store) = runtime.suppression.as_ref() else {
4179 tracing::error!(
4182 target: "mail",
4183 "unsubscribe POST received but no suppression backend is configured"
4184 );
4185 return (
4186 axum::http::StatusCode::SERVICE_UNAVAILABLE,
4187 "unsubscribe storage is not configured",
4188 )
4189 .into_response();
4190 };
4191 if let Err(error) = store.suppress(&decoded.subscriber, &decoded.list_id).await {
4192 tracing::error!(error = %error, "failed to record unsubscribe suppression");
4193 return (
4194 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
4195 "could not process unsubscribe",
4196 )
4197 .into_response();
4198 }
4199 tracing::info!(
4200 target: "mail",
4201 list_id = %decoded.list_id,
4202 outcome = "unsubscribed",
4203 "recorded one-click unsubscribe"
4204 );
4205 (
4206 axum::http::StatusCode::OK,
4207 Html(unsubscribe_confirmation_html(&decoded.list_id)),
4208 )
4209 .into_response()
4210 }
4211 Err(error) => (
4212 axum::http::StatusCode::BAD_REQUEST,
4213 Html(unsubscribe_error_html(&error.to_string())),
4214 )
4215 .into_response(),
4216 }
4217}
4218
4219fn is_one_click_body(body: &str) -> bool {
4221 body.split('&').any(|pair| {
4222 let mut kv = pair.splitn(2, '=');
4223 let key = kv.next().unwrap_or("");
4224 let value = kv.next().unwrap_or("");
4225 key.eq_ignore_ascii_case("List-Unsubscribe") && value.eq_ignore_ascii_case("One-Click")
4226 })
4227}
4228
4229async fn unsubscribe_get_handler(
4231 axum::extract::State(state): axum::extract::State<AppState>,
4232 axum::extract::Query(params): axum::extract::Query<UnsubscribeParams>,
4233) -> Response {
4234 let Some(runtime) = state.extension::<UnsubscribeRuntime>() else {
4235 return (
4236 axum::http::StatusCode::NOT_FOUND,
4237 "unsubscribe is not configured",
4238 )
4239 .into_response();
4240 };
4241 match unsubscribe::verify_token(&runtime.signing_keys, ¶ms.token, current_unix_time()) {
4242 Ok(decoded) => Html(unsubscribe_form_html(&decoded.list_id, ¶ms.token)).into_response(),
4243 Err(error) => (
4244 axum::http::StatusCode::BAD_REQUEST,
4245 Html(unsubscribe_error_html(&error.to_string())),
4246 )
4247 .into_response(),
4248 }
4249}
4250
4251fn unsubscribe_form_html(list_id: &str, token: &str) -> String {
4252 format!(
4255 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
4256 <body><h1>Unsubscribe</h1>\
4257 <p>Stop receiving <strong>{}</strong> emails?</p>\
4258 <form method=\"post\" action=\"?token={}\">\
4259 <input type=\"hidden\" name=\"List-Unsubscribe\" value=\"One-Click\">\
4260 <button type=\"submit\">Unsubscribe</button></form></body></html>",
4261 escape_html(list_id),
4262 escape_html(token),
4263 )
4264}
4265
4266fn unsubscribe_confirmation_html(list_id: &str) -> String {
4267 format!(
4268 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribed</title></head>\
4269 <body><h1>You're unsubscribed</h1>\
4270 <p>You will no longer receive <strong>{}</strong> emails.</p></body></html>",
4271 escape_html(list_id),
4272 )
4273}
4274
4275fn unsubscribe_error_html(detail: &str) -> String {
4276 format!(
4277 "<!doctype html><html><head><meta charset=\"utf-8\"><title>Unsubscribe</title></head>\
4278 <body><h1>Unsubscribe link is not valid</h1><p>{}</p></body></html>",
4279 escape_html(detail),
4280 )
4281}
4282
4283pub mod suppression {
4314 use std::future::Future;
4315 use std::pin::Pin;
4316 use std::sync::Arc;
4317 use std::sync::atomic::{AtomicU64, Ordering};
4318
4319 use super::{MailError, canonical_subscriber};
4320
4321 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
4323 #[serde(rename_all = "snake_case")]
4324 pub enum SuppressionReason {
4325 HardBounce,
4327 Complaint,
4329 Manual,
4331 }
4332
4333 impl SuppressionReason {
4334 #[must_use]
4336 pub const fn as_str(self) -> &'static str {
4337 match self {
4338 Self::HardBounce => "hard_bounce",
4339 Self::Complaint => "complaint",
4340 Self::Manual => "manual",
4341 }
4342 }
4343 }
4344
4345 impl std::fmt::Display for SuppressionReason {
4346 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4347 f.write_str(self.as_str())
4348 }
4349 }
4350
4351 pub trait SuppressionStore: Send + Sync {
4358 fn is_suppressed<'a>(
4360 &'a self,
4361 address: &'a str,
4362 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>>;
4363
4364 fn suppress<'a>(
4367 &'a self,
4368 address: &'a str,
4369 reason: SuppressionReason,
4370 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
4371
4372 fn unsuppress<'a>(
4375 &'a self,
4376 address: &'a str,
4377 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
4378 }
4379
4380 #[derive(Clone)]
4383 pub struct SuppressionStoreHandle(Arc<dyn SuppressionStore>);
4384
4385 impl SuppressionStoreHandle {
4386 #[must_use]
4388 pub fn new(store: impl SuppressionStore + 'static) -> Self {
4389 Self(Arc::new(store))
4390 }
4391
4392 #[must_use]
4394 pub fn from_arc(store: Arc<dyn SuppressionStore>) -> Self {
4395 Self(store)
4396 }
4397
4398 #[must_use]
4400 pub fn inner(&self) -> &Arc<dyn SuppressionStore> {
4401 &self.0
4402 }
4403
4404 #[must_use]
4406 pub fn into_inner(self) -> Arc<dyn SuppressionStore> {
4407 self.0
4408 }
4409 }
4410
4411 impl std::fmt::Debug for SuppressionStoreHandle {
4412 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4413 f.debug_struct("SuppressionStoreHandle")
4414 .finish_non_exhaustive()
4415 }
4416 }
4417
4418 #[derive(Debug, Default, Clone)]
4423 pub struct InMemorySuppressionStore {
4424 entries: Arc<std::sync::Mutex<std::collections::HashMap<String, SuppressionReason>>>,
4425 }
4426
4427 impl InMemorySuppressionStore {
4428 #[must_use]
4430 pub fn new() -> Self {
4431 Self::default()
4432 }
4433 }
4434
4435 impl SuppressionStore for InMemorySuppressionStore {
4436 fn is_suppressed<'a>(
4437 &'a self,
4438 address: &'a str,
4439 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4440 Box::pin(async move {
4441 let key = canonical_subscriber(address);
4442 Ok(self
4443 .entries
4444 .lock()
4445 .unwrap_or_else(std::sync::PoisonError::into_inner)
4446 .contains_key(&key))
4447 })
4448 }
4449
4450 fn suppress<'a>(
4451 &'a self,
4452 address: &'a str,
4453 reason: SuppressionReason,
4454 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4455 Box::pin(async move {
4456 let key = canonical_subscriber(address);
4457 self.entries
4458 .lock()
4459 .unwrap_or_else(std::sync::PoisonError::into_inner)
4460 .insert(key, reason);
4461 Ok(())
4462 })
4463 }
4464
4465 fn unsuppress<'a>(
4466 &'a self,
4467 address: &'a str,
4468 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4469 Box::pin(async move {
4470 let key = canonical_subscriber(address);
4471 self.entries
4472 .lock()
4473 .unwrap_or_else(std::sync::PoisonError::into_inner)
4474 .remove(&key);
4475 Ok(())
4476 })
4477 }
4478 }
4479
4480 static SUPPRESSED_SKIPS: AtomicU64 = AtomicU64::new(0);
4482
4483 #[must_use]
4488 pub fn suppressed_skips() -> u64 {
4489 SUPPRESSED_SKIPS.load(Ordering::Relaxed)
4490 }
4491
4492 pub(crate) fn note_skip(canonical_address: &str) {
4494 SUPPRESSED_SKIPS.fetch_add(1, Ordering::Relaxed);
4495 tracing::info!(
4496 target: "mail",
4497 outcome = "skipped_suppressed",
4498 address = %canonical_address,
4499 "skipping suppressed recipient (hard bounce or complaint); \
4500 pass Mail::ignore_suppression() to override for critical mail"
4501 );
4502 }
4503
4504 #[cfg(feature = "inbound-mail")]
4541 pub async fn record_inbound(
4542 store: &dyn SuppressionStore,
4543 email: &crate::inbound_mail::InboundEmail,
4544 ) -> Result<(), MailError> {
4545 if email.is_bounce {
4546 if let Some(addr) = email.bounced_address.as_deref() {
4550 store.suppress(addr, SuppressionReason::HardBounce).await?;
4551 } else {
4552 tracing::warn!(
4553 target: "mail",
4554 "inbound bounce webhook set is_bounce with no bounced_address; nothing suppressed"
4555 );
4556 }
4557 return Ok(());
4558 }
4559 if let Some(addr) = email.complained_address.as_deref() {
4564 store.suppress(addr, SuppressionReason::Complaint).await?;
4565 } else if email
4566 .spam_report
4567 .as_ref()
4568 .and_then(|r| r.verdict.as_deref())
4569 .is_some_and(|v| v.eq_ignore_ascii_case("yes"))
4570 {
4571 tracing::warn!(
4572 target: "mail",
4573 "inbound spam verdict carries no outbound complainant address; \
4574 nothing suppressed (wire a real FBL/complaint source that \
4575 populates InboundEmail::complained_address)"
4576 );
4577 }
4578 Ok(())
4579 }
4580
4581 #[cfg(feature = "postgres")]
4582 pub use pg::PgSuppressionStore;
4583
4584 #[cfg(feature = "postgres")]
4585 mod pg {
4586 use std::future::Future;
4587 use std::pin::Pin;
4588
4589 use diesel::prelude::*;
4590 use diesel_async::AsyncPgConnection;
4591 use diesel_async::RunQueryDsl;
4592 use diesel_async::pooled_connection::deadpool::Pool;
4593
4594 use super::super::canonical_subscriber;
4595 use super::{MailError, SuppressionReason, SuppressionStore};
4596
4597 diesel::table! {
4598 mail_suppressions (address) {
4599 address -> Text,
4600 reason -> Text,
4601 suppressed_at -> Timestamptz,
4602 }
4603 }
4604
4605 #[derive(Insertable)]
4606 #[diesel(table_name = mail_suppressions)]
4607 struct NewSuppression<'a> {
4608 address: &'a str,
4609 reason: &'a str,
4610 }
4611
4612 #[derive(Clone)]
4632 pub struct PgSuppressionStore {
4633 pool: Pool<AsyncPgConnection>,
4634 }
4635
4636 impl PgSuppressionStore {
4637 #[must_use]
4639 pub const fn new(pool: Pool<AsyncPgConnection>) -> Self {
4640 Self { pool }
4641 }
4642 }
4643
4644 impl SuppressionStore for PgSuppressionStore {
4645 fn is_suppressed<'a>(
4646 &'a self,
4647 address: &'a str,
4648 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4649 Box::pin(async move {
4650 let key = canonical_subscriber(address);
4651 let mut conn = self.pool.get().await.map_err(|e| {
4652 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4653 })?;
4654 let count: i64 = mail_suppressions::table
4655 .filter(mail_suppressions::address.eq(&key))
4656 .count()
4657 .get_result(&mut conn)
4658 .await
4659 .map_err(|e| {
4660 MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4661 })?;
4662 Ok(count > 0)
4663 })
4664 }
4665
4666 fn suppress<'a>(
4667 &'a self,
4668 address: &'a str,
4669 reason: SuppressionReason,
4670 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4671 Box::pin(async move {
4672 let key = canonical_subscriber(address);
4673 let reason_str = reason.as_str();
4674 let mut conn = self.pool.get().await.map_err(|e| {
4675 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4676 })?;
4677 diesel::insert_into(mail_suppressions::table)
4678 .values(NewSuppression {
4679 address: &key,
4680 reason: reason_str,
4681 })
4682 .on_conflict(mail_suppressions::address)
4683 .do_update()
4684 .set((
4688 mail_suppressions::reason.eq(reason_str),
4689 mail_suppressions::suppressed_at.eq(diesel::dsl::now),
4690 ))
4691 .execute(&mut conn)
4692 .await
4693 .map_err(|e| {
4694 MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
4695 })?;
4696 Ok(())
4697 })
4698 }
4699
4700 fn unsuppress<'a>(
4701 &'a self,
4702 address: &'a str,
4703 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4704 Box::pin(async move {
4705 let key = canonical_subscriber(address);
4706 let mut conn = self.pool.get().await.map_err(|e| {
4707 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4708 })?;
4709 diesel::delete(
4710 mail_suppressions::table.filter(mail_suppressions::address.eq(&key)),
4711 )
4712 .execute(&mut conn)
4713 .await
4714 .map_err(|e| {
4715 MailError::RuntimeUnavailable(format!("suppression delete: {e}"))
4716 })?;
4717 Ok(())
4718 })
4719 }
4720 }
4721 }
4722}
4723
4724#[cfg(feature = "db")]
4726pub mod db_suppression {
4727 use std::future::Future;
4728 use std::pin::Pin;
4729
4730 use diesel::prelude::*;
4731 use diesel_async::RunQueryDsl;
4732 use diesel_async::pooled_connection::deadpool::Pool;
4733
4734 use super::{MailError, SuppressionStore};
4735
4736 #[cfg(feature = "postgres")]
4739 type UnsubscribedAt = diesel::sql_types::Timestamptz;
4740 #[cfg(not(feature = "postgres"))]
4741 type UnsubscribedAt = diesel::sql_types::TimestamptzSqlite;
4742
4743 diesel::table! {
4744 mail_unsubscribes (id) {
4745 id -> Int8,
4746 subscriber -> Text,
4747 list_id -> Text,
4748 unsubscribed_at -> crate::mail::db_suppression::UnsubscribedAt,
4749 }
4750 }
4751
4752 #[derive(Insertable)]
4753 #[diesel(table_name = mail_unsubscribes)]
4754 struct NewUnsubscribe<'a> {
4755 subscriber: &'a str,
4756 list_id: &'a str,
4757 }
4758
4759 #[derive(Clone)]
4764 pub struct DbSuppressionStore {
4765 pool: Pool<crate::db::RuntimeConnection>,
4766 }
4767
4768 impl DbSuppressionStore {
4769 #[must_use]
4771 pub const fn new(pool: Pool<crate::db::RuntimeConnection>) -> Self {
4772 Self { pool }
4773 }
4774 }
4775
4776 impl SuppressionStore for DbSuppressionStore {
4777 fn is_suppressed<'a>(
4778 &'a self,
4779 subscriber: &'a str,
4780 list_id: &'a str,
4781 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
4782 Box::pin(async move {
4783 let mut conn =
4784 self.pool.get().await.map_err(|e| {
4785 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4786 })?;
4787 let count: i64 = mail_unsubscribes::table
4788 .filter(mail_unsubscribes::subscriber.eq(subscriber))
4789 .filter(mail_unsubscribes::list_id.eq(list_id))
4790 .count()
4791 .get_result(&mut conn)
4792 .await
4793 .map_err(|e| {
4794 MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4795 })?;
4796 Ok(count > 0)
4797 })
4798 }
4799
4800 fn is_suppressed_many<'a>(
4801 &'a self,
4802 subscribers: &'a [&'a str],
4803 list_id: &'a str,
4804 ) -> Pin<
4805 Box<
4806 dyn Future<Output = Result<std::collections::HashSet<String>, MailError>>
4807 + Send
4808 + 'a,
4809 >,
4810 > {
4811 #[cfg(not(feature = "sqlite"))]
4840 const CHUNK_SIZE: usize = 50_000;
4841 #[cfg(feature = "sqlite")]
4842 const CHUNK_SIZE: usize = crate::repository::MAX_BIND_PARAMS - 1;
4843 Box::pin(async move {
4844 let mut suppressed = std::collections::HashSet::with_capacity(subscribers.len());
4845 if subscribers.is_empty() {
4846 return Ok(suppressed);
4847 }
4848 let mut conn =
4849 self.pool.get().await.map_err(|e| {
4850 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4851 })?;
4852 for chunk in subscribers.chunks(CHUNK_SIZE) {
4853 let owned: Vec<&str> = chunk.to_vec();
4854 let hits: Vec<String> = mail_unsubscribes::table
4855 .filter(mail_unsubscribes::list_id.eq(list_id))
4856 .filter(mail_unsubscribes::subscriber.eq_any(owned))
4857 .select(mail_unsubscribes::subscriber)
4858 .load(&mut conn)
4859 .await
4860 .map_err(|e| {
4861 MailError::RuntimeUnavailable(format!("suppression query: {e}"))
4862 })?;
4863 suppressed.extend(hits);
4864 }
4865 Ok(suppressed)
4866 })
4867 }
4868
4869 fn suppress<'a>(
4870 &'a self,
4871 subscriber: &'a str,
4872 list_id: &'a str,
4873 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
4874 Box::pin(async move {
4875 let mut conn =
4876 self.pool.get().await.map_err(|e| {
4877 MailError::RuntimeUnavailable(format!("suppression pool: {e}"))
4878 })?;
4879 diesel::insert_into(mail_unsubscribes::table)
4880 .values(NewUnsubscribe {
4881 subscriber,
4882 list_id,
4883 })
4884 .on_conflict((mail_unsubscribes::subscriber, mail_unsubscribes::list_id))
4885 .do_nothing()
4886 .execute(&mut conn)
4887 .await
4888 .map_err(|e| {
4889 MailError::RuntimeUnavailable(format!("suppression insert: {e}"))
4890 })?;
4891 Ok(())
4892 })
4893 }
4894 }
4895}
4896
4897#[cfg(test)]
4898mod tests {
4899 use super::*;
4900
4901 #[test]
4904 fn html_contains_style_block_is_case_insensitive() {
4905 assert!(html_contains_style_block("<STYLE>.a{}</STYLE>"));
4906 assert!(html_contains_style_block("<p>x</p><style>.a{}</style>"));
4907 assert!(!html_contains_style_block("<p style=\"color:red\">x</p>"));
4908 assert!(!html_contains_style_block("just plain text, no tags"));
4909 }
4910
4911 #[test]
4912 fn inline_css_applies_class_style_to_anchor() {
4913 let html = r#"<style>.btn{color:#fff;background:#06c}</style><a class="btn">Go</a>"#;
4916 let out = inline_css_html(html).expect("inlining succeeds");
4917 let anchor = out
4921 .split("<a")
4922 .nth(1)
4923 .expect("an <a> tag is present in the output");
4924 let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
4925 assert!(
4926 anchor_open.contains("style="),
4927 "anchor must gain an inline style attribute; got tag: {anchor_open}"
4928 );
4929 assert!(
4930 anchor_open.contains("#fff"),
4931 "anchor's inline style must carry the color rule; got tag: {anchor_open}"
4932 );
4933 assert!(
4934 anchor_open.contains("#06c") || anchor_open.contains("background"),
4935 "anchor's inline style must carry the background rule; got tag: {anchor_open}"
4936 );
4937 }
4938
4939 #[test]
4940 fn inline_css_applies_class_style_to_table() {
4941 let html = r#"<style>.wrap{width:600px;background:#eee}</style><table class="wrap"><tr><td>x</td></tr></table>"#;
4943 let out = inline_css_html(html).expect("inlining succeeds");
4944 let table = out
4945 .split("<table")
4946 .nth(1)
4947 .expect("a <table> tag is present in the output");
4948 let table_open = &table[..table.find('>').expect("table tag closes")];
4949 assert!(
4950 table_open.contains("style=") && table_open.contains("600px"),
4951 "table must carry an inline style with the width rule; got tag: {table_open}"
4952 );
4953 }
4954
4955 #[test]
4956 #[allow(
4957 clippy::literal_string_with_formatting_args,
4958 reason = "CSS rule braces are literal HTML, not format placeholders"
4959 )]
4960 fn inline_css_emits_outlook_width_height_attributes() {
4961 let html = r#"<style>table{width:600px}img{height:40px}</style><table><tr><td><img src="/x.png"></td></tr></table>"#;
4965 let out = inline_css_html(html).expect("inlining succeeds");
4966
4967 let table_open = {
4968 let table = out
4969 .split("<table")
4970 .nth(1)
4971 .expect("a <table> tag is present in the output");
4972 &table[..table.find('>').expect("table tag closes")]
4973 };
4974 assert!(
4976 table_open.contains("style=") && table_open.contains("600px"),
4977 "table must still carry the inline CSS width; got tag: {table_open}"
4978 );
4979 assert!(
4980 table_open.contains(r#"width="600""#),
4981 "table must gain the presentational HTML width attribute Outlook needs; got tag: {table_open}"
4982 );
4983
4984 let img_open = {
4985 let img = out
4986 .split("<img")
4987 .nth(1)
4988 .expect("an <img> tag is present in the output");
4989 &img[..img.find('>').expect("img tag closes")]
4990 };
4991 assert!(
4992 img_open.contains("style=") && img_open.contains("40px"),
4993 "img must still carry the inline CSS height; got tag: {img_open}"
4994 );
4995 assert!(
4996 img_open.contains(r#"height="40""#),
4997 "img must gain the presentational HTML height attribute Outlook needs; got tag: {img_open}"
4998 );
4999 }
5000
5001 #[test]
5002 fn inline_css_passthrough_without_style_block_is_byte_identical() {
5003 let html = r#"<p style="color:red">Hello</p><a href="/x">link</a>"#;
5005 let out = inline_css_html(html).expect("no-op inlining succeeds");
5006 assert_eq!(out, html, "no-<style> body must be returned unchanged");
5007 }
5008
5009 #[test]
5010 #[allow(
5011 clippy::literal_string_with_formatting_args,
5012 reason = "CSS rule braces are literal HTML, not format placeholders"
5013 )]
5014 fn inline_css_retains_link_stylesheet_tags() {
5015 let html = r#"<style>.x{color:red}</style><link rel="stylesheet" href="https://example.com/app.css"><p class="x">Hi</p>"#;
5021 let out = inline_css_html(html).expect("inlining succeeds");
5022 assert!(
5023 out.contains("<link") && out.contains(r#"rel="stylesheet""#),
5024 "the <link rel=\"stylesheet\"> tag must be preserved; got: {out}"
5025 );
5026 assert!(
5027 out.contains("app.css"),
5028 "the linked stylesheet href must be preserved; got: {out}"
5029 );
5030 let para = out
5031 .split("<p")
5032 .nth(1)
5033 .expect("a <p> tag is present in the output");
5034 let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
5035 assert!(
5036 para_open.contains("style=") && para_open.contains("red"),
5037 "the embedded rule must still be inlined onto the paragraph; got tag: {para_open}"
5038 );
5039 }
5040
5041 #[test]
5042 #[allow(
5043 clippy::literal_string_with_formatting_args,
5044 reason = "CSS rule braces are literal HTML, not format placeholders"
5045 )]
5046 fn inline_css_is_idempotent() {
5047 let html = r#"<style>.btn{color:#fff}p{margin:0}</style><a class="btn">Go</a><p>hi</p>"#;
5049 let once = inline_css_html(html).expect("first pass");
5050 let twice = inline_css_html(&once).expect("second pass");
5051 assert_eq!(once, twice, "inlining must be idempotent");
5052 }
5053
5054 #[test]
5055 #[allow(
5056 clippy::literal_string_with_formatting_args,
5057 reason = "CSS rule braces are literal HTML, not format placeholders"
5058 )]
5059 fn inline_css_retains_uninlinable_media_queries() {
5060 let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
5063 let out = inline_css_html(html).expect("inlining succeeds");
5064 assert!(
5065 out.contains("@media") && out.contains("max-width"),
5066 "the @media rule must be preserved in a retained <style> block; got: {out}"
5067 );
5068 assert!(
5070 out.contains("<a") && out.contains("style="),
5071 "the inlinable rule must still be inlined onto the anchor; got: {out}"
5072 );
5073 }
5074
5075 #[test]
5076 #[allow(
5077 clippy::literal_string_with_formatting_args,
5078 reason = "CSS rule braces are literal HTML, not format placeholders"
5079 )]
5080 fn inline_css_fragment_body_stays_a_fragment() {
5081 let html = r#"<style>.x{color:red}</style><p class="x">Hi</p>"#;
5086 let out = inline_css_html(html).expect("inlining succeeds");
5087 assert!(
5088 !out.to_ascii_lowercase().contains("<html")
5089 && !out.to_ascii_lowercase().contains("<body")
5090 && !out.to_ascii_lowercase().contains("<head"),
5091 "fragment body must not gain document wrappers; got: {out}"
5092 );
5093 let para = out
5094 .split("<p")
5095 .nth(1)
5096 .expect("a <p> tag is present in the output");
5097 let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
5098 assert!(
5099 para_open.contains("style=") && para_open.contains("red"),
5100 "the class rule must be inlined onto the paragraph; got tag: {para_open}"
5101 );
5102 }
5103
5104 #[test]
5105 #[allow(
5106 clippy::literal_string_with_formatting_args,
5107 reason = "CSS rule braces are literal HTML, not format placeholders"
5108 )]
5109 fn inline_css_fragment_body_retains_media_query_without_wrapping() {
5110 let html = r#"<style>.btn{color:#fff}@media (max-width:600px){.btn{color:#000}}</style><a class="btn">Go</a>"#;
5116 let out = inline_css_html(html).expect("inlining succeeds");
5117 assert!(
5118 !out.to_ascii_lowercase().contains("<html")
5119 && !out.to_ascii_lowercase().contains("<body")
5120 && !out.to_ascii_lowercase().contains("<head"),
5121 "fragment body must not gain document wrappers; got: {out}"
5122 );
5123 assert!(
5124 out.contains("@media") && out.contains("max-width"),
5125 "the retained @media block must survive the unwrap; got: {out}"
5126 );
5127 let anchor = out
5128 .split("<a")
5129 .nth(1)
5130 .expect("an <a> tag is present in the output");
5131 let anchor_open = &anchor[..anchor.find('>').expect("anchor tag closes")];
5132 assert!(
5133 anchor_open.contains("style=") && anchor_open.contains("#fff"),
5134 "the inlinable rule must still be inlined onto the anchor; got tag: {anchor_open}"
5135 );
5136 }
5137
5138 #[test]
5139 #[allow(
5140 clippy::literal_string_with_formatting_args,
5141 reason = "CSS rule braces are literal HTML, not format placeholders"
5142 )]
5143 fn inline_css_full_document_body_stays_a_document() {
5144 let html = r#"<html><head><style>.x{color:red}</style></head><body><p class="x">Hi</p></body></html>"#;
5147 let out = inline_css_html(html).expect("inlining succeeds");
5148 assert!(
5149 out.to_ascii_lowercase().contains("<html")
5150 && out.to_ascii_lowercase().contains("<body"),
5151 "full-document body must retain its structure; got: {out}"
5152 );
5153 let para = out
5154 .split("<p")
5155 .nth(1)
5156 .expect("a <p> tag is present in the output");
5157 let para_open = ¶[..para.find('>').expect("paragraph tag closes")];
5158 assert!(
5159 para_open.contains("style=") && para_open.contains("red"),
5160 "the class rule must be inlined onto the paragraph; got tag: {para_open}"
5161 );
5162 }
5163
5164 #[test]
5165 fn inline_css_stripped_style_renders_same_computed_styling() {
5166 let html = r#"<style>.btn{color:#fff;padding:8px}</style><a class="btn">Go</a>"#;
5171 let inlined = inline_css_html(html).expect("inlining succeeds");
5172
5173 let mut stripped = String::new();
5175 let mut rest = inlined.as_str();
5176 while let Some(start) = rest.to_ascii_lowercase().find("<style") {
5177 stripped.push_str(&rest[..start]);
5178 let after = &rest[start..];
5179 let end = after
5180 .to_ascii_lowercase()
5181 .find("</style>")
5182 .map_or(after.len(), |e| e + "</style>".len());
5183 rest = &after[end..];
5184 }
5185 stripped.push_str(rest);
5186
5187 let anchor = stripped
5189 .split("<a")
5190 .nth(1)
5191 .expect("anchor present after stripping <style>");
5192 let anchor_open = &anchor[..anchor.find('>').expect("anchor closes")];
5193 assert!(
5194 anchor_open.contains("style=") && anchor_open.contains("#fff"),
5195 "computed styling must be carried inline so a style-stripped copy looks identical; got: {anchor_open}"
5196 );
5197 }
5198
5199 async fn preview_body_for(preview: MailPreview) -> String {
5207 let state = crate::AppState::for_test();
5208 state.insert_extension(MailPreviewRegistry::new(vec![preview]));
5209 let mailer = Mailer::builder()
5210 .build()
5211 .expect("log-transport mailer builds");
5212 state.insert_extension(mailer);
5213
5214 let response = show_template_preview(&state, "test", "styled");
5215 let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
5216 .await
5217 .expect("preview body collects");
5218 String::from_utf8(bytes.to_vec()).expect("preview body is utf-8")
5219 }
5220
5221 fn escaped_anchor_open_tag(body: &str) -> String {
5226 let after = body
5227 .split("<a")
5228 .nth(1)
5229 .expect("an <a> tag is present in the escaped preview body");
5230 let open = &after[..after.find(">").expect("anchor tag closes")];
5231 open.to_owned()
5232 }
5233
5234 #[tokio::test]
5235 #[allow(
5236 clippy::literal_string_with_formatting_args,
5237 reason = "CSS rule braces are literal HTML, not format placeholders"
5238 )]
5239 async fn preview_inlines_style_block_when_inlining_enabled() {
5240 let preview = MailPreview::new("test", "styled", || {
5243 Mail::builder()
5244 .to("user@example.com")
5245 .subject("Styled")
5246 .html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
5247 .inline_css(true)
5248 .build()
5249 .expect("preview mail builds")
5250 });
5251
5252 let body = preview_body_for(preview).await;
5253 let anchor = escaped_anchor_open_tag(&body);
5254 assert!(
5255 anchor.contains("style="),
5256 "preview must inline the <style> block onto the anchor; got tag: {anchor}"
5257 );
5258 assert!(
5259 anchor.contains("#ff0000"),
5260 "the .btn colour rule must be carried inline; got tag: {anchor}"
5261 );
5262 }
5263
5264 #[tokio::test]
5265 #[allow(
5266 clippy::literal_string_with_formatting_args,
5267 reason = "CSS rule braces are literal HTML, not format placeholders"
5268 )]
5269 async fn preview_leaves_style_block_raw_when_inlining_disabled() {
5270 let preview = MailPreview::new("test", "styled", || {
5273 Mail::builder()
5274 .to("user@example.com")
5275 .subject("Styled")
5276 .html(r#"<style>.btn{color:#ff0000}</style><a class="btn">Go</a>"#)
5277 .inline_css(false)
5278 .build()
5279 .expect("preview mail builds")
5280 });
5281
5282 let body = preview_body_for(preview).await;
5283 let anchor = escaped_anchor_open_tag(&body);
5284 assert!(
5285 !anchor.contains("style="),
5286 "inlining is off, so the anchor must not gain an inline style; got tag: {anchor}"
5287 );
5288 assert!(
5289 body.contains("<style>"),
5290 "the raw <style> block must survive when inlining is off"
5291 );
5292 }
5293
5294 #[test]
5295 fn mail_builder_inline_css_sets_per_message_override() {
5296 let on = Mail::builder()
5297 .to("a@example.com")
5298 .subject("s")
5299 .html("<p>x</p>")
5300 .inline_css(true)
5301 .build()
5302 .expect("valid mail");
5303 assert_eq!(on.inline_css, Some(true));
5304
5305 let off = Mail::builder()
5306 .to("a@example.com")
5307 .subject("s")
5308 .html("<p>x</p>")
5309 .inline_css(false)
5310 .build()
5311 .expect("valid mail");
5312 assert_eq!(off.inline_css, Some(false));
5313
5314 let unset = Mail::builder()
5315 .to("a@example.com")
5316 .subject("s")
5317 .html("<p>x</p>")
5318 .build()
5319 .expect("valid mail");
5320 assert_eq!(
5321 unset.inline_css, None,
5322 "unset builder must defer to the mailer/config default"
5323 );
5324 }
5325
5326 #[test]
5327 fn mail_config_inline_css_defaults_off() {
5328 assert!(
5329 !MailConfig::default().inline_css,
5330 "inlining must default off so existing apps are unaffected"
5331 );
5332 }
5333
5334 fn pinned_render_eml_no_attachments(mail: &Mail) -> String {
5344 let mut out = String::new();
5345 if let Some(from) = &mail.from {
5346 out.push_str("From: ");
5347 out.push_str(from);
5348 out.push('\n');
5349 }
5350 for to in &mail.to {
5351 out.push_str("To: ");
5352 out.push_str(to);
5353 out.push('\n');
5354 }
5355 if let Some(reply_to) = &mail.reply_to {
5356 out.push_str("Reply-To: ");
5357 out.push_str(reply_to);
5358 out.push('\n');
5359 }
5360 out.push_str("Date: ");
5361 out.push_str("PINNED-DATE");
5362 out.push('\n');
5363 out.push_str("Message-Id: <");
5364 out.push_str("PINNED-ID");
5365 out.push_str("@autumn.local>\n");
5366 out.push_str("Subject: ");
5367 out.push_str(&mail.subject);
5368 out.push('\n');
5369 for (name, value) in &mail.extra_headers {
5370 out.push_str(name);
5371 out.push_str(": ");
5372 out.push_str(value);
5373 out.push('\n');
5374 }
5375 out.push_str("MIME-Version: 1.0\n");
5376 if mail.html.is_some() && mail.text.is_some() {
5377 out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
5378 if let Some(text) = &mail.text {
5379 out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
5380 out.push_str(text);
5381 out.push('\n');
5382 }
5383 if let Some(html) = &mail.html {
5384 out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
5385 out.push_str(html);
5386 out.push('\n');
5387 }
5388 out.push_str("--autumn-mail--\n");
5389 } else if let Some(html) = &mail.html {
5390 out.push_str("Content-Type: text/html; charset=utf-8\n\n");
5391 out.push_str(html);
5392 out.push('\n');
5393 } else if let Some(text) = &mail.text {
5394 out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
5395 out.push_str(text);
5396 out.push('\n');
5397 }
5398 out
5399 }
5400
5401 fn mask_nondeterministic(eml: &str) -> String {
5402 eml.lines()
5403 .map(|line| {
5404 if line.starts_with("Date: ") {
5405 "Date: PINNED-DATE"
5406 } else if line.starts_with("Message-Id: ") {
5407 "Message-Id: <PINNED-ID@autumn.local>"
5408 } else {
5409 line
5410 }
5411 })
5412 .collect::<Vec<_>>()
5413 .join("\n")
5414 }
5415
5416 #[test]
5417 fn render_eml_without_attachments_matches_pinned_shape() {
5418 let mails = [
5419 Mail::builder()
5420 .from("from@example.com")
5421 .to("user@example.com")
5422 .subject("Hi")
5423 .text("hello text")
5424 .html("<p>hello html</p>")
5425 .build()
5426 .expect("mail should build"),
5427 Mail::builder()
5428 .from("from@example.com")
5429 .to("user@example.com")
5430 .subject("Hi")
5431 .text("hello text only")
5432 .build()
5433 .expect("mail should build"),
5434 Mail::builder()
5435 .from("from@example.com")
5436 .to("user@example.com")
5437 .subject("Hi")
5438 .html("<p>hello html only</p>")
5439 .build()
5440 .expect("mail should build"),
5441 ];
5442 for mail in mails {
5443 let actual = mask_nondeterministic(&render_eml(&mail));
5444 let pinned = mask_nondeterministic(&pinned_render_eml_no_attachments(&mail));
5445 assert_eq!(
5446 actual, pinned,
5447 "render_eml must be byte-identical for attachment-less mail"
5448 );
5449 assert!(!actual.contains("multipart/mixed"));
5450 }
5451 }
5452
5453 #[test]
5454 fn lettre_message_without_attachments_has_no_mixed_part() {
5455 let mail = Mail::builder()
5456 .from("from@example.com")
5457 .to("user@example.com")
5458 .subject("Hi")
5459 .text("hello")
5460 .html("<p>hello</p>")
5461 .build()
5462 .expect("mail should build");
5463 let message = lettre_message(&mail).expect("lettre message should build");
5464 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5465 assert!(formatted.contains("multipart/alternative"));
5466 assert!(!formatted.contains("multipart/mixed"));
5467 }
5468
5469 #[test]
5472 fn mail_builder_attach_preserves_order_and_count() {
5473 let mail = Mail::builder()
5474 .to("user@example.com")
5475 .subject("Hi")
5476 .text("hello")
5477 .attach("a.txt", "text/plain", b"aaa".to_vec())
5478 .attach("b.txt", "text/plain", b"bbb".to_vec())
5479 .attach("c.txt", "text/plain", b"ccc".to_vec())
5480 .build()
5481 .expect("mail should build");
5482 assert_eq!(mail.attachments.len(), 3);
5483 assert_eq!(
5484 mail.attachments
5485 .iter()
5486 .map(|a| a.filename.as_str())
5487 .collect::<Vec<_>>(),
5488 vec!["a.txt", "b.txt", "c.txt"]
5489 );
5490 assert_eq!(mail.attachments[1].content_type, "text/plain");
5491 assert_eq!(mail.attachments[1].bytes, b"bbb".to_vec());
5492 }
5493
5494 #[test]
5495 fn mail_serde_round_trips_attachments() {
5496 let mail = Mail::builder()
5497 .from("from@example.com")
5498 .to("user@example.com")
5499 .subject("Hi")
5500 .text("hello")
5501 .attach("invoice.pdf", "application/pdf", vec![0_u8, 1, 2, 255])
5502 .build()
5503 .expect("mail should build");
5504 let json = serde_json::to_string(&mail).expect("mail should serialize");
5505 let round_tripped: Mail = serde_json::from_str(&json).expect("mail should deserialize");
5506 assert_eq!(round_tripped, mail);
5507 }
5508
5509 #[test]
5510 fn mail_builder_rejects_control_chars_in_attachment_filename() {
5511 let err = Mail::builder()
5512 .to("user@example.com")
5513 .subject("Hi")
5514 .text("hello")
5515 .attach(
5516 "evil\r\nX-Injected: 1.pdf",
5517 "application/pdf",
5518 b"x".to_vec(),
5519 )
5520 .build()
5521 .expect_err("CRLF in filename should be rejected");
5522 assert!(err.to_string().contains("filename"));
5523
5524 let err = Mail::builder()
5525 .to("user@example.com")
5526 .subject("Hi")
5527 .text("hello")
5528 .attach("\0evil.pdf", "application/pdf", b"x".to_vec())
5529 .build()
5530 .expect_err("NUL in filename should be rejected");
5531 assert!(err.to_string().contains("filename"));
5532
5533 let err = Mail::builder()
5534 .to("user@example.com")
5535 .subject("Hi")
5536 .text("hello")
5537 .attach(" ", "application/pdf", b"x".to_vec())
5538 .build()
5539 .expect_err("empty filename should be rejected");
5540 assert!(err.to_string().contains("filename"));
5541 }
5542
5543 #[test]
5544 fn mail_builder_rejects_invalid_attachment_content_type() {
5545 let err = Mail::builder()
5546 .to("user@example.com")
5547 .subject("Hi")
5548 .text("hello")
5549 .attach("a.pdf", "not a mime type", b"x".to_vec())
5550 .build()
5551 .expect_err("invalid content type should be rejected");
5552 assert!(err.to_string().contains("content type"));
5553 }
5554
5555 #[test]
5556 fn mail_attachment_debug_hides_bytes() {
5557 let attachment = MailAttachment {
5558 filename: "secret.bin".to_owned(),
5559 content_type: "application/octet-stream".to_owned(),
5560 bytes: vec![1, 2, 3, 4, 5],
5561 };
5562 let debug = format!("{attachment:?}");
5563 assert!(debug.contains("secret.bin"));
5564 assert!(debug.contains('5'), "byte length should appear: {debug}");
5565 assert!(
5566 !debug.contains("[1, 2, 3, 4, 5]"),
5567 "raw byte values must not appear: {debug}"
5568 );
5569 }
5570
5571 fn blob_all_byte_values() -> Vec<u8> {
5574 (0_u8..=255).cycle().take(4096).collect()
5575 }
5576
5577 fn sha256_hex(bytes: &[u8]) -> String {
5578 use sha2::Digest as _;
5579 let mut hasher = sha2::Sha256::new();
5580 hasher.update(bytes);
5581 format!("{:x}", hasher.finalize())
5582 }
5583
5584 fn mixed_boundary(eml: &str) -> String {
5588 let line = eml
5589 .lines()
5590 .find(|line| line.starts_with("Content-Type: multipart/mixed;"))
5591 .expect("multipart/mixed Content-Type header present");
5592 content_type_boundary(line).expect("boundary parameter present")
5593 }
5594
5595 #[test]
5596 fn render_eml_with_attachment_emits_multipart_mixed() {
5597 let mail = Mail::builder()
5598 .from("from@example.com")
5599 .to("user@example.com")
5600 .subject("Invoice")
5601 .text("see attached")
5602 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5603 .build()
5604 .expect("mail should build");
5605 let eml = render_eml(&mail);
5606 let boundary = mixed_boundary(&eml);
5607 assert!(eml.contains(&format!(
5608 "Content-Type: multipart/mixed; boundary=\"{boundary}\""
5609 )));
5610 assert!(eml.contains("Content-Disposition: attachment; filename=\"invoice.pdf\""));
5611 assert!(eml.contains("Content-Type: application/pdf"));
5612 assert!(eml.contains("Content-Transfer-Encoding: base64"));
5613 assert!(eml.contains(&format!("--{boundary}--")));
5614 }
5615
5616 #[test]
5617 fn render_eml_boundary_is_unpredictable_and_body_cannot_forge_it() {
5618 let mail = Mail::builder()
5623 .from("from@example.com")
5624 .to("user@example.com")
5625 .subject("Spoof attempt")
5626 .text("line one\n--autumn-mixed--\nX-Spoofed: header\nline two")
5627 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5628 .build()
5629 .expect("mail should build");
5630 let eml = render_eml(&mail);
5631 let parsed = parse_eml(&eml);
5632 assert_eq!(
5633 parsed.text.as_deref(),
5634 Some("line one\n--autumn-mixed--\nX-Spoofed: header\nline two"),
5635 "body content resembling the old fixed boundary must not truncate the message"
5636 );
5637 assert_eq!(parsed.attachments.len(), 1);
5638
5639 let other = render_eml(
5640 &Mail::builder()
5641 .from("from@example.com")
5642 .to("user@example.com")
5643 .subject("Second message")
5644 .text("hi")
5645 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5646 .build()
5647 .expect("mail should build"),
5648 );
5649 assert_ne!(
5650 mixed_boundary(&eml),
5651 mixed_boundary(&other),
5652 "boundary must vary per message, not be a fixed/predictable string"
5653 );
5654 }
5655
5656 #[test]
5657 fn render_eml_attachment_bytes_round_trip_sha256() {
5658 use base64::Engine as _;
5659 let blob = blob_all_byte_values();
5660 let expected_digest = sha256_hex(&blob);
5661 let mail = Mail::builder()
5662 .from("from@example.com")
5663 .to("user@example.com")
5664 .subject("Blob")
5665 .text("see attached")
5666 .attach("blob.bin", "application/octet-stream", blob)
5667 .build()
5668 .expect("mail should build");
5669 let eml = render_eml(&mail);
5670 let boundary = mixed_boundary(&eml);
5671
5672 let start = eml
5673 .find("Content-Transfer-Encoding: base64\n\n")
5674 .expect("base64 section present")
5675 + "Content-Transfer-Encoding: base64\n\n".len();
5676 let rest = &eml[start..];
5677 let end = rest
5678 .find(&format!("--{boundary}"))
5679 .expect("closing boundary present");
5680 let encoded: String = rest[..end].chars().filter(|c| !c.is_whitespace()).collect();
5681
5682 let decoded = base64::engine::general_purpose::STANDARD
5683 .decode(encoded)
5684 .expect("attachment body should be valid base64");
5685 assert_eq!(sha256_hex(&decoded), expected_digest);
5686 }
5687
5688 #[test]
5689 fn render_eml_preserves_attachment_order() {
5690 let mail = Mail::builder()
5691 .from("from@example.com")
5692 .to("user@example.com")
5693 .subject("Multi")
5694 .text("see attached")
5695 .attach("a.txt", "text/plain", b"a".to_vec())
5696 .attach("b.txt", "text/plain", b"b".to_vec())
5697 .build()
5698 .expect("mail should build");
5699 let eml = render_eml(&mail);
5700 let a_pos = eml.find("filename=\"a.txt\"").expect("a.txt present");
5701 let b_pos = eml.find("filename=\"b.txt\"").expect("b.txt present");
5702 assert!(a_pos < b_pos, "attachments must render in declared order");
5703 }
5704
5705 #[test]
5706 fn render_eml_with_attachment_nests_alternative_body() {
5707 let mail = Mail::builder()
5708 .from("from@example.com")
5709 .to("user@example.com")
5710 .subject("Both bodies")
5711 .text("plain")
5712 .html("<p>html</p>")
5713 .attach("a.txt", "text/plain", b"a".to_vec())
5714 .build()
5715 .expect("mail should build");
5716 let eml = render_eml(&mail);
5717 assert!(eml.contains("Content-Type: multipart/alternative; boundary=\"autumn-mail\""));
5718 assert!(eml.contains("plain"));
5719 assert!(eml.contains("<p>html</p>"));
5720 }
5721
5722 #[test]
5723 fn render_eml_blocks_filename_header_injection() {
5724 let mail = Mail {
5728 from: Some("from@example.com".to_owned()),
5729 reply_to: None,
5730 to: vec!["user@example.com".to_owned()],
5731 subject: "Hi".to_owned(),
5732 html: None,
5733 text: Some("hello".to_owned()),
5734 list_unsubscribe: None,
5735 extra_headers: Vec::new(),
5736 attachments: vec![MailAttachment {
5737 filename: "evil\r\nX-Injected: 1.pdf".to_owned(),
5738 content_type: "application/pdf".to_owned(),
5739 bytes: b"x".to_vec(),
5740 }],
5741 ignore_suppression: false,
5742 inline_css: None,
5743 };
5744 let eml = render_eml(&mail);
5745 assert!(
5746 !eml.lines().any(|line| line.starts_with("X-Injected")),
5747 "CRLF in filename must not inject a header: {eml}"
5748 );
5749 assert!(!eml.contains('\r'));
5750 }
5751
5752 #[test]
5753 fn render_eml_blocks_header_injection_in_all_deserialized_fields() {
5754 let mail = Mail {
5759 from: Some("from@example.com\r\nX-From-Injected: 1".to_owned()),
5760 reply_to: Some("reply@example.com\r\nX-Reply-Injected: 1".to_owned()),
5761 to: vec!["user@example.com\r\nX-To-Injected: 1".to_owned()],
5762 subject: "Hi\r\nX-Subject-Injected: 1".to_owned(),
5763 html: None,
5764 text: Some("hello".to_owned()),
5765 list_unsubscribe: None,
5766 extra_headers: vec![(
5767 "X-Custom\r\nX-Header-Injected".to_owned(),
5768 "1\r\nX-Value-Injected: 1".to_owned(),
5769 )],
5770 attachments: Vec::new(),
5771 ignore_suppression: false,
5772 inline_css: None,
5773 };
5774 let eml = render_eml(&mail);
5775 assert!(
5776 !eml.lines().any(|line| line.starts_with("X-From-Injected")
5777 || line.starts_with("X-Reply-Injected")
5778 || line.starts_with("X-To-Injected")
5779 || line.starts_with("X-Subject-Injected")
5780 || line.starts_with("X-Header-Injected")
5781 || line.starts_with("X-Value-Injected")),
5782 "CRLF in any header-bound field must not inject a standalone header line: {eml}"
5783 );
5784 assert!(!eml.contains('\r'));
5785 }
5786
5787 #[test]
5788 fn render_eml_falls_back_to_octet_stream_for_invalid_content_type() {
5789 let mail = Mail {
5793 from: Some("from@example.com".to_owned()),
5794 reply_to: None,
5795 to: vec!["user@example.com".to_owned()],
5796 subject: "Hi".to_owned(),
5797 html: None,
5798 text: Some("hello".to_owned()),
5799 list_unsubscribe: None,
5800 extra_headers: Vec::new(),
5801 attachments: vec![MailAttachment {
5802 filename: "file.bin".to_owned(),
5803 content_type: "not a mime type".to_owned(),
5804 bytes: b"x".to_vec(),
5805 }],
5806 ignore_suppression: false,
5807 inline_css: None,
5808 };
5809 let eml = render_eml(&mail);
5810 assert!(eml.contains("Content-Type: application/octet-stream"));
5811 assert!(!eml.contains("not a mime type"));
5812 }
5813
5814 #[test]
5815 fn render_eml_encodes_non_ascii_filename_rfc2231() {
5816 let mail = Mail {
5817 from: Some("from@example.com".to_owned()),
5818 reply_to: None,
5819 to: vec!["user@example.com".to_owned()],
5820 subject: "Hi".to_owned(),
5821 html: None,
5822 text: Some("hello".to_owned()),
5823 list_unsubscribe: None,
5824 extra_headers: Vec::new(),
5825 attachments: vec![MailAttachment {
5826 filename: "Résumé façade.pdf".to_owned(),
5827 content_type: "application/pdf".to_owned(),
5828 bytes: b"x".to_vec(),
5829 }],
5830 ignore_suppression: false,
5831 inline_css: None,
5832 };
5833 let eml = render_eml(&mail);
5834 assert!(eml.contains("filename*=UTF-8''"));
5835 let disposition_line = eml
5836 .lines()
5837 .find(|line| line.starts_with("Content-Disposition:"))
5838 .expect("Content-Disposition header present");
5839 assert!(disposition_line.is_ascii());
5840 }
5841
5842 #[test]
5843 fn content_disposition_params_table() {
5844 assert_eq!(content_disposition_params("a.txt"), "filename=\"a.txt\"");
5845 assert_eq!(
5846 content_disposition_params("weird\"na\\me.txt"),
5847 "filename=\"weird\\\"na\\\\me.txt\""
5848 );
5849 assert_eq!(
5850 content_disposition_params("evil\r\nX: 1"),
5851 "filename=\"evilX: 1\""
5852 );
5853 assert_eq!(content_disposition_params(""), "filename=\"attachment\"");
5854 assert_eq!(content_disposition_params(" "), "filename=\"attachment\"");
5855 let non_ascii = content_disposition_params("café.txt");
5856 assert!(non_ascii.contains("filename*=UTF-8''caf%C3%A9.txt"));
5857 assert!(non_ascii.is_ascii());
5858 }
5859
5860 #[test]
5861 fn render_eml_base64_lines_wrap_at_76() {
5862 let blob = blob_all_byte_values();
5863 let mail = Mail::builder()
5864 .from("from@example.com")
5865 .to("user@example.com")
5866 .subject("Blob")
5867 .text("see attached")
5868 .attach("blob.bin", "application/octet-stream", blob)
5869 .build()
5870 .expect("mail should build");
5871 let eml = render_eml(&mail);
5872 let boundary = mixed_boundary(&eml);
5873 let start = eml
5874 .find("Content-Transfer-Encoding: base64\n\n")
5875 .expect("base64 section present")
5876 + "Content-Transfer-Encoding: base64\n\n".len();
5877 let rest = &eml[start..];
5878 let end = rest
5879 .find(&format!("--{boundary}"))
5880 .expect("closing boundary present");
5881 for line in rest[..end].lines() {
5882 assert!(
5883 line.len() <= 76,
5884 "base64 line too long: {} chars",
5885 line.len()
5886 );
5887 }
5888 }
5889
5890 #[test]
5893 fn lettre_message_with_attachment_is_multipart_mixed() {
5894 let mail = Mail::builder()
5895 .from("from@example.com")
5896 .to("user@example.com")
5897 .subject("Invoice")
5898 .text("see attached")
5899 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
5900 .build()
5901 .expect("mail should build");
5902 let message = lettre_message(&mail).expect("lettre message should build");
5903 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5904 assert!(formatted.contains("multipart/mixed"));
5905 assert!(formatted.contains("Content-Disposition: attachment"));
5906 assert!(formatted.contains("invoice.pdf"));
5907 assert!(formatted.contains("base64"));
5908 }
5909
5910 fn extract_boundary(text: &str) -> String {
5911 let marker = "boundary=\"";
5912 let start = text.find(marker).expect("boundary present") + marker.len();
5913 let rest = &text[start..];
5914 let end = rest.find('"').expect("boundary closing quote");
5915 rest[..end].to_owned()
5916 }
5917
5918 fn extract_attachment_base64(formatted_lf: &str, boundary: &str) -> String {
5919 let marker = format!("--{boundary}");
5920 for segment in formatted_lf.split(&marker).skip(1) {
5921 let segment = segment.trim_start_matches(['\n', '\r']);
5922 if segment.starts_with("--") {
5923 break;
5924 }
5925 let (headers, body) = split_headers_body(segment);
5926 if header_value(&headers, "Content-Disposition")
5927 .unwrap_or_default()
5928 .to_ascii_lowercase()
5929 .contains("attachment")
5930 {
5931 return body.chars().filter(|c| !c.is_whitespace()).collect();
5932 }
5933 }
5934 panic!("attachment part not found in: {formatted_lf}");
5935 }
5936
5937 #[test]
5938 fn lettre_message_attachment_round_trips_sha256() {
5939 use base64::Engine as _;
5940 let blob = blob_all_byte_values();
5941 let expected_digest = sha256_hex(&blob);
5942 let mail = Mail::builder()
5943 .from("from@example.com")
5944 .to("user@example.com")
5945 .subject("Blob")
5946 .text("see attached")
5947 .attach("blob.bin", "application/octet-stream", blob)
5948 .build()
5949 .expect("mail should build");
5950 let message = lettre_message(&mail).expect("lettre message should build");
5951 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5952 let normalized = formatted.replace("\r\n", "\n");
5953 let boundary = extract_boundary(&normalized);
5954 let encoded = extract_attachment_base64(&normalized, &boundary);
5955
5956 let decoded = base64::engine::general_purpose::STANDARD
5957 .decode(encoded)
5958 .expect("attachment body should be valid base64");
5959 assert_eq!(sha256_hex(&decoded), expected_digest);
5960 }
5961
5962 #[test]
5963 fn lettre_message_attachment_headers_ascii_and_injection_free() {
5964 for filename in ["evil\r\nX-Injected: 1.pdf", "Résumé façade.pdf"] {
5965 let mail = Mail {
5966 from: Some("from@example.com".to_owned()),
5967 reply_to: None,
5968 to: vec!["user@example.com".to_owned()],
5969 subject: "Hi".to_owned(),
5970 html: None,
5971 text: Some("hello".to_owned()),
5972 list_unsubscribe: None,
5973 extra_headers: Vec::new(),
5974 attachments: vec![MailAttachment {
5975 filename: filename.to_owned(),
5976 content_type: "application/pdf".to_owned(),
5977 bytes: b"x".to_vec(),
5978 }],
5979 ignore_suppression: false,
5980 inline_css: None,
5981 };
5982 let message = lettre_message(&mail).expect("lettre message should build");
5983 let formatted = String::from_utf8_lossy(&message.formatted()).into_owned();
5984 let header_section = formatted
5985 .split("\r\n\r\n")
5986 .next()
5987 .expect("header section present");
5988 assert!(
5989 header_section.is_ascii(),
5990 "headers must stay ASCII for filename {filename:?}: {header_section}"
5991 );
5992 assert!(
5993 !formatted.lines().any(|line| line.starts_with("X-Injected")),
5994 "CRLF in filename must not inject a header for {filename:?}"
5995 );
5996 }
5997 }
5998
5999 #[test]
6000 fn lettre_message_attachment_with_invalid_content_type_errors() {
6001 let mail = Mail {
6002 from: Some("from@example.com".to_owned()),
6003 reply_to: None,
6004 to: vec!["user@example.com".to_owned()],
6005 subject: "Hi".to_owned(),
6006 html: None,
6007 text: Some("hello".to_owned()),
6008 list_unsubscribe: None,
6009 extra_headers: Vec::new(),
6010 attachments: vec![MailAttachment {
6011 filename: "a.bin".to_owned(),
6012 content_type: "not a mime type".to_owned(),
6013 bytes: b"x".to_vec(),
6014 }],
6015 ignore_suppression: false,
6016 inline_css: None,
6017 };
6018 let err = lettre_message(&mail).expect_err("invalid content type should error");
6019 assert!(matches!(err, MailError::InvalidMessage(_)));
6020 }
6021
6022 #[test]
6025 fn parse_eml_extracts_attachment_list() {
6026 let mail = Mail::builder()
6027 .from("from@example.com")
6028 .to("user@example.com")
6029 .subject("Invoice")
6030 .text("plain")
6031 .html("<p>html</p>")
6032 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
6033 .attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
6034 .build()
6035 .expect("mail should build");
6036 let eml = render_eml(&mail);
6037 let parsed = parse_eml(&eml);
6038 assert_eq!(parsed.attachments.len(), 2);
6039 assert_eq!(parsed.attachments[0].filename, "invoice.pdf");
6040 assert_eq!(parsed.attachments[0].content_type, "application/pdf");
6041 assert_eq!(parsed.attachments[1].filename, "receipt.csv");
6042 assert_eq!(parsed.html.as_deref(), Some("<p>html</p>"));
6043 assert_eq!(parsed.text.as_deref(), Some("plain"));
6044 }
6045
6046 #[test]
6047 fn extract_attachment_filename_handles_semicolon_in_quoted_filename() {
6048 assert_eq!(
6050 extract_attachment_filename(r#"attachment; filename="invoice;2026.pdf""#),
6051 "invoice;2026.pdf"
6052 );
6053 }
6054
6055 #[test]
6056 fn extract_attachment_filename_unescapes_quoted_pairs() {
6057 assert_eq!(
6058 extract_attachment_filename(r#"attachment; filename="weird\"na\\me.txt""#),
6059 "weird\"na\\me.txt"
6060 );
6061 }
6062
6063 #[test]
6064 fn extract_attachment_filename_is_case_insensitive_and_handles_language_tag() {
6065 assert_eq!(
6066 extract_attachment_filename("attachment; filename*=utf-8'en'r%C3%A9sum%C3%A9.pdf"),
6067 "résumé.pdf"
6068 );
6069 }
6070
6071 #[test]
6072 fn extract_attachment_filename_round_trips_through_dev_preview() {
6073 let mail = Mail::builder()
6074 .from("from@example.com")
6075 .to("user@example.com")
6076 .subject("Invoice")
6077 .text("plain")
6078 .attach(r#"a;b"c\d.txt"#, "text/plain", b"x".to_vec())
6079 .build()
6080 .expect("mail should build");
6081 let eml = render_eml(&mail);
6082 let parsed = parse_eml(&eml);
6083 assert_eq!(parsed.attachments.len(), 1);
6084 assert_eq!(parsed.attachments[0].filename, r#"a;b"c\d.txt"#);
6085 }
6086
6087 #[test]
6088 fn parse_multipart_mixed_does_not_misclassify_inline_as_attachment() {
6089 let (_, _, attachments) = parse_multipart_mixed(
6090 "--b\nContent-Disposition: inline; filename=\"my-attachment-notes.pdf\"\nContent-Type: text/plain\n\nhi\n--b--\n",
6091 "b",
6092 );
6093 assert!(
6094 attachments.is_empty(),
6095 "an `inline` disposition must not be classified as an attachment: {attachments:?}"
6096 );
6097 }
6098
6099 #[test]
6100 fn mail_deserializes_from_pre_attachments_json_shape() {
6101 let json = r#"{"from":null,"reply_to":null,"to":["a@example.com"],"subject":"hi","html":null,"text":"hello","list_unsubscribe":null,"extra_headers":[]}"#;
6106 let mail: Mail =
6107 serde_json::from_str(json).expect("pre-attachments JSON should deserialize");
6108 assert!(mail.attachments.is_empty());
6109 }
6110
6111 #[test]
6112 fn render_mail_detail_lists_attachments() {
6113 let mail = Mail::builder()
6114 .from("from@example.com")
6115 .to("user@example.com")
6116 .subject("Invoice")
6117 .text("plain")
6118 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
6119 .attach("receipt.csv", "text/csv", b"a,b,c".to_vec())
6120 .build()
6121 .expect("mail should build");
6122 let parsed = parse_eml(&render_eml(&mail));
6123 let detail = render_mail_detail(&parsed, "captured");
6124 assert!(detail.contains("Attachments (2)"));
6125 assert!(detail.contains("invoice.pdf"));
6126 assert!(detail.contains("receipt.csv"));
6127 }
6128
6129 #[test]
6130 fn render_mail_detail_without_attachments_omits_section() {
6131 let mail = Mail::builder()
6132 .from("from@example.com")
6133 .to("user@example.com")
6134 .subject("Plain")
6135 .text("plain")
6136 .build()
6137 .expect("mail should build");
6138 let parsed = parse_eml(&render_eml(&mail));
6139 let detail = render_mail_detail(&parsed, "captured");
6140 assert!(!detail.contains("Attachments"));
6141 }
6142
6143 #[test]
6144 fn mail_builder_rejects_missing_body() {
6145 let err = Mail::builder()
6146 .to("user@example.com")
6147 .subject("Hello")
6148 .build()
6149 .expect_err("body should be required");
6150 assert!(err.to_string().contains("html or text"));
6151 }
6152
6153 #[test]
6154 fn filename_sanitizer_keeps_safe_characters() {
6155 assert_eq!(
6156 sanitize_filename("Ada Lovelace <ada@example.com>"),
6157 "Ada_Lovelace__ada_example.com_"
6158 );
6159 }
6160
6161 #[test]
6162 fn transport_default_is_disabled() {
6163 assert_eq!(Transport::default(), Transport::Disabled);
6164 }
6165
6166 #[test]
6169 fn mail_defaults_have_no_unsubscribe_or_extra_headers() {
6170 let mail = Mail::builder()
6171 .to("user@example.com")
6172 .subject("Hi")
6173 .text("hello")
6174 .build()
6175 .expect("mail should build");
6176 assert_eq!(mail.list_unsubscribe, None);
6177 assert!(mail.extra_headers.is_empty());
6178 assert!(mail.attachments.is_empty());
6179 }
6180
6181 #[test]
6182 fn mail_builder_sets_list_unsubscribe_and_headers() {
6183 let mail = Mail::builder()
6184 .to("user@example.com")
6185 .subject("Hi")
6186 .text("hello")
6187 .list_unsubscribe("weekly_digest")
6188 .header("X-Custom", "1")
6189 .build()
6190 .expect("mail should build");
6191 assert_eq!(mail.list_unsubscribe.as_deref(), Some("weekly_digest"));
6192 assert_eq!(
6193 mail.extra_headers,
6194 vec![("X-Custom".to_owned(), "1".to_owned())]
6195 );
6196 }
6197
6198 fn test_keys() -> crate::security::config::ResolvedSigningKeys {
6201 crate::security::config::ResolvedSigningKeys::new(
6202 b"unit-test-signing-key-0123456789".to_vec(),
6203 vec![],
6204 )
6205 }
6206
6207 #[test]
6208 fn token_roundtrips_and_hides_subscriber() {
6209 use base64::Engine as _;
6210 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
6211 let keys = test_keys();
6212 let token =
6213 unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
6214 assert!(
6215 !token.contains("ada@example.com"),
6216 "raw subscriber must not appear in the token: {token}"
6217 );
6218 assert!(
6221 !token.contains(&engine.encode("ada@example.com")),
6222 "base64 of subscriber must not appear — the payload must be encrypted: {token}"
6223 );
6224 let decoded = unsubscribe::verify_token(&keys, &token, 1_000).expect("token should verify");
6225 assert_eq!(decoded.subscriber, "ada@example.com");
6226 assert_eq!(decoded.list_id, "weekly_digest");
6227 }
6228
6229 #[test]
6230 fn token_rejects_tamper_and_expiry() {
6231 use base64::Engine as _;
6232 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
6233 let keys = test_keys();
6234 let token =
6235 unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 4_000_000_000);
6236 let mut blob = engine.decode(&token).expect("token is base64");
6238 let last = blob.len() - 1;
6239 blob[last] ^= 0x01;
6240 let tampered = engine.encode(&blob);
6241 assert_eq!(
6242 unsubscribe::verify_token(&keys, &tampered, 1_000),
6243 Err(unsubscribe::TokenError::BadSignature)
6244 );
6245 let short = unsubscribe::sign_token(&keys, "ada@example.com", "weekly_digest", 100);
6247 assert_eq!(
6248 unsubscribe::verify_token(&keys, &short, 200),
6249 Err(unsubscribe::TokenError::Expired)
6250 );
6251 }
6252
6253 #[test]
6254 fn token_verifies_under_rotated_previous_key() {
6255 let signer = crate::security::config::ResolvedSigningKeys::new(
6256 b"old-key-old-key-old-key-old-key!".to_vec(),
6257 vec![],
6258 );
6259 let token = unsubscribe::sign_token(&signer, "ada@example.com", "list", 4_000_000_000);
6260 let rotated = crate::security::config::ResolvedSigningKeys::new(
6261 b"new-key-new-key-new-key-new-key!".to_vec(),
6262 vec![b"old-key-old-key-old-key-old-key!".to_vec()],
6263 );
6264 assert!(unsubscribe::verify_token(&rotated, &token, 1_000).is_ok());
6265 }
6266
6267 #[test]
6268 fn unsubscribe_url_includes_token_and_path() {
6269 let url = unsubscribe::unsubscribe_url("https://app.example.com/", "TOK");
6270 assert_eq!(url, "https://app.example.com/_autumn/unsubscribe?token=TOK");
6271 }
6272
6273 #[tokio::test]
6276 async fn in_memory_suppression_transitions() {
6277 let store = InMemorySuppressionStore::new();
6278 assert!(!store.is_suppressed("a@x.com", "list").await.unwrap());
6279 store.suppress("a@x.com", "list").await.unwrap();
6280 assert!(store.is_suppressed("a@x.com", "list").await.unwrap());
6281 assert!(!store.is_suppressed("a@x.com", "other").await.unwrap());
6283 assert!(!store.is_suppressed("b@x.com", "list").await.unwrap());
6284 }
6285
6286 #[test]
6289 fn render_eml_emits_extra_headers() {
6290 let mail = Mail::builder()
6291 .from("from@example.com")
6292 .to("user@example.com")
6293 .subject("Hi")
6294 .text("hello")
6295 .header("List-Unsubscribe", "<https://x/u?token=t>, <mailto:u@x>")
6296 .header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
6297 .build()
6298 .expect("mail should build");
6299 let eml = render_eml(&mail);
6300 assert!(eml.contains("List-Unsubscribe: <https://x/u?token=t>, <mailto:u@x>"));
6301 assert!(eml.contains("List-Unsubscribe-Post: List-Unsubscribe=One-Click"));
6302 }
6303
6304 #[test]
6305 fn render_eml_without_headers_has_no_unsubscribe() {
6306 let mail = Mail::builder()
6307 .from("from@example.com")
6308 .to("user@example.com")
6309 .subject("Hi")
6310 .text("hello")
6311 .build()
6312 .expect("mail should build");
6313 assert!(!render_eml(&mail).contains("List-Unsubscribe"));
6314 }
6315
6316 #[derive(Clone)]
6317 struct CapturingTransport {
6318 sent: Arc<std::sync::Mutex<Vec<Mail>>>,
6319 }
6320
6321 impl MailTransport for CapturingTransport {
6322 fn send<'a>(
6323 &'a self,
6324 mail: Mail,
6325 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6326 Box::pin(async move {
6327 self.sent.lock().expect("sent lock").push(mail);
6328 Ok(())
6329 })
6330 }
6331 }
6332
6333 fn unsubscribe_runtime(
6334 suppression: Option<Arc<dyn SuppressionStore>>,
6335 ) -> Arc<UnsubscribeRuntime> {
6336 Arc::new(UnsubscribeRuntime {
6337 base_url: Some("https://app.example.com".to_owned()),
6338 mailto: Some("unsub@example.com".to_owned()),
6339 signing_keys: Arc::new(test_keys()),
6340 ttl_days: 30,
6341 suppression,
6342 })
6343 }
6344
6345 #[tokio::test]
6346 #[allow(clippy::significant_drop_tightening)]
6347 async fn send_adds_headers_for_list_mail() {
6348 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6349 let transport = CapturingTransport { sent: sent.clone() };
6350 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
6351 let mail = Mail::builder()
6352 .from("from@example.com")
6353 .to("user@example.com")
6354 .subject("Digest")
6355 .text("hello")
6356 .list_unsubscribe("weekly_digest")
6357 .build()
6358 .unwrap();
6359 mailer.send(mail).await.unwrap();
6360 let captured = sent.lock().unwrap();
6361 assert_eq!(captured.len(), 1);
6362 let headers = &captured[0].extra_headers;
6363 assert!(headers.iter().any(|(n, v)| n == "List-Unsubscribe"
6364 && v.contains("/_autumn/unsubscribe?token=")
6365 && v.contains("mailto:unsub@example.com")));
6366 assert!(
6367 headers
6368 .iter()
6369 .any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
6370 );
6371 }
6372
6373 #[tokio::test]
6374 #[allow(clippy::significant_drop_tightening)]
6375 async fn send_replaces_manual_list_unsubscribe_with_generated_one_click() {
6376 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6380 let transport = CapturingTransport { sent: sent.clone() };
6381 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
6382 let mail = Mail::builder()
6383 .from("from@example.com")
6384 .to("user@example.com")
6385 .subject("Digest")
6386 .text("hello")
6387 .header("List-Unsubscribe", "<mailto:old@example.com>")
6388 .list_unsubscribe("weekly_digest")
6389 .build()
6390 .unwrap();
6391 mailer.send(mail).await.unwrap();
6392 let captured = sent.lock().unwrap();
6393 assert_eq!(captured.len(), 1);
6394 let headers = &captured[0].extra_headers;
6395 let unsub: Vec<&String> = headers
6398 .iter()
6399 .filter(|(n, _)| n == "List-Unsubscribe")
6400 .map(|(_, v)| v)
6401 .collect();
6402 assert_eq!(unsub.len(), 1);
6403 assert!(unsub[0].contains("/_autumn/unsubscribe?token="));
6404 assert!(!unsub[0].contains("old@example.com"));
6405 assert!(
6406 headers
6407 .iter()
6408 .any(|(n, v)| n == "List-Unsubscribe-Post" && v == "List-Unsubscribe=One-Click")
6409 );
6410 }
6411
6412 #[tokio::test]
6413 async fn send_list_mail_rejects_invalid_recipient_before_delivery() {
6414 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6415 let transport = CapturingTransport { sent: sent.clone() };
6416 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
6417 let mail = Mail::builder()
6420 .from("from@example.com")
6421 .to("good@example.com")
6422 .to("not a valid address")
6423 .subject("Digest")
6424 .text("hello")
6425 .list_unsubscribe("weekly_digest")
6426 .build()
6427 .unwrap();
6428 let result = mailer.send(mail).await;
6429 assert!(result.is_err(), "invalid recipient must fail the send");
6430 assert!(
6431 sent.lock().unwrap().is_empty(),
6432 "no recipient may be delivered when the list contains an invalid address"
6433 );
6434 }
6435
6436 #[tokio::test]
6437 async fn send_list_mail_suppression_error_fails_before_any_delivery() {
6438 struct FailingStore {
6440 fail_for: String,
6441 }
6442 impl SuppressionStore for FailingStore {
6443 fn is_suppressed<'a>(
6444 &'a self,
6445 subscriber: &'a str,
6446 _list_id: &'a str,
6447 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
6448 let fails = subscriber == self.fail_for;
6449 Box::pin(async move {
6450 if fails {
6451 Err(MailError::RuntimeUnavailable(
6452 "store unavailable".to_owned(),
6453 ))
6454 } else {
6455 Ok(false)
6456 }
6457 })
6458 }
6459 fn suppress<'a>(
6460 &'a self,
6461 _subscriber: &'a str,
6462 _list_id: &'a str,
6463 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6464 Box::pin(async move { Ok(()) })
6465 }
6466 }
6467
6468 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6469 let transport = CapturingTransport { sent: sent.clone() };
6470 let store: Arc<dyn SuppressionStore> = Arc::new(FailingStore {
6471 fail_for: "second@example.com".to_owned(),
6472 });
6473 let mailer =
6474 Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
6475 let mail = Mail::builder()
6479 .from("from@example.com")
6480 .to("first@example.com")
6481 .to("second@example.com")
6482 .subject("Digest")
6483 .text("hello")
6484 .list_unsubscribe("weekly_digest")
6485 .build()
6486 .unwrap();
6487 let result = mailer.send(mail).await;
6488 assert!(
6489 result.is_err(),
6490 "suppression-store error must fail the send"
6491 );
6492 assert!(
6493 sent.lock().unwrap().is_empty(),
6494 "no recipient may be delivered when a later suppression lookup fails"
6495 );
6496 }
6497
6498 #[tokio::test]
6499 async fn send_skips_suppressed_recipient() {
6500 let store = Arc::new(InMemorySuppressionStore::new());
6501 store
6502 .suppress("user@example.com", "weekly_digest")
6503 .await
6504 .unwrap();
6505 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6506 let transport = CapturingTransport { sent: sent.clone() };
6507 let mailer =
6508 Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(Some(store)));
6509 let mail = Mail::builder()
6510 .from("from@example.com")
6511 .to("user@example.com")
6512 .subject("Digest")
6513 .text("hello")
6514 .list_unsubscribe("weekly_digest")
6515 .build()
6516 .unwrap();
6517 mailer.send(mail).await.unwrap();
6518 assert!(
6519 sent.lock().unwrap().is_empty(),
6520 "suppressed recipient must be skipped"
6521 );
6522 }
6523
6524 #[tokio::test]
6525 async fn send_list_mail_resolves_suppression_in_one_batched_call() {
6526 use std::sync::atomic::AtomicUsize;
6527
6528 struct CountingStore {
6533 is_suppressed_calls: AtomicUsize,
6534 is_suppressed_many_calls: AtomicUsize,
6535 suppressed: std::collections::HashSet<String>,
6536 }
6537 impl SuppressionStore for CountingStore {
6538 fn is_suppressed<'a>(
6539 &'a self,
6540 subscriber: &'a str,
6541 _list_id: &'a str,
6542 ) -> Pin<Box<dyn Future<Output = Result<bool, MailError>> + Send + 'a>> {
6543 self.is_suppressed_calls.fetch_add(1, Ordering::SeqCst);
6544 let hit = self.suppressed.contains(subscriber);
6545 Box::pin(async move { Ok(hit) })
6546 }
6547 fn is_suppressed_many<'a>(
6548 &'a self,
6549 subscribers: &'a [&'a str],
6550 _list_id: &'a str,
6551 ) -> Pin<
6552 Box<
6553 dyn Future<Output = Result<std::collections::HashSet<String>, MailError>>
6554 + Send
6555 + 'a,
6556 >,
6557 > {
6558 self.is_suppressed_many_calls.fetch_add(1, Ordering::SeqCst);
6559 let hits: std::collections::HashSet<String> = subscribers
6560 .iter()
6561 .filter(|s| self.suppressed.contains(**s))
6562 .map(|s| (*s).to_owned())
6563 .collect();
6564 Box::pin(async move { Ok(hits) })
6565 }
6566 fn suppress<'a>(
6567 &'a self,
6568 _subscriber: &'a str,
6569 _list_id: &'a str,
6570 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
6571 Box::pin(async move { Ok(()) })
6572 }
6573 }
6574
6575 let mut suppressed = std::collections::HashSet::new();
6576 suppressed.insert("banned@example.com".to_owned());
6577 let store = Arc::new(CountingStore {
6578 is_suppressed_calls: AtomicUsize::new(0),
6579 is_suppressed_many_calls: AtomicUsize::new(0),
6580 suppressed,
6581 });
6582 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6583 let transport = CapturingTransport { sent: sent.clone() };
6584 let mailer = Mailer::with_transport(transport)
6585 .with_unsubscribe(unsubscribe_runtime(Some(store.clone())));
6586 let mail = Mail::builder()
6587 .from("from@example.com")
6588 .to("first@example.com")
6589 .to("banned@example.com")
6590 .to("third@example.com")
6591 .subject("Digest")
6592 .text("hello")
6593 .list_unsubscribe("weekly_digest")
6594 .build()
6595 .unwrap();
6596 mailer.send(mail).await.unwrap();
6597
6598 assert_eq!(
6599 store.is_suppressed_many_calls.load(Ordering::SeqCst),
6600 1,
6601 "suppression for the whole recipient batch must resolve in exactly one call, \
6602 regardless of recipient count"
6603 );
6604 assert_eq!(
6605 store.is_suppressed_calls.load(Ordering::SeqCst),
6606 0,
6607 "the per-recipient is_suppressed path must not be used when a batch override exists"
6608 );
6609 assert_eq!(
6610 sent.lock().unwrap().len(),
6611 2,
6612 "only the two non-suppressed recipients are delivered"
6613 );
6614 }
6615
6616 #[cfg(feature = "reporting")]
6619 #[tokio::test]
6620 async fn replay_mailer_answers_from_the_tape_and_refuses_off_tape_sends() {
6621 let state = AppState::for_test();
6622 install_replay_mailer(&state, &MailConfig::default(), false);
6623 let mailer = state.extension::<Mailer>().expect("installed");
6624 let mail = || {
6625 Mail::builder()
6626 .from("from@example.com")
6627 .to("user@example.com")
6628 .subject("Reset")
6629 .text("hello")
6630 .build()
6631 .unwrap()
6632 };
6633 let tape = Arc::new(crate::capsule::ReplayEffects::new(
6634 crate::capsule::CapsuleEffects {
6635 mail: vec![crate::capsule::MailEffect {
6636 to: vec!["user@example.com".to_owned()],
6637 from: Some("from@example.com".to_owned()),
6638 subject: "Reset".to_owned(),
6639 body: crate::capsule::CapsuleBody::Text("hello".to_owned()),
6640 ..Default::default()
6641 }],
6642 ..Default::default()
6643 },
6644 ));
6645 crate::capsule::with_effect_tape(Arc::clone(&tape), mailer.send(mail()))
6646 .await
6647 .expect("served from the tape");
6648 assert!(tape.finish().is_empty(), "{:?}", tape.finish());
6649 assert!(
6650 mailer.send(mail()).await.is_err(),
6651 "a send with no tape must not reach a transport"
6652 );
6653 }
6654
6655 #[cfg(feature = "reporting")]
6658 #[tokio::test]
6659 async fn a_v3_capsule_replays_a_disabled_deliver_later_as_before() {
6660 let state = AppState::for_test();
6661 let config = MailConfig {
6662 transport: Transport::Disabled,
6663 ..MailConfig::default()
6664 };
6665 install_replay_mailer(&state, &config, false);
6666 let mailer = state.extension::<Mailer>().expect("installed");
6667 let mail = Mail::builder()
6668 .from("from@example.com")
6669 .to("user@example.com")
6670 .subject("Later")
6671 .text("hello")
6672 .build()
6673 .unwrap();
6674 let tape = Arc::new(
6675 crate::capsule::ReplayEffects::new(crate::capsule::CapsuleEffects::default())
6676 .for_format_version(3),
6677 );
6678 let replayed = crate::capsule::with_effect_tape(Arc::clone(&tape), async {
6679 mailer.try_deliver_later(mail)
6680 })
6681 .await;
6682 assert!(replayed.is_ok(), "{replayed:?}");
6683 assert!(tape.finish().is_empty(), "{:?}", tape.finish());
6684 }
6685
6686 #[cfg(feature = "reporting")]
6689 #[test]
6690 fn replay_mailer_keeps_the_installed_mailer_defaults() {
6691 let state = AppState::for_test();
6692 let installed = Mailer::builder()
6693 .transport(Transport::Disabled)
6694 .from("app@example.com")
6695 .reply_to("support@example.com")
6696 .inline_css(true)
6697 .build()
6698 .unwrap();
6699 state.insert_extension(installed);
6700 install_replay_mailer(&state, &MailConfig::default(), false);
6701 let replay = state.extension::<Mailer>().expect("installed");
6702 assert_eq!(replay.defaults.from.as_deref(), Some("app@example.com"));
6703 assert_eq!(
6704 replay.defaults.reply_to.as_deref(),
6705 Some("support@example.com")
6706 );
6707 assert!(replay.inline_css_default);
6708 assert!(replay.is_disabled());
6709 }
6710
6711 #[cfg(feature = "reporting")]
6714 #[tokio::test]
6715 async fn a_send_with_no_tape_in_a_replaying_process_is_refused() {
6716 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6717 let mailer = Mailer::with_transport(CapturingTransport {
6718 sent: Arc::clone(&sent),
6719 });
6720 let mail = Mail::builder()
6721 .from("from@example.com")
6722 .to("user@example.com")
6723 .subject("Startup")
6724 .text("hello")
6725 .build()
6726 .unwrap();
6727 crate::capsule::boundary::TEST_REPLAYING.with(|replaying| replaying.set(true));
6728 let result = mailer.send(mail).await;
6729 crate::capsule::boundary::TEST_REPLAYING.with(|replaying| replaying.set(false));
6730 assert!(result.is_err(), "{result:?}");
6731 assert!(sent.lock().unwrap().is_empty());
6732 }
6733
6734 #[cfg(feature = "reporting")]
6737 #[test]
6738 fn replay_mailer_keeps_a_builder_queue_capability() {
6739 let sending = MailConfig {
6741 transport: Transport::Log,
6742 ..MailConfig::default()
6743 };
6744 let state = AppState::for_test();
6745 install_replay_mailer(&state, &sending, true);
6746 assert!(
6747 state
6748 .extension::<Mailer>()
6749 .expect("installed")
6750 .has_durable_delivery_queue()
6751 );
6752 let state = AppState::for_test();
6753 install_replay_mailer(&state, &sending, false);
6754 assert!(
6755 !state
6756 .extension::<Mailer>()
6757 .expect("installed")
6758 .has_durable_delivery_queue()
6759 );
6760 }
6761
6762 #[cfg(feature = "reporting")]
6765 #[tokio::test]
6766 async fn replay_mailer_keeps_the_durable_queue_capability() {
6767 let state = AppState::for_test();
6768 let mut installed = Mailer::with_transport(DisabledTransport);
6769 installed.delivery_queue = Some(Arc::new(ReplayTransport { disabled: false }));
6770 state.insert_extension(installed);
6771 install_replay_mailer(&state, &MailConfig::default(), false);
6772 let replay = state.extension::<Mailer>().expect("installed");
6773 assert!(replay.has_durable_delivery_queue());
6774 let queue = replay.delivery_queue.clone().expect("queue");
6775 let mail = Mail::builder()
6776 .from("from@example.com")
6777 .to("user@example.com")
6778 .subject("Later")
6779 .text("hello")
6780 .build()
6781 .unwrap();
6782 assert!(queue.enqueue(mail).await.is_err());
6783 }
6784
6785 #[cfg(feature = "reporting")]
6789 #[test]
6790 fn a_second_replay_mailer_install_keeps_the_first() {
6791 let state = AppState::for_test();
6792 let config = MailConfig {
6793 transport: Transport::Log,
6794 from: Some("app@example.com".to_owned()),
6795 inline_css: true,
6796 ..MailConfig::default()
6797 };
6798 install_replay_mailer(&state, &config, true);
6799 install_replay_mailer(&state, &MailConfig::default(), false);
6800 let replay = state.extension::<Mailer>().expect("installed");
6801 assert!(!replay.is_disabled());
6802 assert!(replay.has_durable_delivery_queue());
6803 assert_eq!(replay.defaults.from.as_deref(), Some("app@example.com"));
6804 assert!(replay.inline_css_default);
6805 }
6806
6807 #[cfg(feature = "reporting")]
6810 #[test]
6811 fn a_disabled_replay_mailer_has_no_durable_queue() {
6812 let state = AppState::for_test();
6813 let config = MailConfig {
6814 transport: Transport::Disabled,
6815 ..MailConfig::default()
6816 };
6817 install_replay_mailer(&state, &config, true);
6818 let replay = state.extension::<Mailer>().expect("installed");
6819 assert!(replay.is_disabled());
6820 assert!(!replay.has_durable_delivery_queue());
6821 }
6822
6823 #[cfg(feature = "reporting")]
6826 #[tokio::test]
6827 async fn a_production_deliver_later_refusal_replays_as_the_same_error() {
6828 let mut mailer = Mailer::with_transport(DisabledTransport);
6829 mailer.transport = Arc::new(CapturingTransport {
6830 sent: Arc::new(std::sync::Mutex::new(Vec::new())),
6831 });
6832 mailer.block_deliver_later_without_durable_queue = true;
6833 let mail = || {
6834 Mail::builder()
6835 .from("from@example.com")
6836 .to("user@example.com")
6837 .subject("Later")
6838 .text("hello")
6839 .build()
6840 .unwrap()
6841 };
6842 let scope = Arc::new(crate::capsule::CaptureScope::new(
6843 "refused".to_owned(),
6844 Arc::new(crate::capsule::CaptureSettings::default()),
6845 Arc::new(crate::log::filter::ParameterFilter::default()),
6846 ));
6847 let captured = crate::capsule::capture::with_capture_scope(Arc::clone(&scope), async {
6848 mailer.try_deliver_later(mail())
6849 })
6850 .await;
6851 assert!(matches!(
6852 captured,
6853 Err(MailError::NoDurableQueueInProduction)
6854 ));
6855 let effects = scope.effects_snapshot();
6856 assert_eq!(effects.mail.len(), 1);
6857
6858 let state = AppState::for_test();
6859 install_replay_mailer(&state, &MailConfig::default(), false);
6860 let replay = state.extension::<Mailer>().expect("installed");
6861 let tape = Arc::new(crate::capsule::ReplayEffects::new(effects));
6862 let replayed = crate::capsule::with_effect_tape(Arc::clone(&tape), async {
6863 replay.try_deliver_later(mail())
6864 })
6865 .await;
6866 assert!(
6867 matches!(replayed, Err(MailError::NoDurableQueueInProduction)),
6868 "{replayed:?}"
6869 );
6870 assert!(tape.finish().is_empty(), "{:?}", tape.finish());
6871 }
6872
6873 #[cfg(feature = "reporting")]
6876 #[tokio::test]
6877 async fn deliver_later_on_a_disabled_transport_is_recorded() {
6878 let mailer = Mailer::with_transport(DisabledTransport);
6879 let scope = Arc::new(crate::capsule::CaptureScope::new(
6880 "disabled".to_owned(),
6881 Arc::new(crate::capsule::CaptureSettings::default()),
6882 Arc::new(crate::log::filter::ParameterFilter::default()),
6883 ));
6884 crate::capsule::capture::with_capture_scope(Arc::clone(&scope), async {
6885 mailer
6886 .try_deliver_later(
6887 Mail::builder()
6888 .from("from@example.com")
6889 .to("user@example.com")
6890 .subject("Later")
6891 .text("hello")
6892 .build()
6893 .unwrap(),
6894 )
6895 .expect("a disabled transport drops the mail");
6896 })
6897 .await;
6898 assert_eq!(scope.effects_snapshot().mail.len(), 1);
6899 }
6900
6901 #[cfg(all(feature = "reporting", feature = "db"))]
6904 #[tokio::test]
6905 async fn deliver_later_in_a_transaction_is_recorded_at_registration() {
6906 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6907 let mailer = Mailer::with_transport(CapturingTransport { sent: sent.clone() });
6908 let scope = Arc::new(crate::capsule::CaptureScope::new(
6909 "deliver-later".to_owned(),
6910 Arc::new(crate::capsule::CaptureSettings::default()),
6911 Arc::new(crate::log::filter::ParameterFilter::default()),
6912 ));
6913 let registry: Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>> =
6914 Arc::new(std::sync::Mutex::new(Vec::new()));
6915 crate::capsule::capture::with_capture_scope(
6916 Arc::clone(&scope),
6917 crate::db::AFTER_COMMIT_REGISTRY.scope(Arc::clone(®istry), async {
6918 mailer
6919 .try_deliver_later(
6920 Mail::builder()
6921 .from("from@example.com")
6922 .to("user@example.com")
6923 .subject("Later")
6924 .text("hello")
6925 .build()
6926 .unwrap(),
6927 )
6928 .expect("registered");
6929 }),
6930 )
6931 .await;
6932 assert_eq!(scope.effects_snapshot().mail.len(), 1);
6933 assert!(!scope.is_truncated());
6934 }
6935
6936 #[tokio::test]
6937 #[allow(clippy::significant_drop_tightening)]
6938 async fn send_without_scope_is_unchanged() {
6939 let sent = Arc::new(std::sync::Mutex::new(Vec::new()));
6940 let transport = CapturingTransport { sent: sent.clone() };
6941 let mailer = Mailer::with_transport(transport).with_unsubscribe(unsubscribe_runtime(None));
6942 let mail = Mail::builder()
6943 .from("from@example.com")
6944 .to("user@example.com")
6945 .subject("Reset")
6946 .text("hello")
6947 .build()
6948 .unwrap();
6949 mailer.send(mail).await.unwrap();
6950 let captured = sent.lock().unwrap();
6951 assert_eq!(captured.len(), 1);
6952 assert!(
6953 captured[0].extra_headers.is_empty(),
6954 "non-list mail must not gain headers"
6955 );
6956 }
6957
6958 #[test]
6961 fn fail_closed_only_in_prod_with_mailers_and_no_config() {
6962 assert!(unsubscribe_config_fail_closed(true, true, true, false));
6963 assert!(!unsubscribe_config_fail_closed(true, true, true, true));
6965 assert!(!unsubscribe_config_fail_closed(true, true, false, false));
6967 assert!(!unsubscribe_config_fail_closed(true, false, true, false));
6969 assert!(!unsubscribe_config_fail_closed(false, true, true, false));
6971 }
6972
6973 #[test]
6974 fn validate_rejects_non_positive_unsubscribe_ttl() {
6975 let ttl = |days: i64| MailConfig {
6976 unsubscribe_token_ttl_days: days,
6977 ..MailConfig::default()
6978 };
6979 assert!(ttl(0).validate(Some("dev")).is_err());
6980 assert!(ttl(-1).validate(Some("dev")).is_err());
6981 assert!(ttl(30).validate(Some("dev")).is_ok());
6982 }
6983
6984 #[test]
6985 fn unsubscribe_base_url_set_tracks_config() {
6986 let with = |base: Option<&str>, mailto: Option<&str>| MailConfig {
6987 unsubscribe_base_url: base.map(str::to_owned),
6988 unsubscribe_mailto: mailto.map(str::to_owned),
6989 ..MailConfig::default()
6990 };
6991 assert!(!with(None, None).unsubscribe_base_url_set());
6992 assert!(!with(None, Some("u@example.com")).unsubscribe_base_url_set());
6994 assert!(with(Some("https://x"), None).unsubscribe_base_url_set());
6995 assert!(!with(Some(" "), None).unsubscribe_base_url_set());
6996 }
6997
6998 #[test]
6999 fn should_mount_unsubscribe_endpoint_requires_opt_in_and_base_url() {
7000 let cfg = |base: Option<&str>, opt_in: bool| MailConfig {
7001 unsubscribe_base_url: base.map(str::to_owned),
7002 mount_unsubscribe_endpoint: opt_in,
7003 ..MailConfig::default()
7004 };
7005 assert!(!cfg(Some("https://x"), false).should_mount_unsubscribe_endpoint());
7007 assert!(cfg(Some("https://x"), true).should_mount_unsubscribe_endpoint());
7008 assert!(!cfg(None, true).should_mount_unsubscribe_endpoint());
7010 }
7011
7012 #[test]
7013 fn validate_rejects_malformed_mailto_in_prod() {
7014 let cfg = |mailto: &str| MailConfig {
7015 unsubscribe_mailto: Some(mailto.to_owned()),
7016 ..MailConfig::default()
7017 };
7018 assert!(
7019 cfg("unsubscribe example.com")
7020 .validate(Some("prod"))
7021 .is_err()
7022 );
7023 assert!(cfg("not-an-email").validate(Some("prod")).is_err());
7024 assert!(cfg("unsub@example.com").validate(Some("prod")).is_ok());
7025 assert!(
7027 cfg("mailto:unsub@example.com")
7028 .validate(Some("prod"))
7029 .is_ok()
7030 );
7031 assert!(cfg("whatever").validate(Some("dev")).is_ok());
7033 }
7034
7035 #[test]
7036 fn validate_requires_https_base_url_in_prod() {
7037 let cfg = |url: &str| MailConfig {
7038 unsubscribe_base_url: Some(url.to_owned()),
7039 ..MailConfig::default()
7040 };
7041 assert!(
7042 cfg("http://app.example.com")
7043 .validate(Some("prod"))
7044 .is_err()
7045 );
7046 assert!(
7047 cfg("https://app.example.com")
7048 .validate(Some("prod"))
7049 .is_ok()
7050 );
7051 assert!(cfg("http://localhost:3000").validate(Some("dev")).is_ok());
7053 assert!(cfg("https://").validate(Some("prod")).is_err());
7055 assert!(cfg("https:///path").validate(Some("prod")).is_err());
7056 assert!(
7058 cfg("https://app.example.com?t=acme")
7059 .validate(Some("prod"))
7060 .is_err()
7061 );
7062 assert!(
7063 cfg("https://app.example.com#x")
7064 .validate(Some("prod"))
7065 .is_err()
7066 );
7067 assert!(
7068 cfg("https://app.example.com/base")
7069 .validate(Some("prod"))
7070 .is_ok()
7071 );
7072 }
7073
7074 #[test]
7075 fn canonical_subscriber_strips_name_and_lowercases() {
7076 assert_eq!(
7077 canonical_subscriber("Ada Lovelace <Ada@Example.com>"),
7078 "ada@example.com"
7079 );
7080 assert_eq!(canonical_subscriber("USER@EXAMPLE.COM"), "user@example.com");
7081 }
7082
7083 #[test]
7084 fn mailto_only_runtime_does_not_support_one_click() {
7085 let runtime = UnsubscribeRuntime {
7086 base_url: None,
7087 mailto: Some("u@example.com".to_owned()),
7088 signing_keys: Arc::new(test_keys()),
7089 ttl_days: 30,
7090 suppression: None,
7091 };
7092 assert!(!runtime.supports_one_click());
7093 let header = runtime
7094 .list_unsubscribe_header("a@x.com", "list")
7095 .expect("mailto header");
7096 assert!(header.contains("mailto:u@example.com"));
7097 assert!(!header.contains("token="));
7098 }
7099
7100 #[test]
7101 fn mailto_value_with_scheme_is_not_double_prefixed() {
7102 let runtime = UnsubscribeRuntime {
7103 base_url: None,
7104 mailto: Some("mailto:u@example.com".to_owned()),
7105 signing_keys: Arc::new(test_keys()),
7106 ttl_days: 30,
7107 suppression: None,
7108 };
7109 let header = runtime
7110 .list_unsubscribe_header("a@x.com", "list")
7111 .expect("mailto header");
7112 assert!(header.contains("<mailto:u@example.com?subject=unsubscribe>"));
7113 assert!(!header.contains("mailto:mailto:"));
7114 }
7115
7116 #[test]
7117 fn one_click_body_detection() {
7118 assert!(is_one_click_body("List-Unsubscribe=One-Click"));
7119 assert!(is_one_click_body("foo=bar&List-Unsubscribe=One-Click"));
7120 assert!(is_one_click_body("list-unsubscribe=one-click")); assert!(!is_one_click_body(""));
7122 assert!(!is_one_click_body("List-Unsubscribe=Nope"));
7123 assert!(!is_one_click_body("something=else"));
7124 }
7125
7126 #[test]
7127 fn smtp_config_validation_rejects_whitespace_only_host() {
7128 let config = MailConfig {
7129 transport: Transport::Smtp,
7130 smtp: SmtpConfig {
7131 host: Some(" ".to_owned()),
7132 ..Default::default()
7133 },
7134 ..Default::default()
7135 };
7136
7137 let error = config
7138 .validate(Some("dev"))
7139 .expect_err("whitespace SMTP host should be rejected");
7140
7141 assert!(error.to_string().contains("mail.smtp.host is required"));
7142 }
7143
7144 #[test]
7145 fn transport_env_value_is_trimmed_and_case_insensitive() {
7146 assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
7147 assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
7148 }
7149
7150 #[test]
7151 fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
7152 assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
7153 assert_eq!(
7154 TlsMode::from_env_value(" START_TLS "),
7155 Some(TlsMode::StartTls)
7156 );
7157 assert_eq!(
7158 TlsMode::from_env_value(" disabled "),
7159 Some(TlsMode::Disabled)
7160 );
7161 }
7162
7163 #[test]
7164 fn file_transport_filename_is_unique_for_same_recipient() {
7165 let mail = Mail::builder()
7166 .to("Ada Lovelace <ada@example.com>")
7167 .subject("Hello")
7168 .text("body")
7169 .build()
7170 .expect("mail should build");
7171
7172 let first = file_transport_filename(&mail);
7173 let second = file_transport_filename(&mail);
7174
7175 assert_ne!(first, second);
7176 assert!(
7177 Path::new(&first)
7178 .extension()
7179 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
7180 );
7181 assert!(
7182 Path::new(&second)
7183 .extension()
7184 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
7185 );
7186 }
7187
7188 #[test]
7189 fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
7190 let missing_key = format!(
7191 "AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
7192 std::process::id(),
7193 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
7194 );
7195 let Err(error) = SmtpTransport::new(
7196 SmtpConfig {
7197 host: Some("smtp.example.com".to_owned()),
7198 port: Some(587),
7199 username: Some("mailer".to_owned()),
7200 password_env: Some(missing_key.clone()),
7201 tls: TlsMode::StartTls,
7202 },
7203 None,
7204 ) else {
7205 panic!("missing password env should fail at startup");
7206 };
7207
7208 let displayed = error.to_string();
7209 assert!(displayed.contains(&missing_key));
7210 assert!(displayed.contains("environment variable is not set"));
7211 }
7212
7213 #[test]
7214 fn smtp_password_env_error_never_embeds_the_secret_value() {
7215 let secret = "hunter2-super-secret-password";
7221 let error = std::env::VarError::NotUnicode(std::ffi::OsString::from(secret));
7222 assert!(error.to_string().contains(secret));
7225 assert!(format!("{error:?}").contains(secret));
7226
7227 let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
7228 let displayed = mail_error.to_string();
7229 let debugged = format!("{mail_error:?}");
7230 assert!(
7231 !displayed.contains(secret),
7232 "Display output leaked the SMTP password: {displayed}"
7233 );
7234 assert!(
7235 !debugged.contains(secret),
7236 "Debug output leaked the SMTP password: {debugged}"
7237 );
7238 assert!(displayed.contains("APP_SMTP_PASSWORD"));
7241 assert!(displayed.contains("environment variable contains non-unicode data"));
7242 }
7243
7244 #[test]
7245 fn smtp_password_env_error_redacts_missing_variable_details() {
7246 let error = std::env::VarError::NotPresent;
7247 let mail_error = smtp_password_env_error("APP_SMTP_PASSWORD", &error);
7248 let displayed = mail_error.to_string();
7249 assert!(displayed.contains("APP_SMTP_PASSWORD"));
7250 assert!(displayed.contains("environment variable is not set"));
7251 }
7252
7253 #[test]
7254 fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
7255 let Err(error) = SmtpTransport::new(
7256 SmtpConfig {
7257 host: Some("smtp.example.com".to_owned()),
7258 port: Some(587),
7259 username: Some("mailer".to_owned()),
7260 password_env: None,
7261 tls: TlsMode::StartTls,
7262 },
7263 None,
7264 ) else {
7265 panic!("missing password_env setting should fail at startup");
7266 };
7267
7268 assert!(error.to_string().contains("mail.smtp.password_env"));
7269 }
7270
7271 #[test]
7272 fn mailer_builder_rejects_invalid_default_from_address() {
7273 let Err(error) = Mailer::builder().from("not an email address").build() else {
7274 panic!("invalid default from should fail fast");
7275 };
7276
7277 match error {
7278 MailError::InvalidAddress { address, .. } => {
7279 assert_eq!(address, "not an email address");
7280 }
7281 other => panic!("expected invalid address error, got {other:?}"),
7282 }
7283 }
7284
7285 #[test]
7286 fn mailer_from_config_rejects_invalid_default_reply_to_address() {
7287 let config = MailConfig {
7288 transport: Transport::Smtp,
7289 from: Some("Autumn <noreply@example.com>".to_owned()),
7290 reply_to: Some("definitely not an address".to_owned()),
7291 smtp: SmtpConfig {
7292 host: Some("smtp.example.com".to_owned()),
7293 ..Default::default()
7294 },
7295 ..Default::default()
7296 };
7297
7298 let Err(error) = Mailer::from_config(&config) else {
7299 panic!("invalid configured reply-to should fail at construction");
7300 };
7301
7302 match error {
7303 MailError::InvalidAddress { address, .. } => {
7304 assert_eq!(address, "definitely not an address");
7305 }
7306 other => panic!("expected invalid address error, got {other:?}"),
7307 }
7308 }
7309
7310 #[test]
7311 fn try_deliver_later_returns_error_without_runtime() {
7312 let mailer = Mailer::builder().build().expect("mailer should build");
7313 let mail = Mail::builder()
7314 .to("user@example.com")
7315 .subject("Hello")
7316 .text("hello")
7317 .build()
7318 .expect("mail should build");
7319
7320 let error = mailer
7321 .try_deliver_later(mail)
7322 .expect_err("missing runtime should return an error");
7323
7324 assert!(error.to_string().contains("active Tokio runtime"));
7325 }
7326
7327 #[test]
7328 fn deliver_later_does_not_panic_without_runtime() {
7329 let mailer = Mailer::builder().build().expect("mailer should build");
7330 let mail = Mail::builder()
7331 .to("user@example.com")
7332 .subject("Hello")
7333 .text("hello")
7334 .build()
7335 .expect("mail should build");
7336
7337 mailer.deliver_later(mail);
7338 }
7339
7340 fn sample_smtp_config() -> MailConfig {
7341 MailConfig {
7342 transport: Transport::Smtp,
7343 from: Some("Autumn <noreply@example.com>".to_owned()),
7344 smtp: SmtpConfig {
7345 host: Some("smtp.example.com".to_owned()),
7346 ..Default::default()
7347 },
7348 ..Default::default()
7349 }
7350 }
7351
7352 fn sample_mail() -> Mail {
7353 Mail::builder()
7354 .to("user@example.com")
7355 .subject("Hi")
7356 .text("hello")
7357 .build()
7358 .expect("mail should build")
7359 }
7360
7361 struct NoopQueue;
7362
7363 impl MailDeliveryQueue for NoopQueue {
7364 fn enqueue<'a>(
7365 &'a self,
7366 _mail: Mail,
7367 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7368 Box::pin(async { Ok(()) })
7369 }
7370 }
7371
7372 #[test]
7373 fn install_mailer_boots_in_prod_without_ack_but_blocks_deliver_later() {
7374 let state = crate::AppState::for_test().with_profile("prod");
7378 let config = sample_smtp_config();
7379
7380 install_mailer(&state, &config, true)
7381 .expect("missing durable queue/ack must not fail app boot");
7382
7383 let installed = state
7384 .extension::<Mailer>()
7385 .expect("install_mailer should store a Mailer extension");
7386
7387 let error = installed
7388 .try_deliver_later(sample_mail())
7389 .expect_err("deliver_later without a durable queue or ack must fail lazily in prod");
7390 let message = error.to_string();
7391 assert!(
7392 message.contains("allow_in_process_deliver_later_in_production"),
7393 "error should explain how to opt in: {message}"
7394 );
7395
7396 let error = installed
7397 .try_deliver_later_eager(sample_mail())
7398 .expect_err("deliver_later_eager must fail the same way");
7399 assert!(
7400 error
7401 .to_string()
7402 .contains("allow_in_process_deliver_later_in_production")
7403 );
7404 }
7405
7406 #[test]
7407 fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
7408 let state = crate::AppState::for_test().with_profile("prod");
7409 let config = MailConfig {
7410 allow_in_process_deliver_later_in_production: true,
7411 ..sample_smtp_config()
7412 };
7413
7414 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
7415 }
7416
7417 #[test]
7418 fn install_mailer_allows_durable_queue_in_prod_without_ack() {
7419 let state = crate::AppState::for_test().with_profile("prod");
7420 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
7421 let config = sample_smtp_config();
7422
7423 install_mailer(&state, &config, true)
7424 .expect("a registered durable queue should satisfy the prod guard");
7425 }
7426
7427 #[test]
7428 fn install_mailer_does_not_require_ack_outside_production() {
7429 let state = crate::AppState::for_test().with_profile("dev");
7430 let config = sample_smtp_config();
7431
7432 install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
7433 }
7434
7435 #[test]
7436 fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
7437 let state = crate::AppState::for_test().with_profile("prod");
7438 let config = MailConfig::default();
7439
7440 install_mailer(&state, &config, true)
7441 .expect("disabled transport never sends mail so it should not need an ack");
7442 }
7443
7444 struct CapturingQueue {
7445 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
7446 }
7447
7448 impl MailDeliveryQueue for CapturingQueue {
7449 fn enqueue<'a>(
7450 &'a self,
7451 mail: Mail,
7452 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7453 let tx = self.tx.clone();
7454 Box::pin(async move {
7455 tx.send(mail)
7456 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
7457 Ok(())
7458 })
7459 }
7460 }
7461
7462 #[cfg(feature = "db")]
7463 struct FailingQueue {
7464 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
7465 }
7466
7467 #[cfg(feature = "db")]
7468 impl MailDeliveryQueue for FailingQueue {
7469 fn enqueue<'a>(
7470 &'a self,
7471 mail: Mail,
7472 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7473 let tx = self.tx.clone();
7474 Box::pin(async move {
7475 tx.send(mail)
7476 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
7477 Err(MailError::RuntimeUnavailable("queue offline".to_owned()))
7478 })
7479 }
7480 }
7481
7482 #[cfg(feature = "db")]
7483 async fn drain_after_commit_callbacks_for_test(
7484 registry: &std::sync::Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>>,
7485 ) {
7486 let callbacks: Vec<crate::db::CommitCallback> = {
7487 let mut reg = registry
7488 .lock()
7489 .unwrap_or_else(std::sync::PoisonError::into_inner);
7490 std::mem::take(&mut *reg)
7491 };
7492
7493 for cb in callbacks {
7494 if let Err(error) = cb().await {
7495 crate::db::record_after_commit_failure();
7496 tracing::error!("test drain: after_commit callback failed: {error}");
7497 }
7498 }
7499 }
7500
7501 #[cfg(feature = "db")]
7502 #[tokio::test]
7503 async fn deferred_deliver_later_queue_failure_increments_after_commit_counter() {
7504 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
7505 let mailer = Mailer::builder()
7506 .delivery_queue(FailingQueue { tx })
7507 .build()
7508 .expect("mailer should build");
7509 let registry = std::sync::Arc::new(std::sync::Mutex::new(
7510 Vec::<crate::db::CommitCallback>::new(),
7511 ));
7512 let before =
7513 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
7514
7515 crate::db::AFTER_COMMIT_REGISTRY
7516 .scope(registry.clone(), async {
7517 mailer
7518 .try_deliver_later(sample_mail())
7519 .expect("registering deferred mail should succeed");
7520 })
7521 .await;
7522
7523 drain_after_commit_callbacks_for_test(®istry).await;
7524
7525 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
7526 .await
7527 .expect("queue should be called within 1s")
7528 .expect("queue should receive the mail");
7529 assert_eq!(received.subject, "Hi");
7530
7531 let after =
7532 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
7533 assert!(
7534 after > before,
7535 "deferred durable mail handoff failures should count as after_commit failures"
7536 );
7537 }
7538
7539 #[tokio::test]
7540 async fn deliver_later_routes_through_configured_queue() {
7541 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
7542
7543 let mailer = Mailer::builder()
7544 .delivery_queue(CapturingQueue { tx })
7545 .build()
7546 .expect("mailer should build");
7547
7548 mailer
7549 .try_deliver_later(sample_mail())
7550 .expect("scheduling onto the queue should succeed");
7551
7552 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
7553 .await
7554 .expect("queue should receive within 1s")
7555 .expect("queue should receive the mail");
7556
7557 assert_eq!(received.subject, "Hi");
7558 }
7559
7560 #[tokio::test]
7561 async fn deliver_later_preserves_attachments_through_queue() {
7562 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
7563
7564 let mailer = Mailer::builder()
7565 .delivery_queue(CapturingQueue { tx })
7566 .build()
7567 .expect("mailer should build");
7568
7569 let mail = Mail::builder()
7570 .to("user@example.com")
7571 .subject("Hi")
7572 .text("hello")
7573 .attach("invoice.pdf", "application/pdf", b"%PDF-1.4".to_vec())
7574 .build()
7575 .expect("mail should build");
7576
7577 mailer
7578 .try_deliver_later(mail.clone())
7579 .expect("scheduling onto the queue should succeed");
7580
7581 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
7582 .await
7583 .expect("queue should receive within 1s")
7584 .expect("queue should receive the mail");
7585
7586 let mut expected = mail;
7591 expected.inline_css = Some(false);
7592 assert_eq!(received, expected);
7593 }
7594
7595 #[tokio::test]
7596 async fn deferred_enqueue_freezes_originating_inline_css_default() {
7597 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
7604 let mailer = Mailer::builder()
7605 .inline_css(true)
7606 .delivery_queue(CapturingQueue { tx })
7607 .build()
7608 .expect("mailer should build");
7609
7610 let mail = Mail::builder()
7611 .to("user@example.com")
7612 .subject("Hi")
7613 .html(
7614 "<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
7615 )
7616 .build()
7617 .expect("mail should build");
7618 assert_eq!(mail.inline_css, None, "sample relies on the mailer default");
7619
7620 mailer
7621 .try_deliver_later(mail)
7622 .expect("scheduling onto the queue should succeed");
7623
7624 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
7625 .await
7626 .expect("queue should receive within 1s")
7627 .expect("queue should receive the mail");
7628
7629 assert_eq!(
7630 received.inline_css,
7631 Some(true),
7632 "the originating mailer's inlining default must be frozen onto the enqueued job"
7633 );
7634 assert!(
7635 received
7636 .html
7637 .as_deref()
7638 .expect("html body")
7639 .contains("<style>"),
7640 "the body must be left un-inlined at enqueue time; inlining happens once at the consumer's send()"
7641 );
7642 }
7643
7644 #[tokio::test]
7645 async fn deferred_enqueue_preserves_explicit_inline_css_override() {
7646 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
7649 let mailer = Mailer::builder()
7650 .inline_css(true)
7651 .delivery_queue(CapturingQueue { tx })
7652 .build()
7653 .expect("mailer should build");
7654
7655 let mail = Mail::builder()
7656 .to("user@example.com")
7657 .subject("Hi")
7658 .html(
7659 "<html><head><style>p { color: red; }</style></head><body><p>hi</p></body></html>",
7660 )
7661 .inline_css(false)
7662 .build()
7663 .expect("mail should build");
7664
7665 mailer
7666 .try_deliver_later(mail)
7667 .expect("scheduling onto the queue should succeed");
7668
7669 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
7670 .await
7671 .expect("queue should receive within 1s")
7672 .expect("queue should receive the mail");
7673
7674 assert_eq!(
7675 received.inline_css,
7676 Some(false),
7677 "an explicit per-message override must be preserved through the queue, not overwritten by the mailer default"
7678 );
7679 }
7680
7681 #[tokio::test]
7682 async fn deliver_later_without_queue_sends_via_transport_directly() {
7683 use std::sync::Arc;
7686 use std::sync::atomic::{AtomicBool, Ordering};
7687
7688 struct TrackingSend(Arc<AtomicBool>);
7689 impl MailTransport for TrackingSend {
7690 fn send<'a>(
7691 &'a self,
7692 _mail: Mail,
7693 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7694 self.0.store(true, Ordering::SeqCst);
7695 Box::pin(async { Ok(()) })
7696 }
7697 }
7698
7699 let sent = Arc::new(AtomicBool::new(false));
7700 let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
7701
7702 mailer
7703 .try_deliver_later(sample_mail())
7704 .expect("should succeed without queue");
7705
7706 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7707 assert!(
7708 sent.load(Ordering::SeqCst),
7709 "mail should have been sent directly via transport"
7710 );
7711 }
7712
7713 #[cfg(feature = "db")]
7714 #[tokio::test]
7715 async fn deferred_deliver_later_without_queue_sends_after_commit() {
7716 use std::sync::Arc;
7719 use std::sync::atomic::{AtomicBool, Ordering};
7720
7721 struct TrackingSend(Arc<AtomicBool>);
7722 impl MailTransport for TrackingSend {
7723 fn send<'a>(
7724 &'a self,
7725 _mail: Mail,
7726 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7727 self.0.store(true, Ordering::SeqCst);
7728 Box::pin(async { Ok(()) })
7729 }
7730 }
7731
7732 let sent = Arc::new(AtomicBool::new(false));
7733 let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
7734 let registry = std::sync::Arc::new(std::sync::Mutex::new(
7735 Vec::<crate::db::CommitCallback>::new(),
7736 ));
7737
7738 crate::db::AFTER_COMMIT_REGISTRY
7739 .scope(registry.clone(), async {
7740 mailer
7741 .try_deliver_later(sample_mail())
7742 .expect("should succeed");
7743 })
7744 .await;
7745
7746 drain_after_commit_callbacks_for_test(®istry).await;
7747 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
7748
7749 assert!(
7750 sent.load(Ordering::SeqCst),
7751 "mail should have been sent after commit via direct transport"
7752 );
7753 }
7754
7755 #[tokio::test]
7756 async fn mailer_with_transport_starts_without_delivery_queue() {
7757 let mailer = Mailer::with_transport(NoopTransport);
7758 assert!(
7759 !mailer.has_durable_delivery_queue(),
7760 "with_transport should default to no durable queue"
7761 );
7762 mailer
7764 .send(sample_mail())
7765 .await
7766 .expect("noop transport should always succeed");
7767 }
7768
7769 struct NoopTransport;
7770 impl MailTransport for NoopTransport {
7771 fn send<'a>(
7772 &'a self,
7773 _mail: Mail,
7774 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
7775 Box::pin(async { Ok(()) })
7776 }
7777 }
7778
7779 #[tokio::test]
7780 async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
7781 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
7786 let mailer = Mailer::builder()
7787 .transport(Transport::Disabled)
7788 .delivery_queue(CapturingQueue { tx })
7789 .build()
7790 .expect("mailer should build");
7791
7792 mailer
7793 .try_deliver_later(sample_mail())
7794 .expect("disabled transport should succeed as a no-op");
7795
7796 let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
7798 assert!(
7799 received.is_err(),
7800 "queue must not be invoked when transport is disabled"
7801 );
7802 }
7803
7804 #[tokio::test]
7805 async fn deliver_later_uses_in_process_fallback_when_no_queue() {
7806 let mailer = Mailer::builder().build().expect("mailer should build");
7809
7810 mailer
7811 .try_deliver_later(sample_mail())
7812 .expect("in-process fallback should still schedule");
7813 }
7814
7815 #[test]
7816 fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
7817 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
7818 let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
7819
7820 assert!(Arc::ptr_eq(handle.inner(), &arc));
7821 }
7822
7823 #[test]
7824 fn mail_delivery_queue_handle_debug_does_not_panic() {
7825 let handle = MailDeliveryQueueHandle::new(NoopQueue);
7826 let rendered = format!("{handle:?}");
7827 assert!(rendered.contains("MailDeliveryQueueHandle"));
7828 }
7829
7830 #[test]
7831 fn mailer_has_durable_delivery_queue_reflects_attachment() {
7832 let plain = Mailer::builder().build().expect("mailer should build");
7833 assert!(!plain.has_durable_delivery_queue());
7834
7835 let with_queue = Mailer::builder()
7836 .delivery_queue(NoopQueue)
7837 .build()
7838 .expect("mailer should build");
7839 assert!(with_queue.has_durable_delivery_queue());
7840 }
7841
7842 #[test]
7843 fn mailer_with_delivery_queue_post_build_attaches_queue() {
7844 let mailer = Mailer::builder()
7845 .build()
7846 .expect("mailer should build")
7847 .with_delivery_queue(NoopQueue);
7848
7849 assert!(mailer.has_durable_delivery_queue());
7850 }
7851
7852 #[test]
7853 fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
7854 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
7855 let mailer = Mailer::builder()
7856 .delivery_queue_arc(arc)
7857 .build()
7858 .expect("mailer should build");
7859
7860 assert!(mailer.has_durable_delivery_queue());
7861 }
7862
7863 #[test]
7864 fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
7865 let state = crate::AppState::for_test().with_profile("prod");
7868 let config = MailConfig {
7869 allow_in_process_deliver_later_in_production: true,
7870 ..sample_smtp_config()
7871 };
7872
7873 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
7874
7875 let installed = state
7876 .extension::<Mailer>()
7877 .expect("install_mailer should store a Mailer extension");
7878 assert!(
7879 !installed.has_durable_delivery_queue(),
7880 "no queue was registered, so installed mailer should fall back in-process"
7881 );
7882 }
7883
7884 #[test]
7885 fn install_mailer_attaches_registered_queue_to_mailer() {
7886 let state = crate::AppState::for_test().with_profile("prod");
7887 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
7888 let config = sample_smtp_config();
7889
7890 install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
7891
7892 let installed = state
7893 .extension::<Mailer>()
7894 .expect("install_mailer should store a Mailer extension");
7895 assert!(
7896 installed.has_durable_delivery_queue(),
7897 "registered queue handle should be attached to the installed mailer"
7898 );
7899 }
7900
7901 #[test]
7902 fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
7903 let state = crate::AppState::for_test().with_profile("prod");
7904 let config = sample_smtp_config();
7905 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
7906 let captured = Arc::clone(&factory_called);
7907
7908 let factory = move |_state: &crate::AppState| {
7909 captured.store(true, std::sync::atomic::Ordering::SeqCst);
7910 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
7911 };
7912
7913 install_mailer_with_factory(&state, &config, Some(factory), true)
7914 .expect("factory should produce a queue and satisfy the prod guard");
7915
7916 assert!(
7917 factory_called.load(std::sync::atomic::Ordering::SeqCst),
7918 "factory must run when enforce_durable_guard is true"
7919 );
7920 let installed = state
7921 .extension::<Mailer>()
7922 .expect("install_mailer should store a Mailer extension");
7923 assert!(
7924 installed.has_durable_delivery_queue(),
7925 "factory's queue should be wired into the installed Mailer"
7926 );
7927 }
7928
7929 #[test]
7930 fn install_mailer_with_factory_skips_factory_when_not_enforced() {
7931 let state = crate::AppState::for_test().with_profile("prod");
7932 let config = sample_smtp_config();
7933 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
7934 let captured = Arc::clone(&factory_called);
7935
7936 let factory = move |_state: &crate::AppState| {
7937 captured.store(true, std::sync::atomic::Ordering::SeqCst);
7938 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
7939 };
7940
7941 install_mailer_with_factory(&state, &config, Some(factory), false)
7942 .expect("static-build path should skip factory and install cleanly");
7943
7944 assert!(
7945 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
7946 "factory must be skipped when enforce_durable_guard is false"
7947 );
7948 }
7949
7950 #[test]
7951 fn install_mailer_with_factory_propagates_factory_errors() {
7952 let state = crate::AppState::for_test().with_profile("prod");
7953 let config = sample_smtp_config();
7954
7955 let factory = |_state: &crate::AppState| {
7956 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
7957 "queue offline",
7958 ))
7959 };
7960
7961 let error = install_mailer_with_factory(&state, &config, Some(factory), true)
7962 .expect_err("factory error should propagate");
7963 assert!(error.to_string().contains("queue offline"));
7964 }
7965
7966 #[test]
7967 fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
7968 let state = crate::AppState::for_test().with_profile("dev");
7973 let config = MailConfig::default(); let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
7975 let captured = Arc::clone(&factory_called);
7976
7977 let factory = move |_state: &crate::AppState| {
7978 captured.store(true, std::sync::atomic::Ordering::SeqCst);
7979 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
7980 "queue must not be reached",
7981 ))
7982 };
7983
7984 install_mailer_with_factory(&state, &config, Some(factory), true)
7985 .expect("disabled transport should bypass the factory entirely");
7986 assert!(
7987 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
7988 "factory must not run when transport = disabled"
7989 );
7990 }
7991
7992 #[test]
7993 fn install_mailer_with_factory_works_without_factory() {
7994 type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
7995 let state = crate::AppState::for_test().with_profile("dev");
7996 let config = sample_smtp_config();
7997 let no_factory: Option<FactoryFn> = None;
7998
7999 install_mailer_with_factory(&state, &config, no_factory, true)
8000 .expect("absent factory should be fine in non-prod");
8001 }
8002
8003 #[test]
8004 fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
8005 let state = crate::AppState::for_test().with_profile("prod");
8009 let config = sample_smtp_config();
8010
8011 install_mailer(&state, &config, false)
8012 .expect("static-build mode should install cleanly with no queue handle");
8013
8014 let installed = state
8015 .extension::<Mailer>()
8016 .expect("install_mailer should store a Mailer extension");
8017 assert!(
8018 !installed.has_durable_delivery_queue(),
8019 "no queue is expected when run_build_mode skips the factory"
8020 );
8021 }
8022
8023 #[test]
8024 fn install_mailer_skips_production_guard_when_not_enforced() {
8025 let state = crate::AppState::for_test().with_profile("prod");
8031 let config = sample_smtp_config();
8032
8033 install_mailer(&state, &config, false)
8034 .expect("static-build mode should not enforce the deliver_later guard");
8035 }
8036
8037 #[test]
8038 fn spawn_mail_delivery_inherits_parent_span() {
8039 use std::future::Future;
8040 use std::pin::Pin;
8041 use std::sync::{Arc, Mutex};
8042
8043 struct CapturingQueue(Arc<Mutex<Option<tracing::span::Id>>>);
8044 impl MailDeliveryQueue for CapturingQueue {
8045 fn enqueue<'a>(
8046 &'a self,
8047 _mail: Mail,
8048 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
8049 let captured = self.0.clone();
8050 Box::pin(async move {
8051 *captured.lock().unwrap() = tracing::Span::current().id();
8052 Ok(())
8053 })
8054 }
8055 }
8056
8057 let captured_span_id: Arc<Mutex<Option<tracing::span::Id>>> = Arc::new(Mutex::new(None));
8058
8059 let mailer = Mailer::builder()
8060 .delivery_queue(CapturingQueue(captured_span_id.clone()))
8061 .build()
8062 .expect("mailer with queue should build");
8063 let mail = sample_mail();
8064
8065 tracing::subscriber::with_default(tracing_subscriber::registry(), || {
8070 let rt = tokio::runtime::Builder::new_current_thread()
8071 .enable_all()
8072 .build()
8073 .expect("build runtime");
8074
8075 let outer = tracing::info_span!("deliver_later_outer");
8076 let outer_id = outer.id();
8077
8078 rt.block_on(async {
8079 {
8080 let _guard = outer.enter();
8081 mailer
8082 .try_deliver_later(mail)
8083 .expect("deliver_later must not fail");
8084 }
8085
8086 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
8087 });
8088
8089 let in_task = captured_span_id.lock().unwrap().clone();
8090 assert_eq!(
8091 in_task, outer_id,
8092 "delivery task must run inside the span that called deliver_later"
8093 );
8094 });
8095 }
8096
8097 #[tokio::test]
8098 async fn spawn_mail_delivery_logs_error_when_queue_fails() {
8099 use std::future::Future;
8100 use std::pin::Pin;
8101
8102 struct AlwaysFailQueue;
8103 impl MailDeliveryQueue for AlwaysFailQueue {
8104 fn enqueue<'a>(
8105 &'a self,
8106 _mail: Mail,
8107 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
8108 Box::pin(async { Err(MailError::RuntimeUnavailable("always fails".to_owned())) })
8109 }
8110 }
8111
8112 let mailer = Mailer::builder()
8113 .delivery_queue(AlwaysFailQueue)
8114 .build()
8115 .expect("build");
8116
8117 mailer
8118 .try_deliver_later(sample_mail())
8119 .expect("should schedule");
8120
8121 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8122 }
8123
8124 #[tokio::test]
8125 async fn spawn_mail_delivery_logs_error_when_transport_fails() {
8126 use std::future::Future;
8127 use std::pin::Pin;
8128
8129 struct AlwaysFailTransport;
8130 impl MailTransport for AlwaysFailTransport {
8131 fn send<'a>(
8132 &'a self,
8133 _mail: Mail,
8134 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
8135 Box::pin(async {
8136 Err(MailError::RuntimeUnavailable(
8137 "transport offline".to_owned(),
8138 ))
8139 })
8140 }
8141 }
8142
8143 let mailer = Mailer::with_transport(AlwaysFailTransport);
8144
8145 mailer
8146 .try_deliver_later(sample_mail())
8147 .expect("should schedule");
8148
8149 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
8150 }
8151
8152 #[test]
8153 fn install_mailer_does_not_attach_queue_when_transport_disabled() {
8154 let state = crate::AppState::for_test().with_profile("dev");
8159 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
8160 let config = MailConfig::default(); install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
8163
8164 let installed = state
8165 .extension::<Mailer>()
8166 .expect("install_mailer should store a Mailer extension");
8167 assert!(
8168 !installed.has_durable_delivery_queue(),
8169 "disabled transport must suppress queue attachment so deliver_later is a no-op"
8170 );
8171 }
8172
8173 #[tokio::test]
8174 async fn intercepted_mail_transport_short_circuit_prevents_sync_execution() {
8175 use std::future::Future;
8176 use std::pin::Pin;
8177 use std::sync::atomic::{AtomicU32, Ordering};
8178
8179 static TRANSPORT_CALLS: AtomicU32 = AtomicU32::new(0);
8180
8181 struct CountingTransport;
8182 impl MailTransport for CountingTransport {
8183 fn send<'a>(
8184 &'a self,
8185 _mail: Mail,
8186 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
8187 TRANSPORT_CALLS.fetch_add(1, Ordering::SeqCst);
8188 Box::pin(async move { Ok(()) })
8189 }
8190
8191 fn is_disabled(&self) -> bool {
8192 false
8193 }
8194 }
8195
8196 struct ShortCircuitMailInterceptor;
8197 impl crate::interceptor::MailInterceptor for ShortCircuitMailInterceptor {
8198 fn intercept<'a>(
8199 &'a self,
8200 _mail: &'a Mail,
8201 _next: Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>,
8202 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
8203 Box::pin(async move {
8204 Err(MailError::RuntimeUnavailable(
8205 "blocked by interceptor".to_owned(),
8206 ))
8207 })
8208 }
8209 }
8210
8211 let transport = Arc::new(CountingTransport);
8212 let interceptor = Arc::new(ShortCircuitMailInterceptor);
8213 let intercepted = InterceptedMailTransport {
8214 inner: transport,
8215 interceptor,
8216 };
8217
8218 let mail = Mail::builder()
8219 .to("test@example.com")
8220 .subject("test")
8221 .text("body")
8222 .build()
8223 .unwrap();
8224
8225 TRANSPORT_CALLS.store(0, Ordering::SeqCst);
8226
8227 let res = intercepted.send(mail).await;
8228 assert!(res.is_err());
8229 assert_eq!(TRANSPORT_CALLS.load(Ordering::SeqCst), 0);
8230 }
8231
8232 #[tokio::test]
8233 #[allow(clippy::await_holding_lock)]
8234 async fn test_smtp_transport_circuit_breaker() {
8235 let _lock = crate::circuit_breaker::TEST_LOCK
8236 .lock()
8237 .unwrap_or_else(std::sync::PoisonError::into_inner);
8238 crate::circuit_breaker::global_registry().clear();
8239 let policy = crate::circuit_breaker::CircuitBreakerPolicy {
8240 failure_ratio_threshold: 0.5,
8241 sample_window: std::time::Duration::from_secs(10),
8242 minimum_sample_count: 3,
8243 open_duration: std::time::Duration::from_secs(60),
8244 half_open_trial_count: 2,
8245 ..crate::circuit_breaker::CircuitBreakerPolicy::default()
8246 };
8247 let breaker =
8248 crate::circuit_breaker::global_registry().get_or_create("smtp_mailer", policy);
8249
8250 assert_eq!(
8252 breaker.state(),
8253 crate::circuit_breaker::CircuitState::Closed
8254 );
8255
8256 let config = SmtpConfig {
8258 host: Some("127.0.0.1".to_string()),
8259 port: Some(9999), tls: TlsMode::Disabled,
8261 username: None,
8262 password_env: None,
8263 };
8264 let transport = SmtpTransport::new(config, None).unwrap();
8265
8266 let mail = Mail::builder()
8267 .from("sender@example.com")
8268 .to("test@example.com")
8269 .subject("test")
8270 .text("body")
8271 .build()
8272 .unwrap();
8273
8274 for _ in 0..3 {
8276 let res = transport.send(mail.clone()).await;
8277 assert!(res.is_err());
8278 }
8279
8280 assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
8281
8282 let res = transport.send(mail.clone()).await;
8284 assert!(res.is_err());
8285 let err_str = res.err().unwrap().to_string();
8286 assert!(
8287 err_str.contains("circuit breaker")
8288 || err_str.contains("open")
8289 || err_str.contains("Open")
8290 || err_str.contains("runtime unavailable")
8291 );
8292
8293 crate::circuit_breaker::global_registry().clear();
8294 }
8295
8296 #[test]
8297 fn validate_log_transport_in_prod_fails() {
8298 let cfg = MailConfig {
8299 transport: Transport::Log,
8300 ..MailConfig::default()
8301 };
8302 assert!(cfg.validate(Some("prod")).is_err());
8303 assert!(cfg.validate(Some("production")).is_err());
8304 let allowed = MailConfig {
8306 transport: Transport::Log,
8307 allow_log_in_production: true,
8308 ..MailConfig::default()
8309 };
8310 assert!(allowed.validate(Some("prod")).is_ok());
8311 }
8312
8313 #[test]
8314 fn validate_preview_outside_dev_fails() {
8315 let cfg = MailConfig {
8316 preview: true,
8317 ..MailConfig::default()
8318 };
8319 assert!(cfg.validate(Some("prod")).is_err());
8320 assert!(cfg.validate(Some("dev")).is_ok());
8321 assert!(cfg.validate(Some("development")).is_ok());
8322 }
8323
8324 #[test]
8325 fn is_valid_https_base_url_edge_cases() {
8326 assert!(is_valid_https_base_url("https://app.example.com"));
8327 assert!(is_valid_https_base_url("https://app.example.com/base"));
8328 assert!(!is_valid_https_base_url("http://app.example.com"));
8329 assert!(!is_valid_https_base_url("https://"));
8330 assert!(!is_valid_https_base_url("https:///path"));
8331 assert!(!is_valid_https_base_url("https://app.example.com?q=1"));
8332 assert!(!is_valid_https_base_url("https://app.example.com#frag"));
8333 assert!(!is_valid_https_base_url("https://host name.com"));
8334 assert!(!is_valid_https_base_url("https://app.example.com:abc"));
8336 assert!(!is_valid_https_base_url("https://@/base"));
8337 assert!(!is_valid_https_base_url("https://user@app.example.com"));
8338 assert!(is_valid_https_base_url("https://app.example.com:8443"));
8340 assert!(!is_valid_https_base_url("https://example.com/<x>"));
8343 assert!(!is_valid_https_base_url("https://example.com/a b"));
8344 assert!(!is_valid_https_base_url("https://example.com/a\r\nb"));
8345 assert!(!is_valid_https_base_url("https:/app.example.com"));
8348 assert!(!is_valid_https_base_url("https:app.example.com"));
8349 }
8350
8351 #[test]
8352 fn is_valid_mailto_address_edge_cases() {
8353 assert!(is_valid_mailto_address("unsub@example.com"));
8354 assert!(is_valid_mailto_address("mailto:unsub@example.com"));
8355 assert!(is_valid_mailto_address(
8356 "mailto:unsub@example.com?subject=hi"
8357 ));
8358 assert!(!is_valid_mailto_address("not-an-email"));
8359 assert!(!is_valid_mailto_address("missing@dot"));
8360 assert!(!is_valid_mailto_address("space @example.com"));
8361 assert!(!is_valid_mailto_address(""));
8362 assert!(!is_valid_mailto_address("@example.com")); assert!(!is_valid_mailto_address("local@")); assert!(!is_valid_mailto_address("https://unsub@example.com"));
8366 assert!(!is_valid_mailto_address("mailto:https://unsub@example.com"));
8367 assert!(!is_valid_mailto_address("unsub@https://example.com"));
8368 assert!(!is_valid_mailto_address(
8371 "mailto:unsub@example.com?subject=x\r\nBcc: victim@example.com"
8372 ));
8373 assert!(!is_valid_mailto_address("unsub@example.com\nBcc: v@x.com"));
8374 assert!(!is_valid_mailto_address(
8377 "unsub@example.com>,<bogus@example.com"
8378 ));
8379 assert!(!is_valid_mailto_address("a@x.com,b@x.com"));
8380 }
8381
8382 #[test]
8383 fn unsubscribe_header_mailto_drops_configured_query_no_injection() {
8384 let runtime = UnsubscribeRuntime {
8388 base_url: None,
8389 mailto: Some("mailto:u@example.com?subject=x\r\nBcc: v@x.com".to_owned()),
8390 signing_keys: Arc::new(test_keys()),
8391 ttl_days: 30,
8392 suppression: None,
8393 };
8394 let header = runtime
8395 .list_unsubscribe_header("a@x.com", "list")
8396 .expect("mailto header");
8397 assert_eq!(header, "<mailto:u@example.com?subject=unsubscribe>");
8398 assert!(!header.contains('\r') && !header.contains('\n'));
8399 assert!(!header.contains("Bcc"));
8400 }
8401
8402 #[test]
8403 fn unsubscribe_runtime_header_both_base_url_and_mailto() {
8404 let runtime = UnsubscribeRuntime {
8405 base_url: Some("https://app.example.com".to_owned()),
8406 mailto: Some("u@example.com".to_owned()),
8407 signing_keys: Arc::new(test_keys()),
8408 ttl_days: 30,
8409 suppression: None,
8410 };
8411 let header = runtime
8412 .list_unsubscribe_header("a@x.com", "list")
8413 .expect("header with both");
8414 assert!(header.contains("https://app.example.com/_autumn/unsubscribe?token="));
8415 assert!(header.contains("mailto:u@example.com?subject=unsubscribe"));
8416 assert!(runtime.supports_one_click());
8417 }
8418
8419 #[test]
8420 fn unsubscribe_runtime_header_neither_configured_returns_none() {
8421 let runtime = UnsubscribeRuntime {
8422 base_url: None,
8423 mailto: None,
8424 signing_keys: Arc::new(test_keys()),
8425 ttl_days: 30,
8426 suppression: None,
8427 };
8428 assert!(runtime.list_unsubscribe_header("a@x.com", "list").is_none());
8429 assert!(!runtime.supports_one_click());
8430 }
8431}