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 let mut builder = Self::builder().transport(config.transport);
586 if let Some(from) = &config.from {
587 builder = builder.from(from.clone());
588 }
589 if let Some(reply_to) = &config.reply_to {
590 builder = builder.reply_to(reply_to.clone());
591 }
592 if config.transport == Transport::File {
593 builder = builder.file_dir(config.file_dir.clone());
594 }
595 if config.transport == Transport::Smtp {
596 builder = builder.smtp(config.smtp.clone());
597 }
598 builder.build()
599 }
600
601 #[must_use]
603 pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
604 Self {
605 defaults: Arc::new(MailerDefaults::default()),
606 transport: Arc::new(transport),
607 delivery_queue: None,
608 }
609 }
610
611 #[must_use]
613 pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
614 self.delivery_queue = Some(Arc::new(queue));
615 self
616 }
617
618 #[must_use]
620 pub fn has_durable_delivery_queue(&self) -> bool {
621 self.delivery_queue.is_some()
622 }
623
624 #[must_use]
630 pub fn is_disabled(&self) -> bool {
631 self.transport.is_disabled()
632 }
633
634 pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
640 self.transport
641 .send(mail.with_defaults(&self.defaults))
642 .await
643 }
644
645 pub fn deliver_later(&self, mail: Mail) {
652 if let Err(error) = self.try_deliver_later(mail) {
653 tracing::error!(error = %error, "background mail delivery was not scheduled");
654 }
655 }
656
657 pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
664 if self.transport.is_disabled() {
670 return Ok(());
671 }
672 let mail = mail.with_defaults(&self.defaults);
673 let handle = tokio::runtime::Handle::try_current().map_err(|_| {
674 MailError::RuntimeUnavailable(
675 "deliver_later requires an active Tokio runtime".to_owned(),
676 )
677 })?;
678 if let Some(queue) = self.delivery_queue.clone() {
679 handle.spawn(async move {
680 if let Err(error) = queue.enqueue(mail).await {
681 tracing::error!(error = %error, "durable mail enqueue failed");
682 }
683 });
684 } else {
685 let mailer = self.clone();
686 handle.spawn(async move {
687 if let Err(error) = mailer.send(mail).await {
688 tracing::error!(error = %error, "background mail delivery failed");
689 }
690 });
691 }
692 Ok(())
693 }
694}
695
696impl FromRequestParts<AppState> for Mailer {
697 type Rejection = AutumnError;
698
699 async fn from_request_parts(
700 _parts: &mut http::request::Parts,
701 state: &AppState,
702 ) -> Result<Self, Self::Rejection> {
703 state
704 .extension::<Self>()
705 .as_deref()
706 .cloned()
707 .ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
708 }
709}
710
711#[derive(Clone)]
713pub struct MailerBuilder {
714 transport: Transport,
715 from: Option<String>,
716 reply_to: Option<String>,
717 file_dir: PathBuf,
718 smtp: Option<SmtpConfig>,
719 delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
720}
721
722impl Default for MailerBuilder {
723 fn default() -> Self {
724 Self {
725 transport: Transport::Log,
726 from: None,
727 reply_to: None,
728 file_dir: default_file_dir(),
729 smtp: None,
730 delivery_queue: None,
731 }
732 }
733}
734
735impl MailerBuilder {
736 #[must_use]
738 pub const fn transport(mut self, transport: Transport) -> Self {
739 self.transport = transport;
740 self
741 }
742
743 #[must_use]
745 pub fn from(mut self, from: impl Into<String>) -> Self {
746 self.from = Some(from.into());
747 self
748 }
749
750 #[must_use]
752 pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
753 self.reply_to = Some(reply_to.into());
754 self
755 }
756
757 #[must_use]
759 pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
760 self.file_dir = dir.as_ref().to_path_buf();
761 self
762 }
763
764 #[must_use]
766 pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
767 self.smtp = Some(smtp);
768 self
769 }
770
771 #[must_use]
774 pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
775 self.delivery_queue = Some(Arc::new(queue));
776 self
777 }
778
779 #[must_use]
781 pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
782 self.delivery_queue = Some(queue);
783 self
784 }
785
786 pub fn build(self) -> Result<Mailer, MailError> {
792 if let Some(from) = &self.from {
793 parse_mailbox(from)?;
794 }
795 if let Some(reply_to) = &self.reply_to {
796 parse_mailbox(reply_to)?;
797 }
798
799 let transport: Arc<dyn MailTransport> = match self.transport {
800 Transport::Log => Arc::new(LogTransport),
801 Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
802 Transport::Disabled => Arc::new(DisabledTransport),
803 Transport::Smtp => Arc::new(SmtpTransport::new(self.smtp.unwrap_or_default())?),
804 };
805
806 Ok(Mailer {
807 defaults: Arc::new(MailerDefaults {
808 from: self.from,
809 reply_to: self.reply_to,
810 }),
811 transport,
812 delivery_queue: self.delivery_queue,
813 })
814 }
815}
816
817struct DisabledTransport;
818
819impl MailTransport for DisabledTransport {
820 fn send<'a>(
821 &'a self,
822 _mail: Mail,
823 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
824 Box::pin(async { Ok(()) })
825 }
826
827 fn is_disabled(&self) -> bool {
828 true
829 }
830}
831
832struct LogTransport;
833
834impl MailTransport for LogTransport {
835 fn send<'a>(
836 &'a self,
837 mail: Mail,
838 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
839 Box::pin(async move {
840 tracing::info!(
841 from = ?mail.from,
842 reply_to = ?mail.reply_to,
843 to = ?mail.to,
844 subject = %mail.subject,
845 text = ?mail.text,
846 html = ?mail.html,
847 "mail captured by log transport"
848 );
849 Ok(())
850 })
851 }
852}
853
854struct FileTransport {
855 dir: PathBuf,
856}
857
858static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
859
860impl MailTransport for FileTransport {
861 fn send<'a>(
862 &'a self,
863 mail: Mail,
864 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
865 Box::pin(async move {
866 tokio::fs::create_dir_all(&self.dir).await?;
867 let filename = file_transport_filename(&mail);
868 let path = self.dir.join(filename);
869 let mut file = tokio::fs::OpenOptions::new()
870 .write(true)
871 .create_new(true)
872 .open(path)
873 .await?;
874 let eml = render_eml(&mail);
875 tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
876 tokio::io::AsyncWriteExt::flush(&mut file).await?;
877 file.sync_all().await?;
878 Ok(())
879 })
880 }
881}
882
883struct SmtpTransport {
884 inner: AsyncSmtpTransport<Tokio1Executor>,
885}
886
887impl SmtpTransport {
888 fn new(config: SmtpConfig) -> Result<Self, MailError> {
889 let host = config
890 .host
891 .filter(|host| !host.trim().is_empty())
892 .ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
893 let mut builder = match config.tls {
894 TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
895 TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
896 TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
897 };
898 if let Some(port) = config.port {
899 builder = builder.port(port);
900 }
901 if let Some(username) = config.username {
902 let password_env = config.password_env.ok_or_else(|| {
903 MailError::InvalidMessage(
904 "mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
905 )
906 })?;
907 let password = std::env::var(&password_env).map_err(|error| {
908 MailError::InvalidMessage(format!(
909 "mail.smtp.password_env={password_env:?} could not be resolved: {error}"
910 ))
911 })?;
912 builder = builder.credentials(Credentials::new(username, password));
913 }
914 Ok(Self {
915 inner: builder.build(),
916 })
917 }
918}
919
920impl MailTransport for SmtpTransport {
921 fn send<'a>(
922 &'a self,
923 mail: Mail,
924 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
925 Box::pin(async move {
926 let message = lettre_message(&mail)?;
927 self.inner.send(message).await?;
928 Ok(())
929 })
930 }
931}
932
933fn sanitize_filename(value: &str) -> String {
934 value
935 .chars()
936 .map(|ch| {
937 if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
938 ch
939 } else {
940 '_'
941 }
942 })
943 .collect()
944}
945
946fn file_transport_filename(mail: &Mail) -> String {
947 let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
948 format!(
949 "{}-{}-{:016x}-{}.eml",
950 chrono::Utc::now().format("%Y%m%d%H%M%S%6f"),
951 std::process::id(),
952 sequence,
953 sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
954 )
955}
956
957fn render_eml(mail: &Mail) -> String {
958 let mut out = String::new();
959 if let Some(from) = &mail.from {
960 out.push_str("From: ");
961 out.push_str(from);
962 out.push('\n');
963 }
964 for to in &mail.to {
965 out.push_str("To: ");
966 out.push_str(to);
967 out.push('\n');
968 }
969 if let Some(reply_to) = &mail.reply_to {
970 out.push_str("Reply-To: ");
971 out.push_str(reply_to);
972 out.push('\n');
973 }
974 out.push_str("Date: ");
975 out.push_str(&chrono::Utc::now().to_rfc2822());
976 out.push('\n');
977 out.push_str("Message-Id: <");
978 out.push_str(&uuid::Uuid::new_v4().to_string());
979 out.push_str("@autumn.local>\n");
980 out.push_str("Subject: ");
981 out.push_str(&mail.subject);
982 out.push_str("\nMIME-Version: 1.0\n");
983 if mail.html.is_some() && mail.text.is_some() {
984 out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
985 if let Some(text) = &mail.text {
986 out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
987 out.push_str(text);
988 out.push('\n');
989 }
990 if let Some(html) = &mail.html {
991 out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
992 out.push_str(html);
993 out.push('\n');
994 }
995 out.push_str("--autumn-mail--\n");
996 } else if let Some(html) = &mail.html {
997 out.push_str("Content-Type: text/html; charset=utf-8\n\n");
998 out.push_str(html);
999 out.push('\n');
1000 } else if let Some(text) = &mail.text {
1001 out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
1002 out.push_str(text);
1003 out.push('\n');
1004 }
1005 out
1006}
1007
1008#[derive(Debug, Clone)]
1009struct ParsedMail {
1010 headers: Vec<(String, String)>,
1011 to: Vec<String>,
1012 subject: String,
1013 date: Option<String>,
1014 html: Option<String>,
1015 text: Option<String>,
1016 raw: String,
1017}
1018
1019impl ParsedMail {
1020 fn header_value(&self, name: &str) -> Option<&str> {
1021 self.headers
1022 .iter()
1023 .find(|(header, _)| header.eq_ignore_ascii_case(name))
1024 .map(|(_, value)| value.as_str())
1025 }
1026}
1027
1028#[derive(Debug, Clone)]
1029struct CapturedMailSummary {
1030 id: String,
1031 to: Vec<String>,
1032 subject: String,
1033 timestamp: String,
1034 modified: SystemTime,
1035}
1036
1037pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
1038where
1039 S: Clone + Send + Sync + 'static,
1040 AppState: axum::extract::FromRef<S>,
1041{
1042 let file_dir = Arc::new(file_dir);
1043 axum::Router::new()
1044 .route(
1045 MAIL_PREVIEW_PATH,
1046 axum::routing::get({
1047 let file_dir = Arc::clone(&file_dir);
1048 move |axum::extract::State(state): axum::extract::State<AppState>| {
1049 let file_dir = Arc::clone(&file_dir);
1050 async move { list_mail_preview(file_dir, state).await }
1051 }
1052 }),
1053 )
1054 .route(
1055 MAIL_PREVIEW_MESSAGE_PATH,
1056 axum::routing::get({
1057 let file_dir = Arc::clone(&file_dir);
1058 move |axum::extract::Path(message_id): axum::extract::Path<String>| {
1059 let file_dir = Arc::clone(&file_dir);
1060 async move { show_captured_mail(file_dir, message_id).await }
1061 }
1062 }),
1063 )
1064 .route(
1065 MAIL_PREVIEW_TEMPLATE_PATH,
1066 axum::routing::get(
1067 |axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
1068 axum::extract::State(state): axum::extract::State<AppState>| async move {
1069 show_template_preview(&state, &mailer, &method)
1070 },
1071 ),
1072 )
1073}
1074
1075async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
1076 match captured_messages(&file_dir).await {
1077 Ok(messages) => {
1078 let previews = state
1079 .extension::<MailPreviewRegistry>()
1080 .map(|registry| registry.previews().to_vec())
1081 .unwrap_or_default();
1082 html_response(render_mail_index(&messages, &previews, &file_dir))
1083 }
1084 Err(error) => preview_error_response(&error),
1085 }
1086}
1087
1088async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
1089 match read_captured_message(&file_dir, &message_id).await {
1090 Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
1091 Err(error) => preview_error_response(&error),
1092 }
1093}
1094
1095fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
1096 let preview = state
1097 .extension::<MailPreviewRegistry>()
1098 .and_then(|registry| registry.find(mailer, method));
1099 let Some(preview) = preview else {
1100 return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
1101 };
1102
1103 match preview.render() {
1104 Ok(mail) => {
1105 let raw = render_eml(&mail);
1106 let parsed = parse_eml(&raw);
1107 html_response(render_mail_detail(&parsed, "Template preview"))
1108 }
1109 Err(error) => preview_error_response(&error),
1110 }
1111}
1112
1113async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
1114 let mut entries = match tokio::fs::read_dir(dir).await {
1115 Ok(entries) => entries,
1116 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1117 Err(error) => return Err(error.into()),
1118 };
1119
1120 let mut messages = Vec::new();
1121 while let Some(entry) = entries.next_entry().await? {
1122 let path = entry.path();
1123 if !path
1124 .extension()
1125 .and_then(|ext| ext.to_str())
1126 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1127 {
1128 continue;
1129 }
1130 let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
1131 continue;
1132 };
1133 let metadata = entry.metadata().await?;
1134 let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
1135 let raw = tokio::fs::read_to_string(&path).await?;
1136 let parsed = parse_eml(&raw);
1137 messages.push(CapturedMailSummary {
1138 id: id.to_owned(),
1139 to: parsed.to,
1140 subject: parsed.subject,
1141 timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
1142 modified,
1143 });
1144 }
1145
1146 messages.sort_by(|left, right| {
1147 right
1148 .modified
1149 .cmp(&left.modified)
1150 .then_with(|| right.id.cmp(&left.id))
1151 });
1152 Ok(messages)
1153}
1154
1155async fn read_captured_message(
1156 dir: &Path,
1157 message_id: &str,
1158) -> Result<ParsedMail, MailPreviewError> {
1159 if !valid_message_id(message_id) {
1160 return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
1161 }
1162 let path = dir.join(message_id);
1163 let raw = match tokio::fs::read_to_string(&path).await {
1164 Ok(raw) => raw,
1165 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1166 return Err(MailPreviewError::NotFound(message_id.to_owned()));
1167 }
1168 Err(error) => return Err(error.into()),
1169 };
1170 Ok(parse_eml(&raw))
1171}
1172
1173fn valid_message_id(message_id: &str) -> bool {
1174 !message_id.is_empty()
1175 && Path::new(message_id)
1176 .extension()
1177 .and_then(|ext| ext.to_str())
1178 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1179 && !message_id.contains('/')
1180 && !message_id.contains('\\')
1181 && !message_id.contains("..")
1182}
1183
1184fn parse_eml(raw: &str) -> ParsedMail {
1185 let normalized = raw.replace("\r\n", "\n");
1186 let (headers, body) = split_headers_body(&normalized);
1187 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
1188 let (html, text) = parse_mail_body(&content_type, body);
1189 let to = header_values(&headers, "To");
1190 let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
1191 let date = header_value(&headers, "Date");
1192
1193 ParsedMail {
1194 headers,
1195 to,
1196 subject,
1197 date,
1198 html,
1199 text,
1200 raw: raw.to_owned(),
1201 }
1202}
1203
1204fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
1205 let Some((header_block, body)) = raw.split_once("\n\n") else {
1206 return (parse_header_block(raw), "");
1207 };
1208 (parse_header_block(header_block), body)
1209}
1210
1211fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
1212 let mut headers = Vec::new();
1213 let mut current: Option<(String, String)> = None;
1214
1215 for line in header_block.lines() {
1216 if line.starts_with(' ') || line.starts_with('\t') {
1217 if let Some((_, value)) = current.as_mut() {
1218 value.push(' ');
1219 value.push_str(line.trim());
1220 }
1221 continue;
1222 }
1223 if let Some(header) = current.take() {
1224 headers.push(header);
1225 }
1226 if let Some((name, value)) = line.split_once(':') {
1227 current = Some((name.trim().to_owned(), value.trim().to_owned()));
1228 }
1229 }
1230 if let Some(header) = current {
1231 headers.push(header);
1232 }
1233 headers
1234}
1235
1236fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
1237 headers
1238 .iter()
1239 .find(|(header, _)| header.eq_ignore_ascii_case(name))
1240 .map(|(_, value)| value.clone())
1241}
1242
1243fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
1244 headers
1245 .iter()
1246 .filter(|(header, _)| header.eq_ignore_ascii_case(name))
1247 .map(|(_, value)| value.clone())
1248 .collect()
1249}
1250
1251fn parse_mail_body(content_type: &str, body: &str) -> (Option<String>, Option<String>) {
1252 if content_type
1253 .to_ascii_lowercase()
1254 .contains("multipart/alternative")
1255 && let Some(boundary) = content_type_boundary(content_type)
1256 {
1257 return parse_multipart_alternative(body, &boundary);
1258 }
1259
1260 if content_type.to_ascii_lowercase().contains("text/html") {
1261 (Some(trim_body(body)), None)
1262 } else {
1263 (None, Some(trim_body(body)))
1264 }
1265}
1266
1267fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
1268 let marker = format!("--{boundary}");
1269 let mut html = None;
1270 let mut text = None;
1271
1272 for segment in body.split(&marker).skip(1) {
1273 let segment = segment.trim_start_matches(['\n', '\r']);
1274 if segment.starts_with("--") {
1275 break;
1276 }
1277 let (headers, part_body) = split_headers_body(segment);
1278 let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
1279 if content_type.to_ascii_lowercase().contains("text/html") {
1280 html = Some(trim_body(part_body));
1281 } else if content_type.to_ascii_lowercase().contains("text/plain") {
1282 text = Some(trim_body(part_body));
1283 }
1284 }
1285
1286 (html, text)
1287}
1288
1289fn content_type_boundary(content_type: &str) -> Option<String> {
1290 content_type.split(';').find_map(|part| {
1291 let part = part.trim();
1292 let (name, value) = part.split_once('=')?;
1293 if !name.trim().eq_ignore_ascii_case("boundary") {
1294 return None;
1295 }
1296 Some(value.trim().trim_matches('"').to_owned())
1297 })
1298}
1299
1300fn trim_body(body: &str) -> String {
1301 body.trim_matches(['\r', '\n']).to_owned()
1302}
1303
1304fn format_system_time(time: SystemTime) -> String {
1305 let datetime: chrono::DateTime<chrono::Utc> = time.into();
1306 datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1307}
1308
1309fn render_mail_index(
1310 messages: &[CapturedMailSummary],
1311 previews: &[MailPreview],
1312 file_dir: &Path,
1313) -> String {
1314 let mut body = String::new();
1315 body.push_str("<h1>Autumn Mail</h1>");
1316 body.push_str("<section><h2>Captured messages</h2>");
1317 if messages.is_empty() {
1318 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>");
1319 body.push_str(&escape_html(&file_dir.display().to_string()));
1320 body.push_str("</code>.</p>");
1321 } else {
1322 body.push_str(
1323 "<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
1324 );
1325 for message in messages {
1326 body.push_str("<tr><td>");
1327 body.push_str(&escape_html(&message.timestamp));
1328 body.push_str("</td><td>");
1329 body.push_str(&escape_html(&message.to.join(", ")));
1330 body.push_str("</td><td><a href=\"");
1331 body.push_str(MAIL_PREVIEW_PATH);
1332 body.push_str("/messages/");
1333 body.push_str(&escape_html(&message.id));
1334 body.push_str("\">");
1335 body.push_str(&escape_html(&message.subject));
1336 body.push_str("</a></td></tr>");
1337 }
1338 body.push_str("</tbody></table>");
1339 }
1340 body.push_str("</section><section><h2>Template previews</h2>");
1341 if previews.is_empty() {
1342 body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
1343 } else {
1344 body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
1345 for preview in previews {
1346 body.push_str("<tr><td>");
1347 body.push_str(&escape_html(preview.mailer()));
1348 body.push_str("</td><td><a href=\"");
1349 body.push_str(MAIL_PREVIEW_PATH);
1350 body.push_str("/previews/");
1351 body.push_str(&escape_html(preview.mailer()));
1352 body.push('/');
1353 body.push_str(&escape_html(preview.method()));
1354 body.push_str("\">");
1355 body.push_str(&escape_html(preview.method()));
1356 body.push_str("</a></td></tr>");
1357 }
1358 body.push_str("</tbody></table>");
1359 }
1360 body.push_str("</section>");
1361 render_mail_preview_layout("Autumn Mail", &body)
1362}
1363
1364fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
1365 let mut body = String::new();
1366 body.push_str("<p><a href=\"");
1367 body.push_str(MAIL_PREVIEW_PATH);
1368 body.push_str("\">Back to mail</a></p><h1>");
1369 body.push_str(&escape_html(&parsed.subject));
1370 body.push_str("</h1><p class=\"muted\">");
1371 body.push_str(&escape_html(label));
1372 body.push_str("</p>");
1373
1374 if let Some(html) = &parsed.html {
1375 body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
1376 body.push_str(&escape_html(html));
1377 body.push_str("\"></iframe>");
1378 } else {
1379 body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
1380 }
1381
1382 body.push_str("<details><summary>Plain text</summary><pre>");
1383 body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
1384 body.push_str("</pre></details>");
1385
1386 body.push_str("<details><summary>Headers</summary><dl>");
1387 for header in ["From", "To", "Reply-To", "Subject", "Date", "Message-Id"] {
1388 if let Some(value) = parsed.header_value(header) {
1389 body.push_str("<dt>");
1390 body.push_str(header);
1391 body.push_str("</dt><dd>");
1392 body.push_str(&escape_html(value));
1393 body.push_str("</dd>");
1394 }
1395 }
1396 body.push_str("</dl></details>");
1397
1398 body.push_str("<details><summary>Raw .eml</summary><pre>");
1399 body.push_str(&escape_html(&parsed.raw));
1400 body.push_str("</pre></details>");
1401
1402 render_mail_preview_layout(&parsed.subject, &body)
1403}
1404
1405fn render_mail_preview_layout(title: &str, body: &str) -> String {
1406 format!(
1407 "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
1408 escape_html(title),
1409 MAIL_PREVIEW_CSS,
1410 body
1411 )
1412}
1413
1414const MAIL_PREVIEW_CSS: &str = r#"
1415body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
1416h1{margin:0 0 16px;font-size:28px}
1417h2{margin:28px 0 12px;font-size:18px}
1418table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
1419th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
1420th{background:#edf2f7;color:#394b59;font-weight:650}
1421a{color:#0b63ce;text-decoration:none}
1422a:hover{text-decoration:underline}
1423.empty,.muted{color:#52616f}
1424code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
1425pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
1426iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
1427details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
1428summary{cursor:pointer;font-weight:650}
1429dt{font-weight:650;margin-top:8px}
1430dd{margin:2px 0 8px}
1431"#;
1432
1433fn html_response(html: String) -> Response {
1434 Html(html).into_response()
1435}
1436
1437fn preview_error_response(error: &MailPreviewError) -> Response {
1438 let status = match error {
1439 MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
1440 http::StatusCode::NOT_FOUND
1441 }
1442 MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
1443 http::StatusCode::INTERNAL_SERVER_ERROR
1444 }
1445 };
1446 (
1447 status,
1448 Html(render_mail_preview_layout(
1449 "Mail preview error",
1450 &format!(
1451 "<h1>Mail preview error</h1><p>{}</p>",
1452 escape_html(&error.to_string())
1453 ),
1454 )),
1455 )
1456 .into_response()
1457}
1458
1459fn escape_html(value: &str) -> String {
1460 let mut escaped = String::with_capacity(value.len());
1461 for ch in value.chars() {
1462 match ch {
1463 '&' => escaped.push_str("&"),
1464 '<' => escaped.push_str("<"),
1465 '>' => escaped.push_str(">"),
1466 '"' => escaped.push_str("""),
1467 '\'' => escaped.push_str("'"),
1468 _ => escaped.push(ch),
1469 }
1470 }
1471 escaped
1472}
1473
1474fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
1475 address.parse().map_err(|source| MailError::InvalidAddress {
1476 address: address.to_owned(),
1477 source,
1478 })
1479}
1480
1481fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
1482 let from = mail
1483 .from
1484 .as_deref()
1485 .ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
1486 let mut builder = Message::builder().from(parse_mailbox(from)?);
1487 for to in &mail.to {
1488 builder = builder.to(parse_mailbox(to)?);
1489 }
1490 if let Some(reply_to) = &mail.reply_to {
1491 builder = builder.reply_to(parse_mailbox(reply_to)?);
1492 }
1493 builder = builder.subject(mail.subject.clone());
1494
1495 match (&mail.text, &mail.html) {
1496 (Some(text), Some(html)) => Ok(builder.multipart(
1497 MultiPart::alternative()
1498 .singlepart(SinglePart::plain(text.clone()))
1499 .singlepart(SinglePart::html(html.clone())),
1500 )?),
1501 (Some(text), None) => Ok(builder.singlepart(SinglePart::plain(text.clone()))?),
1502 (None, Some(html)) => Ok(builder.singlepart(SinglePart::html(html.clone()))?),
1503 (None, None) => Err(MailError::InvalidMessage(
1504 "mail must include html or text body".to_owned(),
1505 )),
1506 }
1507}
1508
1509pub(crate) fn install_mailer(
1524 state: &AppState,
1525 config: &MailConfig,
1526 enforce_durable_guard: bool,
1527) -> AutumnResult<()> {
1528 let mut mailer = Mailer::from_config(config).map_err(AutumnError::service_unavailable)?;
1529
1530 let in_production = matches!(state.profile(), "prod" | "production");
1531 let transport_sends_mail = config.transport != Transport::Disabled;
1532
1533 if transport_sends_mail {
1537 let queue_handle = state.extension::<MailDeliveryQueueHandle>();
1538 if let Some(handle) = queue_handle.as_ref() {
1539 mailer.delivery_queue = Some(Arc::clone(handle.inner()));
1540 }
1541 }
1542
1543 if enforce_durable_guard && in_production && transport_sends_mail {
1544 let has_durable_queue = mailer.delivery_queue.is_some();
1545 if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
1546 return Err(AutumnError::service_unavailable_msg(
1547 "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",
1548 ));
1549 }
1550 if !has_durable_queue {
1551 tracing::warn!(
1552 "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"
1553 );
1554 }
1555 }
1556
1557 state.insert_extension(mailer);
1558 Ok(())
1559}
1560
1561pub(crate) fn install_mailer_with_factory<F>(
1576 state: &AppState,
1577 config: &MailConfig,
1578 queue_factory: Option<F>,
1579 enforce_durable_guard: bool,
1580) -> AutumnResult<()>
1581where
1582 F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
1583{
1584 let transport_sends_mail = config.transport != Transport::Disabled;
1588 if enforce_durable_guard
1589 && transport_sends_mail
1590 && let Some(factory) = queue_factory
1591 {
1592 let queue = factory(state)?;
1593 state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
1594 }
1595 install_mailer(state, config, enforce_durable_guard)
1596}
1597
1598#[cfg(test)]
1599mod tests {
1600 use super::*;
1601
1602 #[test]
1603 fn mail_builder_rejects_missing_body() {
1604 let err = Mail::builder()
1605 .to("user@example.com")
1606 .subject("Hello")
1607 .build()
1608 .expect_err("body should be required");
1609 assert!(err.to_string().contains("html or text"));
1610 }
1611
1612 #[test]
1613 fn filename_sanitizer_keeps_safe_characters() {
1614 assert_eq!(
1615 sanitize_filename("Ada Lovelace <ada@example.com>"),
1616 "Ada_Lovelace__ada_example.com_"
1617 );
1618 }
1619
1620 #[test]
1621 fn transport_default_is_disabled() {
1622 assert_eq!(Transport::default(), Transport::Disabled);
1623 }
1624
1625 #[test]
1626 fn smtp_config_validation_rejects_whitespace_only_host() {
1627 let config = MailConfig {
1628 transport: Transport::Smtp,
1629 smtp: SmtpConfig {
1630 host: Some(" ".to_owned()),
1631 ..Default::default()
1632 },
1633 ..Default::default()
1634 };
1635
1636 let error = config
1637 .validate(Some("dev"))
1638 .expect_err("whitespace SMTP host should be rejected");
1639
1640 assert!(error.to_string().contains("mail.smtp.host is required"));
1641 }
1642
1643 #[test]
1644 fn transport_env_value_is_trimmed_and_case_insensitive() {
1645 assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
1646 assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
1647 }
1648
1649 #[test]
1650 fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
1651 assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
1652 assert_eq!(
1653 TlsMode::from_env_value(" START_TLS "),
1654 Some(TlsMode::StartTls)
1655 );
1656 assert_eq!(
1657 TlsMode::from_env_value(" disabled "),
1658 Some(TlsMode::Disabled)
1659 );
1660 }
1661
1662 #[test]
1663 fn file_transport_filename_is_unique_for_same_recipient() {
1664 let mail = Mail::builder()
1665 .to("Ada Lovelace <ada@example.com>")
1666 .subject("Hello")
1667 .text("body")
1668 .build()
1669 .expect("mail should build");
1670
1671 let first = file_transport_filename(&mail);
1672 let second = file_transport_filename(&mail);
1673
1674 assert_ne!(first, second);
1675 assert!(
1676 Path::new(&first)
1677 .extension()
1678 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1679 );
1680 assert!(
1681 Path::new(&second)
1682 .extension()
1683 .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1684 );
1685 }
1686
1687 #[test]
1688 fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
1689 let missing_key = format!(
1690 "AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
1691 std::process::id(),
1692 chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
1693 );
1694 let Err(error) = SmtpTransport::new(SmtpConfig {
1695 host: Some("smtp.example.com".to_owned()),
1696 port: Some(587),
1697 username: Some("mailer".to_owned()),
1698 password_env: Some(missing_key.clone()),
1699 tls: TlsMode::StartTls,
1700 }) else {
1701 panic!("missing password env should fail at startup");
1702 };
1703
1704 assert!(error.to_string().contains(&missing_key));
1705 }
1706
1707 #[test]
1708 fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
1709 let Err(error) = SmtpTransport::new(SmtpConfig {
1710 host: Some("smtp.example.com".to_owned()),
1711 port: Some(587),
1712 username: Some("mailer".to_owned()),
1713 password_env: None,
1714 tls: TlsMode::StartTls,
1715 }) else {
1716 panic!("missing password_env setting should fail at startup");
1717 };
1718
1719 assert!(error.to_string().contains("mail.smtp.password_env"));
1720 }
1721
1722 #[test]
1723 fn mailer_builder_rejects_invalid_default_from_address() {
1724 let Err(error) = Mailer::builder().from("not an email address").build() else {
1725 panic!("invalid default from should fail fast");
1726 };
1727
1728 match error {
1729 MailError::InvalidAddress { address, .. } => {
1730 assert_eq!(address, "not an email address");
1731 }
1732 other => panic!("expected invalid address error, got {other:?}"),
1733 }
1734 }
1735
1736 #[test]
1737 fn mailer_from_config_rejects_invalid_default_reply_to_address() {
1738 let config = MailConfig {
1739 transport: Transport::Smtp,
1740 from: Some("Autumn <noreply@example.com>".to_owned()),
1741 reply_to: Some("definitely not an address".to_owned()),
1742 smtp: SmtpConfig {
1743 host: Some("smtp.example.com".to_owned()),
1744 ..Default::default()
1745 },
1746 ..Default::default()
1747 };
1748
1749 let Err(error) = Mailer::from_config(&config) else {
1750 panic!("invalid configured reply-to should fail at construction");
1751 };
1752
1753 match error {
1754 MailError::InvalidAddress { address, .. } => {
1755 assert_eq!(address, "definitely not an address");
1756 }
1757 other => panic!("expected invalid address error, got {other:?}"),
1758 }
1759 }
1760
1761 #[test]
1762 fn try_deliver_later_returns_error_without_runtime() {
1763 let mailer = Mailer::builder().build().expect("mailer should build");
1764 let mail = Mail::builder()
1765 .to("user@example.com")
1766 .subject("Hello")
1767 .text("hello")
1768 .build()
1769 .expect("mail should build");
1770
1771 let error = mailer
1772 .try_deliver_later(mail)
1773 .expect_err("missing runtime should return an error");
1774
1775 assert!(error.to_string().contains("active Tokio runtime"));
1776 }
1777
1778 #[test]
1779 fn deliver_later_does_not_panic_without_runtime() {
1780 let mailer = Mailer::builder().build().expect("mailer should build");
1781 let mail = Mail::builder()
1782 .to("user@example.com")
1783 .subject("Hello")
1784 .text("hello")
1785 .build()
1786 .expect("mail should build");
1787
1788 mailer.deliver_later(mail);
1789 }
1790
1791 fn sample_smtp_config() -> MailConfig {
1792 MailConfig {
1793 transport: Transport::Smtp,
1794 from: Some("Autumn <noreply@example.com>".to_owned()),
1795 smtp: SmtpConfig {
1796 host: Some("smtp.example.com".to_owned()),
1797 ..Default::default()
1798 },
1799 ..Default::default()
1800 }
1801 }
1802
1803 fn sample_mail() -> Mail {
1804 Mail::builder()
1805 .to("user@example.com")
1806 .subject("Hi")
1807 .text("hello")
1808 .build()
1809 .expect("mail should build")
1810 }
1811
1812 struct NoopQueue;
1813
1814 impl MailDeliveryQueue for NoopQueue {
1815 fn enqueue<'a>(
1816 &'a self,
1817 _mail: Mail,
1818 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1819 Box::pin(async { Ok(()) })
1820 }
1821 }
1822
1823 #[test]
1824 fn install_mailer_rejects_in_process_fallback_in_prod_without_ack() {
1825 let state = crate::AppState::for_test().with_profile("prod");
1826 let config = sample_smtp_config();
1827
1828 let error = install_mailer(&state, &config, true)
1829 .expect_err("prod must reject in-process deliver_later fallback without ack");
1830
1831 let message = error.to_string();
1832 assert!(
1833 message.contains("allow_in_process_deliver_later_in_production"),
1834 "error should explain how to opt in: {message}"
1835 );
1836 }
1837
1838 #[test]
1839 fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
1840 let state = crate::AppState::for_test().with_profile("prod");
1841 let config = MailConfig {
1842 allow_in_process_deliver_later_in_production: true,
1843 ..sample_smtp_config()
1844 };
1845
1846 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
1847 }
1848
1849 #[test]
1850 fn install_mailer_allows_durable_queue_in_prod_without_ack() {
1851 let state = crate::AppState::for_test().with_profile("prod");
1852 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
1853 let config = sample_smtp_config();
1854
1855 install_mailer(&state, &config, true)
1856 .expect("a registered durable queue should satisfy the prod guard");
1857 }
1858
1859 #[test]
1860 fn install_mailer_does_not_require_ack_outside_production() {
1861 let state = crate::AppState::for_test().with_profile("dev");
1862 let config = sample_smtp_config();
1863
1864 install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
1865 }
1866
1867 #[test]
1868 fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
1869 let state = crate::AppState::for_test().with_profile("prod");
1870 let config = MailConfig::default();
1871
1872 install_mailer(&state, &config, true)
1873 .expect("disabled transport never sends mail so it should not need an ack");
1874 }
1875
1876 struct CapturingQueue {
1877 tx: tokio::sync::mpsc::UnboundedSender<Mail>,
1878 }
1879
1880 impl MailDeliveryQueue for CapturingQueue {
1881 fn enqueue<'a>(
1882 &'a self,
1883 mail: Mail,
1884 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1885 let tx = self.tx.clone();
1886 Box::pin(async move {
1887 tx.send(mail)
1888 .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
1889 Ok(())
1890 })
1891 }
1892 }
1893
1894 #[tokio::test]
1895 async fn deliver_later_routes_through_configured_queue() {
1896 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
1897
1898 let mailer = Mailer::builder()
1899 .delivery_queue(CapturingQueue { tx })
1900 .build()
1901 .expect("mailer should build");
1902
1903 mailer
1904 .try_deliver_later(sample_mail())
1905 .expect("scheduling onto the queue should succeed");
1906
1907 let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
1908 .await
1909 .expect("queue should receive within 1s")
1910 .expect("queue should receive the mail");
1911
1912 assert_eq!(received.subject, "Hi");
1913 }
1914
1915 #[tokio::test]
1916 async fn mailer_with_transport_starts_without_delivery_queue() {
1917 let mailer = Mailer::with_transport(NoopTransport);
1918 assert!(
1919 !mailer.has_durable_delivery_queue(),
1920 "with_transport should default to no durable queue"
1921 );
1922 mailer
1924 .send(sample_mail())
1925 .await
1926 .expect("noop transport should always succeed");
1927 }
1928
1929 struct NoopTransport;
1930 impl MailTransport for NoopTransport {
1931 fn send<'a>(
1932 &'a self,
1933 _mail: Mail,
1934 ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1935 Box::pin(async { Ok(()) })
1936 }
1937 }
1938
1939 #[tokio::test]
1940 async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
1941 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
1946 let mailer = Mailer::builder()
1947 .transport(Transport::Disabled)
1948 .delivery_queue(CapturingQueue { tx })
1949 .build()
1950 .expect("mailer should build");
1951
1952 mailer
1953 .try_deliver_later(sample_mail())
1954 .expect("disabled transport should succeed as a no-op");
1955
1956 let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
1958 assert!(
1959 received.is_err(),
1960 "queue must not be invoked when transport is disabled"
1961 );
1962 }
1963
1964 #[tokio::test]
1965 async fn deliver_later_uses_in_process_fallback_when_no_queue() {
1966 let mailer = Mailer::builder().build().expect("mailer should build");
1969
1970 mailer
1971 .try_deliver_later(sample_mail())
1972 .expect("in-process fallback should still schedule");
1973 }
1974
1975 #[test]
1976 fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
1977 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
1978 let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
1979
1980 assert!(Arc::ptr_eq(handle.inner(), &arc));
1981 }
1982
1983 #[test]
1984 fn mail_delivery_queue_handle_debug_does_not_panic() {
1985 let handle = MailDeliveryQueueHandle::new(NoopQueue);
1986 let rendered = format!("{handle:?}");
1987 assert!(rendered.contains("MailDeliveryQueueHandle"));
1988 }
1989
1990 #[test]
1991 fn mailer_has_durable_delivery_queue_reflects_attachment() {
1992 let plain = Mailer::builder().build().expect("mailer should build");
1993 assert!(!plain.has_durable_delivery_queue());
1994
1995 let with_queue = Mailer::builder()
1996 .delivery_queue(NoopQueue)
1997 .build()
1998 .expect("mailer should build");
1999 assert!(with_queue.has_durable_delivery_queue());
2000 }
2001
2002 #[test]
2003 fn mailer_with_delivery_queue_post_build_attaches_queue() {
2004 let mailer = Mailer::builder()
2005 .build()
2006 .expect("mailer should build")
2007 .with_delivery_queue(NoopQueue);
2008
2009 assert!(mailer.has_durable_delivery_queue());
2010 }
2011
2012 #[test]
2013 fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
2014 let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
2015 let mailer = Mailer::builder()
2016 .delivery_queue_arc(arc)
2017 .build()
2018 .expect("mailer should build");
2019
2020 assert!(mailer.has_durable_delivery_queue());
2021 }
2022
2023 #[test]
2024 fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
2025 let state = crate::AppState::for_test().with_profile("prod");
2028 let config = MailConfig {
2029 allow_in_process_deliver_later_in_production: true,
2030 ..sample_smtp_config()
2031 };
2032
2033 install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
2034
2035 let installed = state
2036 .extension::<Mailer>()
2037 .expect("install_mailer should store a Mailer extension");
2038 assert!(
2039 !installed.has_durable_delivery_queue(),
2040 "no queue was registered, so installed mailer should fall back in-process"
2041 );
2042 }
2043
2044 #[test]
2045 fn install_mailer_attaches_registered_queue_to_mailer() {
2046 let state = crate::AppState::for_test().with_profile("prod");
2047 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2048 let config = sample_smtp_config();
2049
2050 install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
2051
2052 let installed = state
2053 .extension::<Mailer>()
2054 .expect("install_mailer should store a Mailer extension");
2055 assert!(
2056 installed.has_durable_delivery_queue(),
2057 "registered queue handle should be attached to the installed mailer"
2058 );
2059 }
2060
2061 #[test]
2062 fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
2063 let state = crate::AppState::for_test().with_profile("prod");
2064 let config = sample_smtp_config();
2065 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2066 let captured = Arc::clone(&factory_called);
2067
2068 let factory = move |_state: &crate::AppState| {
2069 captured.store(true, std::sync::atomic::Ordering::SeqCst);
2070 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
2071 };
2072
2073 install_mailer_with_factory(&state, &config, Some(factory), true)
2074 .expect("factory should produce a queue and satisfy the prod guard");
2075
2076 assert!(
2077 factory_called.load(std::sync::atomic::Ordering::SeqCst),
2078 "factory must run when enforce_durable_guard is true"
2079 );
2080 let installed = state
2081 .extension::<Mailer>()
2082 .expect("install_mailer should store a Mailer extension");
2083 assert!(
2084 installed.has_durable_delivery_queue(),
2085 "factory's queue should be wired into the installed Mailer"
2086 );
2087 }
2088
2089 #[test]
2090 fn install_mailer_with_factory_skips_factory_when_not_enforced() {
2091 let state = crate::AppState::for_test().with_profile("prod");
2092 let config = sample_smtp_config();
2093 let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2094 let captured = Arc::clone(&factory_called);
2095
2096 let factory = move |_state: &crate::AppState| {
2097 captured.store(true, std::sync::atomic::Ordering::SeqCst);
2098 Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
2099 };
2100
2101 install_mailer_with_factory(&state, &config, Some(factory), false)
2102 .expect("static-build path should skip factory and install cleanly");
2103
2104 assert!(
2105 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
2106 "factory must be skipped when enforce_durable_guard is false"
2107 );
2108 }
2109
2110 #[test]
2111 fn install_mailer_with_factory_propagates_factory_errors() {
2112 let state = crate::AppState::for_test().with_profile("prod");
2113 let config = sample_smtp_config();
2114
2115 let factory = |_state: &crate::AppState| {
2116 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
2117 "queue offline",
2118 ))
2119 };
2120
2121 let error = install_mailer_with_factory(&state, &config, Some(factory), true)
2122 .expect_err("factory error should propagate");
2123 assert!(error.to_string().contains("queue offline"));
2124 }
2125
2126 #[test]
2127 fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
2128 let state = crate::AppState::for_test().with_profile("dev");
2133 let config = MailConfig::default(); let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2135 let captured = Arc::clone(&factory_called);
2136
2137 let factory = move |_state: &crate::AppState| {
2138 captured.store(true, std::sync::atomic::Ordering::SeqCst);
2139 Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
2140 "queue must not be reached",
2141 ))
2142 };
2143
2144 install_mailer_with_factory(&state, &config, Some(factory), true)
2145 .expect("disabled transport should bypass the factory entirely");
2146 assert!(
2147 !factory_called.load(std::sync::atomic::Ordering::SeqCst),
2148 "factory must not run when transport = disabled"
2149 );
2150 }
2151
2152 #[test]
2153 fn install_mailer_with_factory_works_without_factory() {
2154 type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
2155 let state = crate::AppState::for_test().with_profile("dev");
2156 let config = sample_smtp_config();
2157 let no_factory: Option<FactoryFn> = None;
2158
2159 install_mailer_with_factory(&state, &config, no_factory, true)
2160 .expect("absent factory should be fine in non-prod");
2161 }
2162
2163 #[test]
2164 fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
2165 let state = crate::AppState::for_test().with_profile("prod");
2169 let config = sample_smtp_config();
2170
2171 install_mailer(&state, &config, false)
2172 .expect("static-build mode should install cleanly with no queue handle");
2173
2174 let installed = state
2175 .extension::<Mailer>()
2176 .expect("install_mailer should store a Mailer extension");
2177 assert!(
2178 !installed.has_durable_delivery_queue(),
2179 "no queue is expected when run_build_mode skips the factory"
2180 );
2181 }
2182
2183 #[test]
2184 fn install_mailer_skips_production_guard_when_not_enforced() {
2185 let state = crate::AppState::for_test().with_profile("prod");
2191 let config = sample_smtp_config();
2192
2193 install_mailer(&state, &config, false)
2194 .expect("static-build mode should not enforce the deliver_later guard");
2195 }
2196
2197 #[test]
2198 fn install_mailer_does_not_attach_queue_when_transport_disabled() {
2199 let state = crate::AppState::for_test().with_profile("dev");
2204 state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2205 let config = MailConfig::default(); install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
2208
2209 let installed = state
2210 .extension::<Mailer>()
2211 .expect("install_mailer should store a Mailer extension");
2212 assert!(
2213 !installed.has_durable_delivery_queue(),
2214 "disabled transport must suppress queue attachment so deliver_later is a no-op"
2215 );
2216 }
2217}