1use std::future::Future;
8use std::path::{Path, PathBuf};
9use std::pin::Pin;
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU64, Ordering};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use axum::extract::FromRequestParts;
15use axum::response::{Html, IntoResponse, Response};
16use lettre::message::{Mailbox, MultiPart, SinglePart};
17use lettre::transport::smtp::authentication::Credentials;
18use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
19use serde::Deserialize;
20use thiserror::Error;
21
22use crate::{AppState, AutumnError, AutumnResult};
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Transport {
28 Log,
30 File,
32 Smtp,
34 #[default]
36 Disabled,
37}
38
39impl Transport {
40 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
41 match value.trim().to_ascii_lowercase().as_str() {
42 "log" => Some(Self::Log),
43 "file" => Some(Self::File),
44 "smtp" => Some(Self::Smtp),
45 "disabled" => Some(Self::Disabled),
46 _ => None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum TlsMode {
55 Disabled,
57 #[default]
59 StartTls,
60 Tls,
62}
63
64impl TlsMode {
65 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
66 match value.trim().to_ascii_lowercase().as_str() {
67 "disabled" => Some(Self::Disabled),
68 "starttls" | "start_tls" => Some(Self::StartTls),
69 "tls" => Some(Self::Tls),
70 _ => None,
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
77pub struct SmtpConfig {
78 #[serde(default)]
80 pub host: Option<String>,
81 #[serde(default)]
83 pub port: Option<u16>,
84 #[serde(default)]
86 pub username: Option<String>,
87 #[serde(default)]
89 pub password_env: Option<String>,
90 #[serde(default)]
92 pub tls: TlsMode,
93}
94
95impl Default for SmtpConfig {
96 fn default() -> Self {
97 Self {
98 host: None,
99 port: None,
100 username: None,
101 password_env: None,
102 tls: TlsMode::StartTls,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
109pub struct MailConfig {
110 #[serde(default)]
112 pub transport: Transport,
113 #[serde(default)]
115 pub from: Option<String>,
116 #[serde(default)]
118 pub reply_to: Option<String>,
119 #[serde(default)]
121 pub allow_log_in_production: bool,
122 #[serde(default)]
126 pub allow_in_process_deliver_later_in_production: bool,
127 #[serde(default = "default_file_dir")]
129 pub file_dir: PathBuf,
130 #[serde(default)]
135 pub preview: bool,
136 #[serde(default)]
138 pub smtp: SmtpConfig,
139}
140
141impl Default for MailConfig {
142 fn default() -> Self {
143 Self {
144 transport: Transport::Disabled,
145 from: None,
146 reply_to: None,
147 allow_log_in_production: false,
148 allow_in_process_deliver_later_in_production: false,
149 file_dir: default_file_dir(),
150 preview: false,
151 smtp: SmtpConfig::default(),
152 }
153 }
154}
155
156impl MailConfig {
157 pub fn validate(&self, profile: Option<&str>) -> Result<(), crate::config::ConfigError> {
164 if matches!(profile, Some("prod" | "production"))
165 && self.transport == Transport::Log
166 && !self.allow_log_in_production
167 {
168 return Err(crate::config::ConfigError::Validation(
169 "mail.transport = \"log\" is disabled in prod; set mail.allow_log_in_production = true to acknowledge this explicitly".to_owned(),
170 ));
171 }
172
173 if self.transport == Transport::Smtp
174 && self.smtp.host.as_deref().map_or("", str::trim).is_empty()
175 {
176 return Err(crate::config::ConfigError::Validation(
177 "mail.smtp.host is required when mail.transport = \"smtp\"".to_owned(),
178 ));
179 }
180
181 if self.preview && !matches!(profile, Some("dev" | "development")) {
182 return Err(crate::config::ConfigError::Validation(
183 "mail.preview = true is only allowed in dev; refusing to mount /_autumn/mail outside the dev profile".to_owned(),
184 ));
185 }
186
187 Ok(())
188 }
189
190 pub(crate) fn preview_routes_enabled(&self, profile: Option<&str>) -> bool {
191 matches!(profile, Some("dev" | "development"))
192 && (self.preview || self.transport == Transport::File)
193 }
194}
195
196fn default_file_dir() -> PathBuf {
197 PathBuf::from("target/mail")
198}
199
200pub trait IntoMailBody {
202 fn into_mail_body(self) -> String;
204}
205
206impl IntoMailBody for String {
207 fn into_mail_body(self) -> String {
208 self
209 }
210}
211
212impl IntoMailBody for &str {
213 fn into_mail_body(self) -> String {
214 self.to_owned()
215 }
216}
217
218impl IntoMailBody for maud::Markup {
219 fn into_mail_body(self) -> String {
220 self.into_string()
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct Mail {
227 pub from: Option<String>,
229 pub reply_to: Option<String>,
231 pub to: Vec<String>,
233 pub subject: String,
235 pub html: Option<String>,
237 pub text: Option<String>,
239}
240
241pub const MAIL_PREVIEW_PATH: &str = "/_autumn/mail";
243
244const MAIL_PREVIEW_MESSAGE_PATH: &str = "/_autumn/mail/messages/{message_id}";
245const MAIL_PREVIEW_TEMPLATE_PATH: &str = "/_autumn/mail/previews/{mailer}/{method}";
246
247#[derive(Clone)]
249pub struct MailPreview {
250 mailer: &'static str,
251 method: &'static str,
252 render: fn() -> Mail,
253}
254
255impl std::fmt::Debug for MailPreview {
256 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257 f.debug_struct("MailPreview")
258 .field("mailer", &self.mailer)
259 .field("method", &self.method)
260 .finish_non_exhaustive()
261 }
262}
263
264impl MailPreview {
265 #[must_use]
267 pub const fn new(mailer: &'static str, method: &'static str, render: fn() -> Mail) -> Self {
268 Self {
269 mailer,
270 method,
271 render,
272 }
273 }
274
275 #[must_use]
277 pub const fn mailer(&self) -> &'static str {
278 self.mailer
279 }
280
281 #[must_use]
283 pub const fn method(&self) -> &'static str {
284 self.method
285 }
286
287 pub fn render(&self) -> Result<Mail, MailPreviewError> {
294 std::panic::catch_unwind(|| (self.render)()).map_err(|_| {
295 MailPreviewError::PreviewPanicked {
296 mailer: self.mailer,
297 method: self.method,
298 }
299 })
300 }
301}
302
303#[derive(Debug, Clone, Default)]
305pub struct MailPreviewRegistry {
306 previews: Arc<Vec<MailPreview>>,
307}
308
309impl MailPreviewRegistry {
310 #[must_use]
312 pub fn new(previews: Vec<MailPreview>) -> Self {
313 Self {
314 previews: Arc::new(previews),
315 }
316 }
317
318 #[must_use]
320 pub fn previews(&self) -> &[MailPreview] {
321 &self.previews
322 }
323
324 fn find(&self, mailer: &str, method: &str) -> Option<MailPreview> {
325 self.previews
326 .iter()
327 .find(|preview| preview.mailer == mailer && preview.method == method)
328 .cloned()
329 }
330}
331
332#[derive(Debug, Error)]
334pub enum MailPreviewError {
335 #[error("mail preview file IO failed: {0}")]
337 Io(#[from] std::io::Error),
338 #[error("captured mail message not found: {0}")]
340 NotFound(String),
341 #[error("invalid captured mail message id: {0}")]
343 InvalidMessageId(String),
344 #[error("mail preview {mailer}::{method} panicked while rendering")]
346 PreviewPanicked {
347 mailer: &'static str,
349 method: &'static str,
351 },
352}
353
354impl Mail {
355 #[must_use]
357 pub fn builder() -> MailBuilder {
358 MailBuilder::default()
359 }
360
361 fn with_defaults(mut self, defaults: &MailerDefaults) -> Self {
362 if self.from.is_none() {
363 self.from.clone_from(&defaults.from);
364 }
365 if self.reply_to.is_none() {
366 self.reply_to.clone_from(&defaults.reply_to);
367 }
368 self
369 }
370}
371
372#[derive(Debug, Clone, Default)]
374pub struct MailBuilder {
375 from: Option<String>,
376 reply_to: Option<String>,
377 to: Vec<String>,
378 subject: Option<String>,
379 html: Option<String>,
380 text: Option<String>,
381}
382
383impl MailBuilder {
384 #[must_use]
386 pub fn from(mut self, from: impl Into<String>) -> Self {
387 self.from = Some(from.into());
388 self
389 }
390
391 #[must_use]
393 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
394 self.reply_to = Some(reply_to.into());
395 self
396 }
397
398 #[must_use]
400 pub fn to(mut self, to: impl Into<String>) -> Self {
401 self.to.push(to.into());
402 self
403 }
404
405 #[must_use]
407 pub fn subject(mut self, subject: impl Into<String>) -> Self {
408 self.subject = Some(subject.into());
409 self
410 }
411
412 #[must_use]
414 pub fn html(mut self, html: impl IntoMailBody) -> Self {
415 self.html = Some(html.into_mail_body());
416 self
417 }
418
419 #[must_use]
421 pub fn text(mut self, text: impl IntoMailBody) -> Self {
422 self.text = Some(text.into_mail_body());
423 self
424 }
425
426 pub fn build(self) -> Result<Mail, MailError> {
432 if self.to.is_empty() {
433 return Err(MailError::InvalidMessage(
434 "mail must have at least one recipient".to_owned(),
435 ));
436 }
437 let subject = self
438 .subject
439 .filter(|s| !s.trim().is_empty())
440 .ok_or_else(|| MailError::InvalidMessage("mail subject is required".to_owned()))?;
441 if self.html.is_none() && self.text.is_none() {
442 return Err(MailError::InvalidMessage(
443 "mail must include html or text body".to_owned(),
444 ));
445 }
446 Ok(Mail {
447 from: self.from,
448 reply_to: self.reply_to,
449 to: self.to,
450 subject,
451 html: self.html,
452 text: self.text,
453 })
454 }
455}
456
457#[derive(Debug, Error)]
459pub enum MailError {
460 #[error("invalid mail message: {0}")]
462 InvalidMessage(String),
463 #[error("mail runtime unavailable: {0}")]
465 RuntimeUnavailable(String),
466 #[error("invalid mail address {address:?}: {source}")]
468 InvalidAddress {
469 address: String,
471 source: lettre::address::AddressError,
473 },
474 #[error("failed to build mail message: {0}")]
476 Build(#[from] lettre::error::Error),
477 #[error("smtp send failed: {0}")]
479 Smtp(#[from] lettre::transport::smtp::Error),
480 #[error("file mail transport failed: {0}")]
482 Io(#[from] std::io::Error),
483}
484
485pub trait MailTransport: Send + Sync {
487 fn send<'a>(
489 &'a self,
490 mail: Mail,
491 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
492
493 fn is_disabled(&self) -> bool {
503 false
504 }
505}
506
507pub trait MailDeliveryQueue: Send + Sync {
517 fn enqueue<'a>(
519 &'a self,
520 mail: Mail,
521 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
522}
523
524#[derive(Clone)]
530pub struct MailDeliveryQueueHandle(Arc<dyn MailDeliveryQueue>);
531
532impl MailDeliveryQueueHandle {
533 #[must_use]
535 pub fn new(queue: impl MailDeliveryQueue + 'static) -> Self {
536 Self(Arc::new(queue))
537 }
538
539 #[must_use]
541 pub fn from_arc(queue: Arc<dyn MailDeliveryQueue>) -> Self {
542 Self(queue)
543 }
544
545 #[must_use]
547 pub fn inner(&self) -> &Arc<dyn MailDeliveryQueue> {
548 &self.0
549 }
550}
551
552impl std::fmt::Debug for MailDeliveryQueueHandle {
553 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
554 f.debug_struct("MailDeliveryQueueHandle").finish()
555 }
556}
557
558#[derive(Debug, Clone, Default)]
559struct MailerDefaults {
560 from: Option<String>,
561 reply_to: Option<String>,
562}
563
564#[derive(Clone)]
566pub struct Mailer {
567 defaults: Arc<MailerDefaults>,
568 transport: Arc<dyn MailTransport>,
569 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
570}
571
572impl Mailer {
573 #[must_use]
575 pub fn builder() -> MailerBuilder {
576 MailerBuilder::default()
577 }
578
579 pub fn from_config(config: &MailConfig) -> Result<Self, MailError> {
585 Self::from_config_inner(config, None)
586 }
587
588 pub(crate) fn from_config_inner(
589 config: &MailConfig,
590 resilience: Option<Arc<crate::config::ResilienceConfig>>,
591 ) -> Result<Self, MailError> {
592 let mut builder = Self::builder()
593 .transport(config.transport)
594 .resilience_config(resilience);
595 if let Some(from) = &config.from {
596 builder = builder.from(from.clone());
597 }
598 if let Some(reply_to) = &config.reply_to {
599 builder = builder.reply_to(reply_to.clone());
600 }
601 if config.transport == Transport::File {
602 builder = builder.file_dir(config.file_dir.clone());
603 }
604 if config.transport == Transport::Smtp {
605 builder = builder.smtp(config.smtp.clone());
606 }
607 builder.build()
608 }
609
610 #[must_use]
612 pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
613 Self {
614 defaults: Arc::new(MailerDefaults::default()),
615 transport: Arc::new(transport),
616 delivery_queue: None,
617 }
618 }
619
620 #[must_use]
622 pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
623 self.delivery_queue = Some(Arc::new(queue));
624 self
625 }
626
627 #[must_use]
629 pub fn has_durable_delivery_queue(&self) -> bool {
630 self.delivery_queue.is_some()
631 }
632
633 #[must_use]
639 pub fn is_disabled(&self) -> bool {
640 self.transport.is_disabled()
641 }
642
643 pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
649 self.transport
650 .send(mail.with_defaults(&self.defaults))
651 .await
652 }
653
654 pub fn deliver_later(&self, mail: Mail) {
671 if let Err(error) = self.try_deliver_later(mail) {
672 tracing::error!(error = %error, "background mail delivery was not scheduled");
673 }
674 }
675
676 pub fn deliver_later_eager(&self, mail: Mail) {
684 if let Err(error) = self.try_deliver_later_eager(mail) {
685 tracing::error!(error = %error, "background mail delivery was not scheduled");
686 }
687 }
688
689 pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
700 if self.transport.is_disabled() {
701 return Ok(());
702 }
703 let mail = mail.with_defaults(&self.defaults);
704
705 #[cfg(feature = "db")]
708 {
709 let mailer = self.clone();
710 let deferred = mail.clone();
711 let mut f_opt: Option<(Self, Mail)> = Some((mailer, deferred));
712 let deliver_span = tracing::Span::current();
716
717 crate::db::AFTER_COMMIT_REGISTRY
718 .try_with(|registry| {
719 let (m, m_mail) = f_opt.take().expect("once");
720 let span = deliver_span.clone();
721 let boxed: crate::db::CommitCallback = Box::new(move || {
722 Box::pin(tracing::Instrument::instrument(
723 async move {
724 if let Some(queue) = m.delivery_queue.clone() {
725 queue.enqueue(m_mail).await.map_err(|e| {
726 crate::AutumnError::internal_server_error_msg(e.to_string())
727 })
728 } else {
729 m.spawn_mail_delivery(m_mail).map_err(|e| {
730 crate::AutumnError::internal_server_error_msg(e.to_string())
731 })
732 }
733 },
734 span,
735 ))
736 });
737 registry.lock().expect("registry lock").push(boxed);
738 })
739 .ok();
740
741 if f_opt.is_none() {
742 return Ok(());
744 }
745 }
746
747 self.spawn_mail_delivery(mail)
749 }
750
751 pub fn try_deliver_later_eager(&self, mail: Mail) -> Result<(), MailError> {
757 if self.transport.is_disabled() {
758 return Ok(());
759 }
760 let mail = mail.with_defaults(&self.defaults);
761 self.spawn_mail_delivery(mail)
762 }
763
764 fn spawn_mail_delivery(&self, mail: Mail) -> Result<(), MailError> {
765 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
769 MailError::RuntimeUnavailable(
770 "deliver_later requires an active Tokio runtime".to_owned(),
771 )
772 })?;
773 let parent_span = tracing::Span::current();
774 if let Some(queue) = self.delivery_queue.clone() {
775 handle.spawn(tracing::Instrument::instrument(
776 async move {
777 if let Err(error) = queue.enqueue(mail).await {
778 tracing::error!(error = %error, "durable mail enqueue failed");
779 }
780 },
781 parent_span,
782 ));
783 } else {
784 let mailer = self.clone();
785 handle.spawn(tracing::Instrument::instrument(
786 async move {
787 if let Err(error) = mailer.send(mail).await {
788 tracing::error!(error = %error, "background mail delivery failed");
789 }
790 },
791 parent_span,
792 ));
793 }
794 Ok(())
795 }
796}
797
798impl FromRequestParts<AppState> for Mailer {
799 type Rejection = AutumnError;
800
801 async fn from_request_parts(
802 _parts: &mut http::request::Parts,
803 state: &AppState,
804 ) -> Result<Self, Self::Rejection> {
805 state
806 .extension::<Self>()
807 .as_deref()
808 .cloned()
809 .ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
810 }
811}
812
813#[derive(Clone)]
815pub struct MailerBuilder {
816 transport: Transport,
817 from: Option<String>,
818 reply_to: Option<String>,
819 file_dir: PathBuf,
820 smtp: Option<SmtpConfig>,
821 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
822 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
823}
824
825impl Default for MailerBuilder {
826 fn default() -> Self {
827 Self {
828 transport: Transport::Log,
829 from: None,
830 reply_to: None,
831 file_dir: default_file_dir(),
832 smtp: None,
833 delivery_queue: None,
834 resilience_config: None,
835 }
836 }
837}
838
839impl MailerBuilder {
840 #[must_use]
842 pub const fn transport(mut self, transport: Transport) -> Self {
843 self.transport = transport;
844 self
845 }
846
847 #[must_use]
849 pub fn from(mut self, from: impl Into<String>) -> Self {
850 self.from = Some(from.into());
851 self
852 }
853
854 #[must_use]
856 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
857 self.reply_to = Some(reply_to.into());
858 self
859 }
860
861 #[must_use]
863 pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
864 self.file_dir = dir.as_ref().to_path_buf();
865 self
866 }
867
868 #[must_use]
870 pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
871 self.smtp = Some(smtp);
872 self
873 }
874
875 #[must_use]
878 pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
879 self.delivery_queue = Some(Arc::new(queue));
880 self
881 }
882
883 #[must_use]
885 pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
886 self.delivery_queue = Some(queue);
887 self
888 }
889
890 #[must_use]
891 pub fn resilience_config(mut self, rc: Option<Arc<crate::config::ResilienceConfig>>) -> Self {
892 self.resilience_config = rc;
893 self
894 }
895
896 pub fn build(self) -> Result<Mailer, MailError> {
902 if let Some(from) = &self.from {
903 parse_mailbox(from)?;
904 }
905 if let Some(reply_to) = &self.reply_to {
906 parse_mailbox(reply_to)?;
907 }
908
909 let transport: Arc<dyn MailTransport> = match self.transport {
910 Transport::Log => Arc::new(LogTransport),
911 Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
912 Transport::Disabled => Arc::new(DisabledTransport),
913 Transport::Smtp => Arc::new(SmtpTransport::new(
914 self.smtp.unwrap_or_default(),
915 self.resilience_config.clone(),
916 )?),
917 };
918
919 Ok(Mailer {
920 defaults: Arc::new(MailerDefaults {
921 from: self.from,
922 reply_to: self.reply_to,
923 }),
924 transport,
925 delivery_queue: self.delivery_queue,
926 })
927 }
928}
929
930struct DisabledTransport;
931
932impl MailTransport for DisabledTransport {
933 fn send<'a>(
934 &'a self,
935 _mail: Mail,
936 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
937 Box::pin(async { Ok(()) })
938 }
939
940 fn is_disabled(&self) -> bool {
941 true
942 }
943}
944
945struct LogTransport;
946
947impl MailTransport for LogTransport {
948 fn send<'a>(
949 &'a self,
950 mail: Mail,
951 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
952 Box::pin(async move {
953 tracing::info!(
954 from = ?mail.from,
955 reply_to = ?mail.reply_to,
956 to = ?mail.to,
957 subject = %mail.subject,
958 text = ?mail.text,
959 html = ?mail.html,
960 "mail captured by log transport"
961 );
962 Ok(())
963 })
964 }
965}
966
967struct FileTransport {
968 dir: PathBuf,
969}
970
971static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
972
973impl MailTransport for FileTransport {
974 fn send<'a>(
975 &'a self,
976 mail: Mail,
977 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
978 Box::pin(async move {
979 tokio::fs::create_dir_all(&self.dir).await?;
980 let filename = file_transport_filename(&mail);
981 let path = self.dir.join(filename);
982 let mut file = tokio::fs::OpenOptions::new()
983 .write(true)
984 .create_new(true)
985 .open(path)
986 .await?;
987 let eml = render_eml(&mail);
988 tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
989 tokio::io::AsyncWriteExt::flush(&mut file).await?;
990 file.sync_all().await?;
991 Ok(())
992 })
993 }
994}
995
996struct SmtpTransport {
997 inner: AsyncSmtpTransport<Tokio1Executor>,
998 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
999}
1000
1001impl SmtpTransport {
1002 fn new(
1003 config: SmtpConfig,
1004 resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
1005 ) -> Result<Self, MailError> {
1006 let host = config
1007 .host
1008 .filter(|host| !host.trim().is_empty())
1009 .ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
1010 let mut builder = match config.tls {
1011 TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
1012 TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
1013 TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
1014 };
1015 if let Some(port) = config.port {
1016 builder = builder.port(port);
1017 }
1018 if let Some(username) = config.username {
1019 let password_env = config.password_env.ok_or_else(|| {
1020 MailError::InvalidMessage(
1021 "mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
1022 )
1023 })?;
1024 let password = std::env::var(&password_env).map_err(|error| {
1025 MailError::InvalidMessage(format!(
1026 "mail.smtp.password_env={password_env:?} could not be resolved: {error}"
1027 ))
1028 })?;
1029 builder = builder.credentials(Credentials::new(username, password));
1030 }
1031 Ok(Self {
1032 inner: builder.build(),
1033 resilience_config,
1034 })
1035 }
1036}
1037
1038impl MailTransport for SmtpTransport {
1039 fn send<'a>(
1040 &'a self,
1041 mail: Mail,
1042 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1043 Box::pin(async move {
1044 let breaker = self.resilience_config.as_ref().map_or_else(
1045 || {
1046 crate::circuit_breaker::global_registry().get_or_create(
1047 "smtp_mailer",
1048 crate::circuit_breaker::CircuitBreakerPolicy::default(),
1049 )
1050 },
1051 |rc| {
1052 let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(
1053 rc,
1054 "smtp_mailer",
1055 );
1056 crate::circuit_breaker::global_registry()
1057 .get_or_create_with_config("smtp_mailer", policy)
1058 },
1059 );
1060
1061 if breaker.before_call().is_err() {
1062 return Err(MailError::RuntimeUnavailable(
1063 "smtp mailer circuit breaker is open".to_owned(),
1064 ));
1065 }
1066 let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
1067
1068 let message = lettre_message(&mail)?;
1069 let res = self.inner.send(message).await;
1070 if res.is_ok() {
1071 guard.success();
1072 } else {
1073 guard.failure();
1074 }
1075
1076 res.map(|_| ()).map_err(Into::into)
1077 })
1078 }
1079}
1080
1081fn sanitize_filename(value: &str) -> String {
1082 value
1083 .chars()
1084 .map(|ch| {
1085 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
1086 ch
1087 } else {
1088 '_'
1089 }
1090 })
1091 .collect()
1092}
1093
1094fn file_transport_filename(mail: &Mail) -> String {
1095 let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1096 format!(
1097 "{}-{}-{:016x}-{}.eml",
1098 chrono::Utc::now().format("%Y%m%d%H%M%S%6f"),
1099 std::process::id(),
1100 sequence,
1101 sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
1102 )
1103}
1104
1105fn render_eml(mail: &Mail) -> String {
1106 let mut out = String::new();
1107 if let Some(from) = &mail.from {
1108 out.push_str("From: ");
1109 out.push_str(from);
1110 out.push('\n');
1111 }
1112 for to in &mail.to {
1113 out.push_str("To: ");
1114 out.push_str(to);
1115 out.push('\n');
1116 }
1117 if let Some(reply_to) = &mail.reply_to {
1118 out.push_str("Reply-To: ");
1119 out.push_str(reply_to);
1120 out.push('\n');
1121 }
1122 out.push_str("Date: ");
1123 out.push_str(&chrono::Utc::now().to_rfc2822());
1124 out.push('\n');
1125 out.push_str("Message-Id: <");
1126 out.push_str(&uuid::Uuid::new_v4().to_string());
1127 out.push_str("@autumn.local>\n");
1128 out.push_str("Subject: ");
1129 out.push_str(&mail.subject);
1130 out.push_str("\nMIME-Version: 1.0\n");
1131 if mail.html.is_some() && mail.text.is_some() {
1132 out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
1133 if let Some(text) = &mail.text {
1134 out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
1135 out.push_str(text);
1136 out.push('\n');
1137 }
1138 if let Some(html) = &mail.html {
1139 out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
1140 out.push_str(html);
1141 out.push('\n');
1142 }
1143 out.push_str("--autumn-mail--\n");
1144 } else if let Some(html) = &mail.html {
1145 out.push_str("Content-Type: text/html; charset=utf-8\n\n");
1146 out.push_str(html);
1147 out.push('\n');
1148 } else if let Some(text) = &mail.text {
1149 out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
1150 out.push_str(text);
1151 out.push('\n');
1152 }
1153 out
1154}
1155
1156#[derive(Debug, Clone)]
1157struct ParsedMail {
1158 headers: Vec<(String, String)>,
1159 to: Vec<String>,
1160 subject: String,
1161 date: Option<String>,
1162 html: Option<String>,
1163 text: Option<String>,
1164 raw: String,
1165}
1166
1167impl ParsedMail {
1168 fn header_value(&self, name: &str) -> Option<&str> {
1169 self.headers
1170 .iter()
1171 .find(|(header, _)| header.eq_ignore_ascii_case(name))
1172 .map(|(_, value)| value.as_str())
1173 }
1174}
1175
1176#[derive(Debug, Clone)]
1177struct CapturedMailSummary {
1178 id: String,
1179 to: Vec<String>,
1180 subject: String,
1181 timestamp: String,
1182 modified: SystemTime,
1183}
1184
1185pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
1186where
1187 S: Clone + Send + Sync + 'static,
1188 AppState: axum::extract::FromRef<S>,
1189{
1190 let file_dir = Arc::new(file_dir);
1191 axum::Router::new()
1192 .route(
1193 MAIL_PREVIEW_PATH,
1194 axum::routing::get({
1195 let file_dir = Arc::clone(&file_dir);
1196 move |axum::extract::State(state): axum::extract::State<AppState>| {
1197 let file_dir = Arc::clone(&file_dir);
1198 async move { list_mail_preview(file_dir, state).await }
1199 }
1200 }),
1201 )
1202 .route(
1203 MAIL_PREVIEW_MESSAGE_PATH,
1204 axum::routing::get({
1205 let file_dir = Arc::clone(&file_dir);
1206 move |axum::extract::Path(message_id): axum::extract::Path<String>| {
1207 let file_dir = Arc::clone(&file_dir);
1208 async move { show_captured_mail(file_dir, message_id).await }
1209 }
1210 }),
1211 )
1212 .route(
1213 MAIL_PREVIEW_TEMPLATE_PATH,
1214 axum::routing::get(
1215 |axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
1216 axum::extract::State(state): axum::extract::State<AppState>| async move {
1217 show_template_preview(&state, &mailer, &method)
1218 },
1219 ),
1220 )
1221}
1222
1223async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
1224 match captured_messages(&file_dir).await {
1225 Ok(messages) => {
1226 let previews = state
1227 .extension::<MailPreviewRegistry>()
1228 .map(|registry| registry.previews().to_vec())
1229 .unwrap_or_default();
1230 html_response(render_mail_index(&messages, &previews, &file_dir))
1231 }
1232 Err(error) => preview_error_response(&error),
1233 }
1234}
1235
1236async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
1237 match read_captured_message(&file_dir, &message_id).await {
1238 Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
1239 Err(error) => preview_error_response(&error),
1240 }
1241}
1242
1243fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
1244 let preview = state
1245 .extension::<MailPreviewRegistry>()
1246 .and_then(|registry| registry.find(mailer, method));
1247 let Some(preview) = preview else {
1248 return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
1249 };
1250
1251 match preview.render() {
1252 Ok(mail) => {
1253 let raw = render_eml(&mail);
1254 let parsed = parse_eml(&raw);
1255 html_response(render_mail_detail(&parsed, "Template preview"))
1256 }
1257 Err(error) => preview_error_response(&error),
1258 }
1259}
1260
1261async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
1262 let mut entries = match tokio::fs::read_dir(dir).await {
1263 Ok(entries) => entries,
1264 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1265 Err(error) => return Err(error.into()),
1266 };
1267
1268 let mut messages = Vec::new();
1269 while let Some(entry) = entries.next_entry().await? {
1270 let path = entry.path();
1271 if !path
1272 .extension()
1273 .and_then(|ext| ext.to_str())
1274 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1275 {
1276 continue;
1277 }
1278 let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
1279 continue;
1280 };
1281 let metadata = entry.metadata().await?;
1282 let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
1283 let raw = tokio::fs::read_to_string(&path).await?;
1284 let parsed = parse_eml(&raw);
1285 messages.push(CapturedMailSummary {
1286 id: id.to_owned(),
1287 to: parsed.to,
1288 subject: parsed.subject,
1289 timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
1290 modified,
1291 });
1292 }
1293
1294 messages.sort_by(|left, right| {
1295 right
1296 .modified
1297 .cmp(&left.modified)
1298 .then_with(|| right.id.cmp(&left.id))
1299 });
1300 Ok(messages)
1301}
1302
1303async fn read_captured_message(
1304 dir: &Path,
1305 message_id: &str,
1306) -> Result<ParsedMail, MailPreviewError> {
1307 if !valid_message_id(message_id) {
1308 return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
1309 }
1310 let path = dir.join(message_id);
1311 let raw = match tokio::fs::read_to_string(&path).await {
1312 Ok(raw) => raw,
1313 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1314 return Err(MailPreviewError::NotFound(message_id.to_owned()));
1315 }
1316 Err(error) => return Err(error.into()),
1317 };
1318 Ok(parse_eml(&raw))
1319}
1320
1321fn valid_message_id(message_id: &str) -> bool {
1322 !message_id.is_empty()
1323 && Path::new(message_id)
1324 .extension()
1325 .and_then(|ext| ext.to_str())
1326 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1327 && !message_id.contains('/')
1328 && !message_id.contains('\\')
1329 && !message_id.contains("..")
1330}
1331
1332fn parse_eml(raw: &str) -> ParsedMail {
1333 let normalized = raw.replace("\r\n", "\n");
1334 let (headers, body) = split_headers_body(&normalized);
1335 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
1336 let (html, text) = parse_mail_body(&content_type, body);
1337 let to = header_values(&headers, "To");
1338 let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
1339 let date = header_value(&headers, "Date");
1340
1341 ParsedMail {
1342 headers,
1343 to,
1344 subject,
1345 date,
1346 html,
1347 text,
1348 raw: raw.to_owned(),
1349 }
1350}
1351
1352fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
1353 let Some((header_block, body)) = raw.split_once("\n\n") else {
1354 return (parse_header_block(raw), "");
1355 };
1356 (parse_header_block(header_block), body)
1357}
1358
1359fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
1360 let mut headers = Vec::new();
1361 let mut current: Option<(String, String)> = None;
1362
1363 for line in header_block.lines() {
1364 if line.starts_with(' ') || line.starts_with('\t') {
1365 if let Some((_, value)) = current.as_mut() {
1366 value.push(' ');
1367 value.push_str(line.trim());
1368 }
1369 continue;
1370 }
1371 if let Some(header) = current.take() {
1372 headers.push(header);
1373 }
1374 if let Some((name, value)) = line.split_once(':') {
1375 current = Some((name.trim().to_owned(), value.trim().to_owned()));
1376 }
1377 }
1378 if let Some(header) = current {
1379 headers.push(header);
1380 }
1381 headers
1382}
1383
1384fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
1385 headers
1386 .iter()
1387 .find(|(header, _)| header.eq_ignore_ascii_case(name))
1388 .map(|(_, value)| value.clone())
1389}
1390
1391fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
1392 headers
1393 .iter()
1394 .filter(|(header, _)| header.eq_ignore_ascii_case(name))
1395 .map(|(_, value)| value.clone())
1396 .collect()
1397}
1398
1399fn parse_mail_body(content_type: &str, body: &str) -> (Option<String>, Option<String>) {
1400 if content_type
1401 .to_ascii_lowercase()
1402 .contains("multipart/alternative")
1403 && let Some(boundary) = content_type_boundary(content_type)
1404 {
1405 return parse_multipart_alternative(body, &boundary);
1406 }
1407
1408 if content_type.to_ascii_lowercase().contains("text/html") {
1409 (Some(trim_body(body)), None)
1410 } else {
1411 (None, Some(trim_body(body)))
1412 }
1413}
1414
1415fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
1416 let marker = format!("--{boundary}");
1417 let mut html = None;
1418 let mut text = None;
1419
1420 for segment in body.split(&marker).skip(1) {
1421 let segment = segment.trim_start_matches(['\n', '\r']);
1422 if segment.starts_with("--") {
1423 break;
1424 }
1425 let (headers, part_body) = split_headers_body(segment);
1426 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
1427 if content_type.to_ascii_lowercase().contains("text/html") {
1428 html = Some(trim_body(part_body));
1429 } else if content_type.to_ascii_lowercase().contains("text/plain") {
1430 text = Some(trim_body(part_body));
1431 }
1432 }
1433
1434 (html, text)
1435}
1436
1437fn content_type_boundary(content_type: &str) -> Option<String> {
1438 content_type.split(';').find_map(|part| {
1439 let part = part.trim();
1440 let (name, value) = part.split_once('=')?;
1441 if !name.trim().eq_ignore_ascii_case("boundary") {
1442 return None;
1443 }
1444 Some(value.trim().trim_matches('"').to_owned())
1445 })
1446}
1447
1448fn trim_body(body: &str) -> String {
1449 body.trim_matches(['\r', '\n']).to_owned()
1450}
1451
1452fn format_system_time(time: SystemTime) -> String {
1453 let datetime: chrono::DateTime<chrono::Utc> = time.into();
1454 datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1455}
1456
1457fn render_mail_index(
1458 messages: &[CapturedMailSummary],
1459 previews: &[MailPreview],
1460 file_dir: &Path,
1461) -> String {
1462 let mut body = String::new();
1463 body.push_str("<h1>Autumn Mail</h1>");
1464 body.push_str("<section><h2>Captured messages</h2>");
1465 if messages.is_empty() {
1466 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>");
1467 body.push_str(&escape_html(&file_dir.display().to_string()));
1468 body.push_str("</code>.</p>");
1469 } else {
1470 body.push_str(
1471 "<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
1472 );
1473 for message in messages {
1474 body.push_str("<tr><td>");
1475 body.push_str(&escape_html(&message.timestamp));
1476 body.push_str("</td><td>");
1477 body.push_str(&escape_html(&message.to.join(", ")));
1478 body.push_str("</td><td><a href=\"");
1479 body.push_str(MAIL_PREVIEW_PATH);
1480 body.push_str("/messages/");
1481 body.push_str(&escape_html(&message.id));
1482 body.push_str("\">");
1483 body.push_str(&escape_html(&message.subject));
1484 body.push_str("</a></td></tr>");
1485 }
1486 body.push_str("</tbody></table>");
1487 }
1488 body.push_str("</section><section><h2>Template previews</h2>");
1489 if previews.is_empty() {
1490 body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
1491 } else {
1492 body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
1493 for preview in previews {
1494 body.push_str("<tr><td>");
1495 body.push_str(&escape_html(preview.mailer()));
1496 body.push_str("</td><td><a href=\"");
1497 body.push_str(MAIL_PREVIEW_PATH);
1498 body.push_str("/previews/");
1499 body.push_str(&escape_html(preview.mailer()));
1500 body.push('/');
1501 body.push_str(&escape_html(preview.method()));
1502 body.push_str("\">");
1503 body.push_str(&escape_html(preview.method()));
1504 body.push_str("</a></td></tr>");
1505 }
1506 body.push_str("</tbody></table>");
1507 }
1508 body.push_str("</section>");
1509 render_mail_preview_layout("Autumn Mail", &body)
1510}
1511
1512fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
1513 let mut body = String::new();
1514 body.push_str("<p><a href=\"");
1515 body.push_str(MAIL_PREVIEW_PATH);
1516 body.push_str("\">Back to mail</a></p><h1>");
1517 body.push_str(&escape_html(&parsed.subject));
1518 body.push_str("</h1><p class=\"muted\">");
1519 body.push_str(&escape_html(label));
1520 body.push_str("</p>");
1521
1522 if let Some(html) = &parsed.html {
1523 body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
1524 body.push_str(&escape_html(html));
1525 body.push_str("\"></iframe>");
1526 } else {
1527 body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
1528 }
1529
1530 body.push_str("<details><summary>Plain text</summary><pre>");
1531 body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
1532 body.push_str("</pre></details>");
1533
1534 body.push_str("<details><summary>Headers</summary><dl>");
1535 for header in ["From", "To", "Reply-To", "Subject", "Date", "Message-Id"] {
1536 if let Some(value) = parsed.header_value(header) {
1537 body.push_str("<dt>");
1538 body.push_str(header);
1539 body.push_str("</dt><dd>");
1540 body.push_str(&escape_html(value));
1541 body.push_str("</dd>");
1542 }
1543 }
1544 body.push_str("</dl></details>");
1545
1546 body.push_str("<details><summary>Raw .eml</summary><pre>");
1547 body.push_str(&escape_html(&parsed.raw));
1548 body.push_str("</pre></details>");
1549
1550 render_mail_preview_layout(&parsed.subject, &body)
1551}
1552
1553fn render_mail_preview_layout(title: &str, body: &str) -> String {
1554 format!(
1555 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
1556 escape_html(title),
1557 MAIL_PREVIEW_CSS,
1558 body
1559 )
1560}
1561
1562const MAIL_PREVIEW_CSS: &str = r#"
1563body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
1564h1{margin:0 0 16px;font-size:28px}
1565h2{margin:28px 0 12px;font-size:18px}
1566table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
1567th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
1568th{background:#edf2f7;color:#394b59;font-weight:650}
1569a{color:#0b63ce;text-decoration:none}
1570a:hover{text-decoration:underline}
1571.empty,.muted{color:#52616f}
1572code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
1573pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
1574iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
1575details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
1576summary{cursor:pointer;font-weight:650}
1577dt{font-weight:650;margin-top:8px}
1578dd{margin:2px 0 8px}
1579"#;
1580
1581fn html_response(html: String) -> Response {
1582 Html(html).into_response()
1583}
1584
1585fn preview_error_response(error: &MailPreviewError) -> Response {
1586 let status = match error {
1587 MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
1588 http::StatusCode::NOT_FOUND
1589 }
1590 MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
1591 http::StatusCode::INTERNAL_SERVER_ERROR
1592 }
1593 };
1594 (
1595 status,
1596 Html(render_mail_preview_layout(
1597 "Mail preview error",
1598 &format!(
1599 "<h1>Mail preview error</h1><p>{}</p>",
1600 escape_html(&error.to_string())
1601 ),
1602 )),
1603 )
1604 .into_response()
1605}
1606
1607fn escape_html(value: &str) -> String {
1608 let mut escaped = String::with_capacity(value.len());
1609 for ch in value.chars() {
1610 match ch {
1611 '&' => escaped.push_str("&"),
1612 '<' => escaped.push_str("<"),
1613 '>' => escaped.push_str(">"),
1614 '"' => escaped.push_str("""),
1615 '\'' => escaped.push_str("'"),
1616 _ => escaped.push(ch),
1617 }
1618 }
1619 escaped
1620}
1621
1622fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
1623 address.parse().map_err(|source| MailError::InvalidAddress {
1624 address: address.to_owned(),
1625 source,
1626 })
1627}
1628
1629fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
1630 let from = mail
1631 .from
1632 .as_deref()
1633 .ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
1634 let mut builder = Message::builder().from(parse_mailbox(from)?);
1635 for to in &mail.to {
1636 builder = builder.to(parse_mailbox(to)?);
1637 }
1638 if let Some(reply_to) = &mail.reply_to {
1639 builder = builder.reply_to(parse_mailbox(reply_to)?);
1640 }
1641 builder = builder.subject(mail.subject.clone());
1642
1643 match (&mail.text, &mail.html) {
1644 (Some(text), Some(html)) => Ok(builder.multipart(
1645 MultiPart::alternative()
1646 .singlepart(SinglePart::plain(text.clone()))
1647 .singlepart(SinglePart::html(html.clone())),
1648 )?),
1649 (Some(text), None) => Ok(builder.singlepart(SinglePart::plain(text.clone()))?),
1650 (None, Some(html)) => Ok(builder.singlepart(SinglePart::html(html.clone()))?),
1651 (None, None) => Err(MailError::InvalidMessage(
1652 "mail must include html or text body".to_owned(),
1653 )),
1654 }
1655}
1656
1657struct InterceptedMailTransport {
1658 inner: Arc<dyn MailTransport>,
1659 interceptor: Arc<dyn crate::interceptor::MailInterceptor>,
1660}
1661
1662impl MailTransport for InterceptedMailTransport {
1663 fn send<'a>(
1664 &'a self,
1665 mail: Mail,
1666 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1667 Box::pin(async move {
1668 let inner = Arc::clone(&self.inner);
1669 let mail_for_next = mail.clone();
1670 let next = Box::pin(async move { inner.send(mail_for_next).await });
1671 self.interceptor.intercept(&mail, next).await
1672 })
1673 }
1674
1675 fn is_disabled(&self) -> bool {
1676 self.inner.is_disabled()
1677 }
1678}
1679
1680pub(crate) fn install_mailer(
1695 state: &AppState,
1696 config: &MailConfig,
1697 enforce_durable_guard: bool,
1698) -> AutumnResult<()> {
1699 let resilience = state
1700 .extension::<crate::config::AutumnConfig>()
1701 .map(|c| Arc::new(c.resilience.clone()));
1702 let mut mailer =
1703 Mailer::from_config_inner(config, resilience).map_err(AutumnError::service_unavailable)?;
1704
1705 if let Some(interceptor) = state.extension::<Arc<dyn crate::interceptor::MailInterceptor>>() {
1706 mailer.transport = Arc::new(InterceptedMailTransport {
1707 inner: Arc::clone(&mailer.transport),
1708 interceptor: (*interceptor).clone(),
1709 });
1710 }
1711
1712 let in_production = matches!(state.profile(), "prod" | "production");
1713 let transport_sends_mail = config.transport != Transport::Disabled;
1714
1715 if transport_sends_mail {
1719 let queue_handle = state.extension::<MailDeliveryQueueHandle>();
1720 if let Some(handle) = queue_handle.as_ref() {
1721 mailer.delivery_queue = Some(Arc::clone(handle.inner()));
1722 }
1723 }
1724
1725 if enforce_durable_guard && in_production && transport_sends_mail {
1726 let has_durable_queue = mailer.delivery_queue.is_some();
1727 if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
1728 return Err(AutumnError::service_unavailable_msg(
1729 "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",
1730 ));
1731 }
1732 if !has_durable_queue {
1733 tracing::warn!(
1734 "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"
1735 );
1736 }
1737 }
1738
1739 state.insert_extension(mailer);
1740 Ok(())
1741}
1742
1743pub(crate) fn install_mailer_with_factory<F>(
1758 state: &AppState,
1759 config: &MailConfig,
1760 queue_factory: Option<F>,
1761 enforce_durable_guard: bool,
1762) -> AutumnResult<()>
1763where
1764 F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
1765{
1766 let transport_sends_mail = config.transport != Transport::Disabled;
1770 if enforce_durable_guard
1771 && transport_sends_mail
1772 && let Some(factory) = queue_factory
1773 {
1774 let queue = factory(state)?;
1775 state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
1776 }
1777 install_mailer(state, config, enforce_durable_guard)
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782 use super::*;
1783
1784 #[test]
1785 fn mail_builder_rejects_missing_body() {
1786 let err = Mail::builder()
1787 .to("user@example.com")
1788 .subject("Hello")
1789 .build()
1790 .expect_err("body should be required");
1791 assert!(err.to_string().contains("html or text"));
1792 }
1793
1794 #[test]
1795 fn filename_sanitizer_keeps_safe_characters() {
1796 assert_eq!(
1797 sanitize_filename("Ada Lovelace <ada@example.com>"),
1798 "Ada_Lovelace__ada_example.com_"
1799 );
1800 }
1801
1802 #[test]
1803 fn transport_default_is_disabled() {
1804 assert_eq!(Transport::default(), Transport::Disabled);
1805 }
1806
1807 #[test]
1808 fn smtp_config_validation_rejects_whitespace_only_host() {
1809 let config = MailConfig {
1810 transport: Transport::Smtp,
1811 smtp: SmtpConfig {
1812 host: Some(" ".to_owned()),
1813 ..Default::default()
1814 },
1815 ..Default::default()
1816 };
1817
1818 let error = config
1819 .validate(Some("dev"))
1820 .expect_err("whitespace SMTP host should be rejected");
1821
1822 assert!(error.to_string().contains("mail.smtp.host is required"));
1823 }
1824
1825 #[test]
1826 fn transport_env_value_is_trimmed_and_case_insensitive() {
1827 assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
1828 assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
1829 }
1830
1831 #[test]
1832 fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
1833 assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
1834 assert_eq!(
1835 TlsMode::from_env_value(" START_TLS "),
1836 Some(TlsMode::StartTls)
1837 );
1838 assert_eq!(
1839 TlsMode::from_env_value(" disabled "),
1840 Some(TlsMode::Disabled)
1841 );
1842 }
1843
1844 #[test]
1845 fn file_transport_filename_is_unique_for_same_recipient() {
1846 let mail = Mail::builder()
1847 .to("Ada Lovelace <ada@example.com>")
1848 .subject("Hello")
1849 .text("body")
1850 .build()
1851 .expect("mail should build");
1852
1853 let first = file_transport_filename(&mail);
1854 let second = file_transport_filename(&mail);
1855
1856 assert_ne!(first, second);
1857 assert!(
1858 Path::new(&first)
1859 .extension()
1860 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1861 );
1862 assert!(
1863 Path::new(&second)
1864 .extension()
1865 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1866 );
1867 }
1868
1869 #[test]
1870 fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
1871 let missing_key = format!(
1872 "AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
1873 std::process::id(),
1874 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
1875 );
1876 let Err(error) = SmtpTransport::new(
1877 SmtpConfig {
1878 host: Some("smtp.example.com".to_owned()),
1879 port: Some(587),
1880 username: Some("mailer".to_owned()),
1881 password_env: Some(missing_key.clone()),
1882 tls: TlsMode::StartTls,
1883 },
1884 None,
1885 ) else {
1886 panic!("missing password env should fail at startup");
1887 };
1888
1889 assert!(error.to_string().contains(&missing_key));
1890 }
1891
1892 #[test]
1893 fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
1894 let Err(error) = SmtpTransport::new(
1895 SmtpConfig {
1896 host: Some("smtp.example.com".to_owned()),
1897 port: Some(587),
1898 username: Some("mailer".to_owned()),
1899 password_env: None,
1900 tls: TlsMode::StartTls,
1901 },
1902 None,
1903 ) else {
1904 panic!("missing password_env setting should fail at startup");
1905 };
1906
1907 assert!(error.to_string().contains("mail.smtp.password_env"));
1908 }
1909
1910 #[test]
1911 fn mailer_builder_rejects_invalid_default_from_address() {
1912 let Err(error) = Mailer::builder().from("not an email address").build() else {
1913 panic!("invalid default from should fail fast");
1914 };
1915
1916 match error {
1917 MailError::InvalidAddress { address, .. } => {
1918 assert_eq!(address, "not an email address");
1919 }
1920 other => panic!("expected invalid address error, got {other:?}"),
1921 }
1922 }
1923
1924 #[test]
1925 fn mailer_from_config_rejects_invalid_default_reply_to_address() {
1926 let config = MailConfig {
1927 transport: Transport::Smtp,
1928 from: Some("Autumn <noreply@example.com>".to_owned()),
1929 reply_to: Some("definitely not an address".to_owned()),
1930 smtp: SmtpConfig {
1931 host: Some("smtp.example.com".to_owned()),
1932 ..Default::default()
1933 },
1934 ..Default::default()
1935 };
1936
1937 let Err(error) = Mailer::from_config(&config) else {
1938 panic!("invalid configured reply-to should fail at construction");
1939 };
1940
1941 match error {
1942 MailError::InvalidAddress { address, .. } => {
1943 assert_eq!(address, "definitely not an address");
1944 }
1945 other => panic!("expected invalid address error, got {other:?}"),
1946 }
1947 }
1948
1949 #[test]
1950 fn try_deliver_later_returns_error_without_runtime() {
1951 let mailer = Mailer::builder().build().expect("mailer should build");
1952 let mail = Mail::builder()
1953 .to("user@example.com")
1954 .subject("Hello")
1955 .text("hello")
1956 .build()
1957 .expect("mail should build");
1958
1959 let error = mailer
1960 .try_deliver_later(mail)
1961 .expect_err("missing runtime should return an error");
1962
1963 assert!(error.to_string().contains("active Tokio runtime"));
1964 }
1965
1966 #[test]
1967 fn deliver_later_does_not_panic_without_runtime() {
1968 let mailer = Mailer::builder().build().expect("mailer should build");
1969 let mail = Mail::builder()
1970 .to("user@example.com")
1971 .subject("Hello")
1972 .text("hello")
1973 .build()
1974 .expect("mail should build");
1975
1976 mailer.deliver_later(mail);
1977 }
1978
1979 fn sample_smtp_config() -> MailConfig {
1980 MailConfig {
1981 transport: Transport::Smtp,
1982 from: Some("Autumn <noreply@example.com>".to_owned()),
1983 smtp: SmtpConfig {
1984 host: Some("smtp.example.com".to_owned()),
1985 ..Default::default()
1986 },
1987 ..Default::default()
1988 }
1989 }
1990
1991 fn sample_mail() -> Mail {
1992 Mail::builder()
1993 .to("user@example.com")
1994 .subject("Hi")
1995 .text("hello")
1996 .build()
1997 .expect("mail should build")
1998 }
1999
2000 struct NoopQueue;
2001
2002 impl MailDeliveryQueue for NoopQueue {
2003 fn enqueue<'a>(
2004 &'a self,
2005 _mail: Mail,
2006 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2007 Box::pin(async { Ok(()) })
2008 }
2009 }
2010
2011 #[test]
2012 fn install_mailer_rejects_in_process_fallback_in_prod_without_ack() {
2013 let state = crate::AppState::for_test().with_profile("prod");
2014 let config = sample_smtp_config();
2015
2016 let error = install_mailer(&state, &config, true)
2017 .expect_err("prod must reject in-process deliver_later fallback without ack");
2018
2019 let message = error.to_string();
2020 assert!(
2021 message.contains("allow_in_process_deliver_later_in_production"),
2022 "error should explain how to opt in: {message}"
2023 );
2024 }
2025
2026 #[test]
2027 fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
2028 let state = crate::AppState::for_test().with_profile("prod");
2029 let config = MailConfig {
2030 allow_in_process_deliver_later_in_production: true,
2031 ..sample_smtp_config()
2032 };
2033
2034 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
2035 }
2036
2037 #[test]
2038 fn install_mailer_allows_durable_queue_in_prod_without_ack() {
2039 let state = crate::AppState::for_test().with_profile("prod");
2040 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2041 let config = sample_smtp_config();
2042
2043 install_mailer(&state, &config, true)
2044 .expect("a registered durable queue should satisfy the prod guard");
2045 }
2046
2047 #[test]
2048 fn install_mailer_does_not_require_ack_outside_production() {
2049 let state = crate::AppState::for_test().with_profile("dev");
2050 let config = sample_smtp_config();
2051
2052 install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
2053 }
2054
2055 #[test]
2056 fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
2057 let state = crate::AppState::for_test().with_profile("prod");
2058 let config = MailConfig::default();
2059
2060 install_mailer(&state, &config, true)
2061 .expect("disabled transport never sends mail so it should not need an ack");
2062 }
2063
2064 struct CapturingQueue {
2065 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
2066 }
2067
2068 impl MailDeliveryQueue for CapturingQueue {
2069 fn enqueue<'a>(
2070 &'a self,
2071 mail: Mail,
2072 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2073 let tx = self.tx.clone();
2074 Box::pin(async move {
2075 tx.send(mail)
2076 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
2077 Ok(())
2078 })
2079 }
2080 }
2081
2082 #[cfg(feature = "db")]
2083 struct FailingQueue {
2084 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
2085 }
2086
2087 #[cfg(feature = "db")]
2088 impl MailDeliveryQueue for FailingQueue {
2089 fn enqueue<'a>(
2090 &'a self,
2091 mail: Mail,
2092 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2093 let tx = self.tx.clone();
2094 Box::pin(async move {
2095 tx.send(mail)
2096 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
2097 Err(MailError::RuntimeUnavailable("queue offline".to_owned()))
2098 })
2099 }
2100 }
2101
2102 #[cfg(feature = "db")]
2103 async fn drain_after_commit_callbacks_for_test(
2104 registry: &std::sync::Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>>,
2105 ) {
2106 let callbacks: Vec<crate::db::CommitCallback> = {
2107 let mut reg = registry.lock().expect("registry lock");
2108 std::mem::take(&mut *reg)
2109 };
2110
2111 for cb in callbacks {
2112 if let Err(error) = cb().await {
2113 crate::db::record_after_commit_failure();
2114 tracing::error!("test drain: after_commit callback failed: {error}");
2115 }
2116 }
2117 }
2118
2119 #[cfg(feature = "db")]
2120 #[tokio::test]
2121 async fn deferred_deliver_later_queue_failure_increments_after_commit_counter() {
2122 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
2123 let mailer = Mailer::builder()
2124 .delivery_queue(FailingQueue { tx })
2125 .build()
2126 .expect("mailer should build");
2127 let registry = std::sync::Arc::new(std::sync::Mutex::new(
2128 Vec::<crate::db::CommitCallback>::new(),
2129 ));
2130 let before =
2131 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
2132
2133 crate::db::AFTER_COMMIT_REGISTRY
2134 .scope(registry.clone(), async {
2135 mailer
2136 .try_deliver_later(sample_mail())
2137 .expect("registering deferred mail should succeed");
2138 })
2139 .await;
2140
2141 drain_after_commit_callbacks_for_test(®istry).await;
2142
2143 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
2144 .await
2145 .expect("queue should be called within 1s")
2146 .expect("queue should receive the mail");
2147 assert_eq!(received.subject, "Hi");
2148
2149 let after =
2150 crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
2151 assert!(
2152 after > before,
2153 "deferred durable mail handoff failures should count as after_commit failures"
2154 );
2155 }
2156
2157 #[tokio::test]
2158 async fn deliver_later_routes_through_configured_queue() {
2159 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
2160
2161 let mailer = Mailer::builder()
2162 .delivery_queue(CapturingQueue { tx })
2163 .build()
2164 .expect("mailer should build");
2165
2166 mailer
2167 .try_deliver_later(sample_mail())
2168 .expect("scheduling onto the queue should succeed");
2169
2170 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
2171 .await
2172 .expect("queue should receive within 1s")
2173 .expect("queue should receive the mail");
2174
2175 assert_eq!(received.subject, "Hi");
2176 }
2177
2178 #[tokio::test]
2179 async fn deliver_later_without_queue_sends_via_transport_directly() {
2180 use std::sync::Arc;
2183 use std::sync::atomic::{AtomicBool, Ordering};
2184
2185 struct TrackingSend(Arc<AtomicBool>);
2186 impl MailTransport for TrackingSend {
2187 fn send<'a>(
2188 &'a self,
2189 _mail: Mail,
2190 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2191 self.0.store(true, Ordering::SeqCst);
2192 Box::pin(async { Ok(()) })
2193 }
2194 }
2195
2196 let sent = Arc::new(AtomicBool::new(false));
2197 let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
2198
2199 mailer
2200 .try_deliver_later(sample_mail())
2201 .expect("should succeed without queue");
2202
2203 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2204 assert!(
2205 sent.load(Ordering::SeqCst),
2206 "mail should have been sent directly via transport"
2207 );
2208 }
2209
2210 #[cfg(feature = "db")]
2211 #[tokio::test]
2212 async fn deferred_deliver_later_without_queue_sends_after_commit() {
2213 use std::sync::Arc;
2216 use std::sync::atomic::{AtomicBool, Ordering};
2217
2218 struct TrackingSend(Arc<AtomicBool>);
2219 impl MailTransport for TrackingSend {
2220 fn send<'a>(
2221 &'a self,
2222 _mail: Mail,
2223 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2224 self.0.store(true, Ordering::SeqCst);
2225 Box::pin(async { Ok(()) })
2226 }
2227 }
2228
2229 let sent = Arc::new(AtomicBool::new(false));
2230 let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
2231 let registry = std::sync::Arc::new(std::sync::Mutex::new(
2232 Vec::<crate::db::CommitCallback>::new(),
2233 ));
2234
2235 crate::db::AFTER_COMMIT_REGISTRY
2236 .scope(registry.clone(), async {
2237 mailer
2238 .try_deliver_later(sample_mail())
2239 .expect("should succeed");
2240 })
2241 .await;
2242
2243 drain_after_commit_callbacks_for_test(®istry).await;
2244 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2245
2246 assert!(
2247 sent.load(Ordering::SeqCst),
2248 "mail should have been sent after commit via direct transport"
2249 );
2250 }
2251
2252 #[tokio::test]
2253 async fn mailer_with_transport_starts_without_delivery_queue() {
2254 let mailer = Mailer::with_transport(NoopTransport);
2255 assert!(
2256 !mailer.has_durable_delivery_queue(),
2257 "with_transport should default to no durable queue"
2258 );
2259 mailer
2261 .send(sample_mail())
2262 .await
2263 .expect("noop transport should always succeed");
2264 }
2265
2266 struct NoopTransport;
2267 impl MailTransport for NoopTransport {
2268 fn send<'a>(
2269 &'a self,
2270 _mail: Mail,
2271 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2272 Box::pin(async { Ok(()) })
2273 }
2274 }
2275
2276 #[tokio::test]
2277 async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
2278 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
2283 let mailer = Mailer::builder()
2284 .transport(Transport::Disabled)
2285 .delivery_queue(CapturingQueue { tx })
2286 .build()
2287 .expect("mailer should build");
2288
2289 mailer
2290 .try_deliver_later(sample_mail())
2291 .expect("disabled transport should succeed as a no-op");
2292
2293 let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
2295 assert!(
2296 received.is_err(),
2297 "queue must not be invoked when transport is disabled"
2298 );
2299 }
2300
2301 #[tokio::test]
2302 async fn deliver_later_uses_in_process_fallback_when_no_queue() {
2303 let mailer = Mailer::builder().build().expect("mailer should build");
2306
2307 mailer
2308 .try_deliver_later(sample_mail())
2309 .expect("in-process fallback should still schedule");
2310 }
2311
2312 #[test]
2313 fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
2314 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
2315 let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
2316
2317 assert!(Arc::ptr_eq(handle.inner(), &arc));
2318 }
2319
2320 #[test]
2321 fn mail_delivery_queue_handle_debug_does_not_panic() {
2322 let handle = MailDeliveryQueueHandle::new(NoopQueue);
2323 let rendered = format!("{handle:?}");
2324 assert!(rendered.contains("MailDeliveryQueueHandle"));
2325 }
2326
2327 #[test]
2328 fn mailer_has_durable_delivery_queue_reflects_attachment() {
2329 let plain = Mailer::builder().build().expect("mailer should build");
2330 assert!(!plain.has_durable_delivery_queue());
2331
2332 let with_queue = Mailer::builder()
2333 .delivery_queue(NoopQueue)
2334 .build()
2335 .expect("mailer should build");
2336 assert!(with_queue.has_durable_delivery_queue());
2337 }
2338
2339 #[test]
2340 fn mailer_with_delivery_queue_post_build_attaches_queue() {
2341 let mailer = Mailer::builder()
2342 .build()
2343 .expect("mailer should build")
2344 .with_delivery_queue(NoopQueue);
2345
2346 assert!(mailer.has_durable_delivery_queue());
2347 }
2348
2349 #[test]
2350 fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
2351 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
2352 let mailer = Mailer::builder()
2353 .delivery_queue_arc(arc)
2354 .build()
2355 .expect("mailer should build");
2356
2357 assert!(mailer.has_durable_delivery_queue());
2358 }
2359
2360 #[test]
2361 fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
2362 let state = crate::AppState::for_test().with_profile("prod");
2365 let config = MailConfig {
2366 allow_in_process_deliver_later_in_production: true,
2367 ..sample_smtp_config()
2368 };
2369
2370 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
2371
2372 let installed = state
2373 .extension::<Mailer>()
2374 .expect("install_mailer should store a Mailer extension");
2375 assert!(
2376 !installed.has_durable_delivery_queue(),
2377 "no queue was registered, so installed mailer should fall back in-process"
2378 );
2379 }
2380
2381 #[test]
2382 fn install_mailer_attaches_registered_queue_to_mailer() {
2383 let state = crate::AppState::for_test().with_profile("prod");
2384 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2385 let config = sample_smtp_config();
2386
2387 install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
2388
2389 let installed = state
2390 .extension::<Mailer>()
2391 .expect("install_mailer should store a Mailer extension");
2392 assert!(
2393 installed.has_durable_delivery_queue(),
2394 "registered queue handle should be attached to the installed mailer"
2395 );
2396 }
2397
2398 #[test]
2399 fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
2400 let state = crate::AppState::for_test().with_profile("prod");
2401 let config = sample_smtp_config();
2402 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2403 let captured = Arc::clone(&factory_called);
2404
2405 let factory = move |_state: &crate::AppState| {
2406 captured.store(true, std::sync::atomic::Ordering::SeqCst);
2407 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
2408 };
2409
2410 install_mailer_with_factory(&state, &config, Some(factory), true)
2411 .expect("factory should produce a queue and satisfy the prod guard");
2412
2413 assert!(
2414 factory_called.load(std::sync::atomic::Ordering::SeqCst),
2415 "factory must run when enforce_durable_guard is true"
2416 );
2417 let installed = state
2418 .extension::<Mailer>()
2419 .expect("install_mailer should store a Mailer extension");
2420 assert!(
2421 installed.has_durable_delivery_queue(),
2422 "factory's queue should be wired into the installed Mailer"
2423 );
2424 }
2425
2426 #[test]
2427 fn install_mailer_with_factory_skips_factory_when_not_enforced() {
2428 let state = crate::AppState::for_test().with_profile("prod");
2429 let config = sample_smtp_config();
2430 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2431 let captured = Arc::clone(&factory_called);
2432
2433 let factory = move |_state: &crate::AppState| {
2434 captured.store(true, std::sync::atomic::Ordering::SeqCst);
2435 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
2436 };
2437
2438 install_mailer_with_factory(&state, &config, Some(factory), false)
2439 .expect("static-build path should skip factory and install cleanly");
2440
2441 assert!(
2442 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
2443 "factory must be skipped when enforce_durable_guard is false"
2444 );
2445 }
2446
2447 #[test]
2448 fn install_mailer_with_factory_propagates_factory_errors() {
2449 let state = crate::AppState::for_test().with_profile("prod");
2450 let config = sample_smtp_config();
2451
2452 let factory = |_state: &crate::AppState| {
2453 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
2454 "queue offline",
2455 ))
2456 };
2457
2458 let error = install_mailer_with_factory(&state, &config, Some(factory), true)
2459 .expect_err("factory error should propagate");
2460 assert!(error.to_string().contains("queue offline"));
2461 }
2462
2463 #[test]
2464 fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
2465 let state = crate::AppState::for_test().with_profile("dev");
2470 let config = MailConfig::default(); let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2472 let captured = Arc::clone(&factory_called);
2473
2474 let factory = move |_state: &crate::AppState| {
2475 captured.store(true, std::sync::atomic::Ordering::SeqCst);
2476 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
2477 "queue must not be reached",
2478 ))
2479 };
2480
2481 install_mailer_with_factory(&state, &config, Some(factory), true)
2482 .expect("disabled transport should bypass the factory entirely");
2483 assert!(
2484 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
2485 "factory must not run when transport = disabled"
2486 );
2487 }
2488
2489 #[test]
2490 fn install_mailer_with_factory_works_without_factory() {
2491 type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
2492 let state = crate::AppState::for_test().with_profile("dev");
2493 let config = sample_smtp_config();
2494 let no_factory: Option<FactoryFn> = None;
2495
2496 install_mailer_with_factory(&state, &config, no_factory, true)
2497 .expect("absent factory should be fine in non-prod");
2498 }
2499
2500 #[test]
2501 fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
2502 let state = crate::AppState::for_test().with_profile("prod");
2506 let config = sample_smtp_config();
2507
2508 install_mailer(&state, &config, false)
2509 .expect("static-build mode should install cleanly with no queue handle");
2510
2511 let installed = state
2512 .extension::<Mailer>()
2513 .expect("install_mailer should store a Mailer extension");
2514 assert!(
2515 !installed.has_durable_delivery_queue(),
2516 "no queue is expected when run_build_mode skips the factory"
2517 );
2518 }
2519
2520 #[test]
2521 fn install_mailer_skips_production_guard_when_not_enforced() {
2522 let state = crate::AppState::for_test().with_profile("prod");
2528 let config = sample_smtp_config();
2529
2530 install_mailer(&state, &config, false)
2531 .expect("static-build mode should not enforce the deliver_later guard");
2532 }
2533
2534 #[test]
2535 fn spawn_mail_delivery_inherits_parent_span() {
2536 use std::future::Future;
2537 use std::pin::Pin;
2538 use std::sync::{Arc, Mutex};
2539
2540 struct CapturingQueue(Arc<Mutex<Option<tracing::span::Id>>>);
2541 impl MailDeliveryQueue for CapturingQueue {
2542 fn enqueue<'a>(
2543 &'a self,
2544 _mail: Mail,
2545 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2546 let captured = self.0.clone();
2547 Box::pin(async move {
2548 *captured.lock().unwrap() = tracing::Span::current().id();
2549 Ok(())
2550 })
2551 }
2552 }
2553
2554 let captured_span_id: Arc<Mutex<Option<tracing::span::Id>>> = Arc::new(Mutex::new(None));
2555
2556 let mailer = Mailer::builder()
2557 .delivery_queue(CapturingQueue(captured_span_id.clone()))
2558 .build()
2559 .expect("mailer with queue should build");
2560 let mail = sample_mail();
2561
2562 tracing::subscriber::with_default(tracing_subscriber::registry(), || {
2567 let rt = tokio::runtime::Builder::new_current_thread()
2568 .enable_all()
2569 .build()
2570 .expect("build runtime");
2571
2572 let outer = tracing::info_span!("deliver_later_outer");
2573 let outer_id = outer.id();
2574
2575 rt.block_on(async {
2576 {
2577 let _guard = outer.enter();
2578 mailer
2579 .try_deliver_later(mail)
2580 .expect("deliver_later must not fail");
2581 }
2582
2583 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2584 });
2585
2586 let in_task = captured_span_id.lock().unwrap().clone();
2587 assert_eq!(
2588 in_task, outer_id,
2589 "delivery task must run inside the span that called deliver_later"
2590 );
2591 });
2592 }
2593
2594 #[tokio::test]
2595 async fn spawn_mail_delivery_logs_error_when_queue_fails() {
2596 use std::future::Future;
2597 use std::pin::Pin;
2598
2599 struct AlwaysFailQueue;
2600 impl MailDeliveryQueue for AlwaysFailQueue {
2601 fn enqueue<'a>(
2602 &'a self,
2603 _mail: Mail,
2604 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2605 Box::pin(async { Err(MailError::RuntimeUnavailable("always fails".to_owned())) })
2606 }
2607 }
2608
2609 let mailer = Mailer::builder()
2610 .delivery_queue(AlwaysFailQueue)
2611 .build()
2612 .expect("build");
2613
2614 mailer
2615 .try_deliver_later(sample_mail())
2616 .expect("should schedule");
2617
2618 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2619 }
2620
2621 #[tokio::test]
2622 async fn spawn_mail_delivery_logs_error_when_transport_fails() {
2623 use std::future::Future;
2624 use std::pin::Pin;
2625
2626 struct AlwaysFailTransport;
2627 impl MailTransport for AlwaysFailTransport {
2628 fn send<'a>(
2629 &'a self,
2630 _mail: Mail,
2631 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2632 Box::pin(async {
2633 Err(MailError::RuntimeUnavailable(
2634 "transport offline".to_owned(),
2635 ))
2636 })
2637 }
2638 }
2639
2640 let mailer = Mailer::with_transport(AlwaysFailTransport);
2641
2642 mailer
2643 .try_deliver_later(sample_mail())
2644 .expect("should schedule");
2645
2646 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2647 }
2648
2649 #[test]
2650 fn install_mailer_does_not_attach_queue_when_transport_disabled() {
2651 let state = crate::AppState::for_test().with_profile("dev");
2656 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2657 let config = MailConfig::default(); install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
2660
2661 let installed = state
2662 .extension::<Mailer>()
2663 .expect("install_mailer should store a Mailer extension");
2664 assert!(
2665 !installed.has_durable_delivery_queue(),
2666 "disabled transport must suppress queue attachment so deliver_later is a no-op"
2667 );
2668 }
2669
2670 #[tokio::test]
2671 async fn intercepted_mail_transport_short_circuit_prevents_sync_execution() {
2672 use std::future::Future;
2673 use std::pin::Pin;
2674 use std::sync::atomic::{AtomicU32, Ordering};
2675
2676 static TRANSPORT_CALLS: AtomicU32 = AtomicU32::new(0);
2677
2678 struct CountingTransport;
2679 impl MailTransport for CountingTransport {
2680 fn send<'a>(
2681 &'a self,
2682 _mail: Mail,
2683 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2684 TRANSPORT_CALLS.fetch_add(1, Ordering::SeqCst);
2685 Box::pin(async move { Ok(()) })
2686 }
2687
2688 fn is_disabled(&self) -> bool {
2689 false
2690 }
2691 }
2692
2693 struct ShortCircuitMailInterceptor;
2694 impl crate::interceptor::MailInterceptor for ShortCircuitMailInterceptor {
2695 fn intercept<'a>(
2696 &'a self,
2697 _mail: &'a Mail,
2698 _next: Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>,
2699 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2700 Box::pin(async move {
2701 Err(MailError::RuntimeUnavailable(
2702 "blocked by interceptor".to_owned(),
2703 ))
2704 })
2705 }
2706 }
2707
2708 let transport = Arc::new(CountingTransport);
2709 let interceptor = Arc::new(ShortCircuitMailInterceptor);
2710 let intercepted = InterceptedMailTransport {
2711 inner: transport,
2712 interceptor,
2713 };
2714
2715 let mail = Mail::builder()
2716 .to("test@example.com")
2717 .subject("test")
2718 .text("body")
2719 .build()
2720 .unwrap();
2721
2722 TRANSPORT_CALLS.store(0, Ordering::SeqCst);
2723
2724 let res = intercepted.send(mail).await;
2725 assert!(res.is_err());
2726 assert_eq!(TRANSPORT_CALLS.load(Ordering::SeqCst), 0);
2727 }
2728
2729 #[tokio::test]
2730 #[allow(clippy::await_holding_lock)]
2731 async fn test_smtp_transport_circuit_breaker() {
2732 let _lock = crate::circuit_breaker::TEST_LOCK
2733 .lock()
2734 .unwrap_or_else(std::sync::PoisonError::into_inner);
2735 crate::circuit_breaker::global_registry().clear();
2736 let policy = crate::circuit_breaker::CircuitBreakerPolicy {
2737 failure_ratio_threshold: 0.5,
2738 sample_window: std::time::Duration::from_secs(10),
2739 minimum_sample_count: 3,
2740 open_duration: std::time::Duration::from_secs(60),
2741 half_open_trial_count: 2,
2742 };
2743 let breaker =
2744 crate::circuit_breaker::global_registry().get_or_create("smtp_mailer", policy);
2745
2746 assert_eq!(
2748 breaker.state(),
2749 crate::circuit_breaker::CircuitState::Closed
2750 );
2751
2752 let config = SmtpConfig {
2754 host: Some("127.0.0.1".to_string()),
2755 port: Some(9999), tls: TlsMode::Disabled,
2757 username: None,
2758 password_env: None,
2759 };
2760 let transport = SmtpTransport::new(config, None).unwrap();
2761
2762 let mail = Mail::builder()
2763 .from("sender@example.com")
2764 .to("test@example.com")
2765 .subject("test")
2766 .text("body")
2767 .build()
2768 .unwrap();
2769
2770 for _ in 0..3 {
2772 let res = transport.send(mail.clone()).await;
2773 assert!(res.is_err());
2774 }
2775
2776 assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
2777
2778 let res = transport.send(mail.clone()).await;
2780 assert!(res.is_err());
2781 let err_str = res.err().unwrap().to_string();
2782 assert!(
2783 err_str.contains("circuit breaker")
2784 || err_str.contains("open")
2785 || err_str.contains("Open")
2786 || err_str.contains("runtime unavailable")
2787 );
2788
2789 crate::circuit_breaker::global_registry().clear();
2790 }
2791}