Skip to main content

autumn_web/
mail.rs

1//! Transactional email support.
2//!
3//! The public surface is intentionally small: build a [`Mail`] value, send it
4//! through the cloneable [`Mailer`] extractor, and swap transports through the
5//! [`MailTransport`] trait when SMTP is not the right coffin lining.
6
7use 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/// Mail transport selected by `[mail].transport`.
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Transport {
28    /// Write full email contents to the tracing log at INFO.
29    Log,
30    /// Write RFC 822 `.eml` files under `target/mail` or a configured dir.
31    File,
32    /// Send through SMTP using Lettre.
33    Smtp,
34    /// Drop all email sends successfully.
35    #[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/// SMTP TLS mode.
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum TlsMode {
55    /// Plain connection; useful only for local test SMTP sinks.
56    Disabled,
57    /// Upgrade with STARTTLS.
58    #[default]
59    StartTls,
60    /// Connect with wrapper TLS.
61    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/// SMTP configuration nested under `[mail.smtp]`.
76#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
77pub struct SmtpConfig {
78    /// SMTP host name.
79    #[serde(default)]
80    pub host: Option<String>,
81    /// SMTP port. Defaults to 587 for STARTTLS, 465 for TLS, and 25 for disabled TLS.
82    #[serde(default)]
83    pub port: Option<u16>,
84    /// Optional SMTP username.
85    #[serde(default)]
86    pub username: Option<String>,
87    /// Environment variable containing the SMTP password.
88    #[serde(default)]
89    pub password_env: Option<String>,
90    /// TLS behavior.
91    #[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/// `[mail]` config section.
108#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
109pub struct MailConfig {
110    /// Active transport.
111    #[serde(default)]
112    pub transport: Transport,
113    /// Default From header.
114    #[serde(default)]
115    pub from: Option<String>,
116    /// Default Reply-To header.
117    #[serde(default)]
118    pub reply_to: Option<String>,
119    /// Permit log transport in `prod`.
120    #[serde(default)]
121    pub allow_log_in_production: bool,
122    /// Acknowledge that `deliver_later` may use the in-process Tokio fallback in
123    /// `prod`. Without a registered durable [`MailDeliveryQueue`], this is the
124    /// only way to start the app in `prod` with an active mail transport.
125    #[serde(default)]
126    pub allow_in_process_deliver_later_in_production: bool,
127    /// Directory for file transport.
128    #[serde(default = "default_file_dir")]
129    pub file_dir: PathBuf,
130    /// Force-enable the dev mail preview UI.
131    ///
132    /// The UI is auto-enabled in `dev` when `mail.transport = "file"`.
133    /// Setting this flag outside `dev` is rejected at startup.
134    #[serde(default)]
135    pub preview: bool,
136    /// SMTP settings.
137    #[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    /// Validate semantic mail configuration.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`crate::config::ConfigError::Validation`] for unsafe profile
162    /// combinations or missing SMTP settings.
163    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
200/// Renderable mail body input.
201pub trait IntoMailBody {
202    /// Convert into owned body text.
203    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/// A transactional email.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct Mail {
227    /// Optional From header. Falls back to [`Mailer`]'s default.
228    pub from: Option<String>,
229    /// Optional Reply-To header. Falls back to [`Mailer`]'s default.
230    pub reply_to: Option<String>,
231    /// To recipients.
232    pub to: Vec<String>,
233    /// Subject header.
234    pub subject: String,
235    /// HTML body.
236    pub html: Option<String>,
237    /// Plain-text body.
238    pub text: Option<String>,
239}
240
241/// Stable root path for the dev mail preview UI.
242pub 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/// A developer-authored, zero-argument mail template preview.
248#[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    /// Register a mail preview for the dev mail preview UI.
266    #[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    /// Mailer type label used in preview URLs.
276    #[must_use]
277    pub const fn mailer(&self) -> &'static str {
278        self.mailer
279    }
280
281    /// Preview method label used in preview URLs.
282    #[must_use]
283    pub const fn method(&self) -> &'static str {
284        self.method
285    }
286
287    /// Render the preview without invoking any configured transport.
288    ///
289    /// # Errors
290    ///
291    /// Returns [`MailPreviewError::PreviewPanicked`] if the preview function
292    /// panics while constructing sample data.
293    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/// Collection of registered mail previews stored on [`AppState`].
304#[derive(Debug, Clone, Default)]
305pub struct MailPreviewRegistry {
306    previews: Arc<Vec<MailPreview>>,
307}
308
309impl MailPreviewRegistry {
310    /// Create a registry from preview registrations.
311    #[must_use]
312    pub fn new(previews: Vec<MailPreview>) -> Self {
313        Self {
314            previews: Arc::new(previews),
315        }
316    }
317
318    /// Registered previews.
319    #[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/// Dev mail preview UI errors.
333#[derive(Debug, Error)]
334pub enum MailPreviewError {
335    /// File transport preview IO failed.
336    #[error("mail preview file IO failed: {0}")]
337    Io(#[from] std::io::Error),
338    /// Requested captured message was not found.
339    #[error("captured mail message not found: {0}")]
340    NotFound(String),
341    /// Requested message id is not a single `.eml` filename.
342    #[error("invalid captured mail message id: {0}")]
343    InvalidMessageId(String),
344    /// Developer-authored preview panicked while rendering sample data.
345    #[error("mail preview {mailer}::{method} panicked while rendering")]
346    PreviewPanicked {
347        /// Mailer label.
348        mailer: &'static str,
349        /// Method label.
350        method: &'static str,
351    },
352}
353
354impl Mail {
355    /// Start building a mail message.
356    #[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/// Builder for [`Mail`].
373#[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    /// Set a message-specific From header.
385    #[must_use]
386    pub fn from(mut self, from: impl Into<String>) -> Self {
387        self.from = Some(from.into());
388        self
389    }
390
391    /// Set a message-specific Reply-To header.
392    #[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    /// Add a To recipient.
399    #[must_use]
400    pub fn to(mut self, to: impl Into<String>) -> Self {
401        self.to.push(to.into());
402        self
403    }
404
405    /// Set the subject.
406    #[must_use]
407    pub fn subject(mut self, subject: impl Into<String>) -> Self {
408        self.subject = Some(subject.into());
409        self
410    }
411
412    /// Set the HTML body.
413    #[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    /// Set the plain-text body.
420    #[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    /// Build the mail.
427    ///
428    /// # Errors
429    ///
430    /// Returns [`MailError::InvalidMessage`] when required fields are missing.
431    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/// Mailer errors.
458#[derive(Debug, Error)]
459pub enum MailError {
460    /// Message could not be built or validated.
461    #[error("invalid mail message: {0}")]
462    InvalidMessage(String),
463    /// Deferred delivery could not be scheduled.
464    #[error("mail runtime unavailable: {0}")]
465    RuntimeUnavailable(String),
466    /// Address parsing failed.
467    #[error("invalid mail address {address:?}: {source}")]
468    InvalidAddress {
469        /// Address that failed to parse.
470        address: String,
471        /// Lettre parse error.
472        source: lettre::address::AddressError,
473    },
474    /// Lettre message construction failed.
475    #[error("failed to build mail message: {0}")]
476    Build(#[from] lettre::error::Error),
477    /// SMTP transport failed.
478    #[error("smtp send failed: {0}")]
479    Smtp(#[from] lettre::transport::smtp::Error),
480    /// File transport failed.
481    #[error("file mail transport failed: {0}")]
482    Io(#[from] std::io::Error),
483}
484
485/// Escape hatch for custom transports.
486pub trait MailTransport: Send + Sync {
487    /// Send a mail message.
488    fn send<'a>(
489        &'a self,
490        mail: Mail,
491    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
492
493    /// Returns `true` if this transport is intentionally a no-op (e.g.
494    /// [`Transport::Disabled`] for review apps and tests).
495    ///
496    /// When `true`, [`Mailer::deliver_later`] short-circuits before the queue
497    /// or in-process fallback so deferred mail honors the same "drop
498    /// everything" contract as immediate sends. Custom transports that mean
499    /// "drop all mail" can override this to opt into the same behavior; the
500    /// default of `false` preserves the existing contract for transports that
501    /// merely capture mail (file, log, etc.) or send it (SMTP, custom APIs).
502    fn is_disabled(&self) -> bool {
503        false
504    }
505}
506
507/// Durable backend for [`Mailer::deliver_later`].
508///
509/// Implementors persist the mail (DB row, Redis stream, Harvest job, etc.) and
510/// return as soon as the handoff is durable. The framework's in-process Tokio
511/// fallback is intentionally not durable; production deployments should
512/// register a real implementation via [`MailDeliveryQueueHandle`] before
513/// `install_mailer` runs, or set
514/// [`MailConfig::allow_in_process_deliver_later_in_production`] to opt into the
515/// fallback explicitly.
516pub trait MailDeliveryQueue: Send + Sync {
517    /// Enqueue a mail for durable later delivery.
518    fn enqueue<'a>(
519        &'a self,
520        mail: Mail,
521    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>;
522}
523
524/// Cloneable handle to a [`MailDeliveryQueue`].
525///
526/// Designed for storage on [`AppState`] extensions. Plugins
527/// (Harvest, custom Redis, etc.) install this before `install_mailer` runs and
528/// the mailer picks it up.
529#[derive(Clone)]
530pub struct MailDeliveryQueueHandle(Arc<dyn MailDeliveryQueue>);
531
532impl MailDeliveryQueueHandle {
533    /// Wrap a queue implementation in a cloneable handle.
534    #[must_use]
535    pub fn new(queue: impl MailDeliveryQueue + 'static) -> Self {
536        Self(Arc::new(queue))
537    }
538
539    /// Wrap an already-shared queue implementation.
540    #[must_use]
541    pub fn from_arc(queue: Arc<dyn MailDeliveryQueue>) -> Self {
542        Self(queue)
543    }
544
545    /// Borrow the inner queue.
546    #[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/// Cloneable email sender. Extract it in handlers as `mailer: Mailer`.
565#[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    /// Build a mailer manually.
574    #[must_use]
575    pub fn builder() -> MailerBuilder {
576        MailerBuilder::default()
577    }
578
579    /// Build a mailer from resolved config.
580    ///
581    /// # Errors
582    ///
583    /// Returns an error when SMTP or address configuration is invalid.
584    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    /// Build a mailer from any custom transport.
602    #[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    /// Attach a durable [`MailDeliveryQueue`] used by [`Self::deliver_later`].
612    #[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    /// Returns whether a durable [`MailDeliveryQueue`] is attached.
619    #[must_use]
620    pub fn has_durable_delivery_queue(&self) -> bool {
621        self.delivery_queue.is_some()
622    }
623
624    /// Returns `true` when the active transport is intentionally a no-op
625    /// (i.e. `transport = "disabled"` in `autumn.toml`).
626    ///
627    /// Handlers that require mail (e.g. forgot-password) can guard against
628    /// silently dropped messages by checking this before attempting to send.
629    #[must_use]
630    pub fn is_disabled(&self) -> bool {
631        self.transport.is_disabled()
632    }
633
634    /// Send mail immediately.
635    ///
636    /// # Errors
637    ///
638    /// Returns an error from the selected transport.
639    pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
640        self.transport
641            .send(mail.with_defaults(&self.defaults))
642            .await
643    }
644
645    /// Queue mail for later delivery.
646    ///
647    /// This release falls back to an in-process Tokio task. The method shape is
648    /// intentionally stable so Harvest-backed durable dispatch can slot in
649    /// behind the same call once the web crate and Harvest plugin share a
650    /// first-class queue contract.
651    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    /// Queue mail for later delivery.
658    ///
659    /// # Errors
660    ///
661    /// Returns an error when no active Tokio runtime is available to host the
662    /// background task.
663    pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
664        // Honor the disabled-transport contract for deferred mail too: if the
665        // operator turned mail off for this profile, deliver_later must drop
666        // the message just like immediate `send` does — even when a queue is
667        // attached. Otherwise `Mailer::builder().transport(Disabled)
668        // .delivery_queue(...)` would persist mail through the queue branch.
669        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/// Builder for [`Mailer`].
712#[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    /// Select the transport.
737    #[must_use]
738    pub const fn transport(mut self, transport: Transport) -> Self {
739        self.transport = transport;
740        self
741    }
742
743    /// Set default From header.
744    #[must_use]
745    pub fn from(mut self, from: impl Into<String>) -> Self {
746        self.from = Some(from.into());
747        self
748    }
749
750    /// Set default Reply-To header.
751    #[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    /// Set file output directory.
758    #[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    /// Set SMTP config.
765    #[must_use]
766    pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
767        self.smtp = Some(smtp);
768        self
769    }
770
771    /// Attach a durable [`MailDeliveryQueue`] used by
772    /// [`Mailer::deliver_later`].
773    #[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    /// Attach an already-shared durable [`MailDeliveryQueue`].
780    #[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    /// Build the mailer.
787    ///
788    /// # Errors
789    ///
790    /// Returns an error when the SMTP transport or default addresses cannot be configured.
791    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 = &quot;file&quot;</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("&amp;"),
1464            '<' => escaped.push_str("&lt;"),
1465            '>' => escaped.push_str("&gt;"),
1466            '"' => escaped.push_str("&quot;"),
1467            '\'' => escaped.push_str("&#39;"),
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
1509/// Install the configured mailer into app state.
1510///
1511/// Picks up a runtime-installed [`MailDeliveryQueueHandle`] from
1512/// [`AppState`] extensions when present, so plugins (Harvest, Redis-backed,
1513/// etc.) can register durable delivery before this runs. In `prod` with a
1514/// non-`Disabled` transport, startup fails when neither a durable queue nor
1515/// [`MailConfig::allow_in_process_deliver_later_in_production`] is set, unless
1516/// `enforce_durable_guard` is `false` (used by short-lived contexts like
1517/// static-site builds where `deliver_later` semantics don't apply).
1518///
1519/// # Errors
1520///
1521/// Returns an Autumn error when the configured transport cannot be created or
1522/// when the production `deliver_later` guard is not satisfied.
1523pub(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    // Honor the disabled transport contract: if the operator turned mail off
1534    // for this profile (tests, review apps, etc.), `deliver_later` must also
1535    // be a no-op — even when a durable queue was registered globally.
1536    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
1561/// Run the optional [`MailDeliveryQueue`] factory and install the configured
1562/// mailer.
1563///
1564/// Centralizes the wiring used by every [`AppBuilder`](crate::app::AppBuilder)
1565/// build path: optionally invoke `queue_factory` against the live `AppState`,
1566/// register the resulting [`MailDeliveryQueueHandle`], then call
1567/// [`install_mailer`]. The factory is skipped entirely when
1568/// `enforce_durable_guard` is `false` (static-site builds), since the queue
1569/// may capture infrastructure (Redis, Harvest, etc.) that isn't available in
1570/// the asset-build environment.
1571///
1572/// # Errors
1573///
1574/// Propagates errors from the queue factory and from [`install_mailer`].
1575pub(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    // Honor the disabled transport contract: a profile that turned mail off
1585    // (tests, review apps, etc.) must not open queue infrastructure either,
1586    // since all sends — immediate and deferred — are supposed to be no-ops.
1587    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        // Exercise NoopTransport::send so its body is also covered.
1923        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        // The Mailer-level builder lets callers attach a queue *and* pick
1942        // Transport::Disabled. The disabled-transport contract requires
1943        // deliver_later to drop the message in that case — the queue must
1944        // not persist mail when the operator has turned mail off entirely.
1945        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        // Wait briefly for any spawn that might erroneously fire to land.
1957        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        // The default Mailer has no durable queue, so deliver_later should
1967        // still spawn the in-process Tokio task and not call any queue.
1968        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        // Same as the explicit-ack test, but also asserts the mailer was
2026        // actually inserted and has no durable queue attached.
2027        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        // Even when enforce_durable_guard=true (normal server path), a
2129        // profile with transport=disabled must not run the factory: the
2130        // factory might open Redis/Harvest/DB connections, but all mail in
2131        // this profile is supposed to be a no-op.
2132        let state = crate::AppState::for_test().with_profile("dev");
2133        let config = MailConfig::default(); // transport = Disabled
2134        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        // Mirrors run_build_mode: queue factory is intentionally skipped, so
2166        // no MailDeliveryQueueHandle is on AppState. install_mailer must
2167        // tolerate this and not try to enforce or warn about a missing queue.
2168        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        // Static-site builds (run_build_mode) call install_mailer with
2186        // enforce_durable_guard=false because they don't run the request
2187        // loop and don't actually defer mail. Even with a prod profile,
2188        // an active SMTP transport, no queue, and no ack flag, install
2189        // must succeed in this mode.
2190        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        // When mail.transport = "disabled" the operator has explicitly turned
2200        // mail off for this profile (tests, review apps, etc.). A globally
2201        // registered queue must not turn deliver_later back into a durable
2202        // persist; it should remain a no-op.
2203        let state = crate::AppState::for_test().with_profile("dev");
2204        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2205        let config = MailConfig::default(); // transport = Disabled
2206
2207        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}