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        Self::from_config_inner(config, None)
586    }
587
588    pub(crate) fn from_config_inner(
589        config: &MailConfig,
590        resilience: Option<Arc<crate::config::ResilienceConfig>>,
591    ) -> Result<Self, MailError> {
592        let mut builder = Self::builder()
593            .transport(config.transport)
594            .resilience_config(resilience);
595        if let Some(from) = &config.from {
596            builder = builder.from(from.clone());
597        }
598        if let Some(reply_to) = &config.reply_to {
599            builder = builder.reply_to(reply_to.clone());
600        }
601        if config.transport == Transport::File {
602            builder = builder.file_dir(config.file_dir.clone());
603        }
604        if config.transport == Transport::Smtp {
605            builder = builder.smtp(config.smtp.clone());
606        }
607        builder.build()
608    }
609
610    /// Build a mailer from any custom transport.
611    #[must_use]
612    pub fn with_transport(transport: impl MailTransport + 'static) -> Self {
613        Self {
614            defaults: Arc::new(MailerDefaults::default()),
615            transport: Arc::new(transport),
616            delivery_queue: None,
617        }
618    }
619
620    /// Attach a durable [`MailDeliveryQueue`] used by [`Self::deliver_later`].
621    #[must_use]
622    pub fn with_delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
623        self.delivery_queue = Some(Arc::new(queue));
624        self
625    }
626
627    /// Returns whether a durable [`MailDeliveryQueue`] is attached.
628    #[must_use]
629    pub fn has_durable_delivery_queue(&self) -> bool {
630        self.delivery_queue.is_some()
631    }
632
633    /// Returns `true` when the active transport is intentionally a no-op
634    /// (i.e. `transport = "disabled"` in `autumn.toml`).
635    ///
636    /// Handlers that require mail (e.g. forgot-password) can guard against
637    /// silently dropped messages by checking this before attempting to send.
638    #[must_use]
639    pub fn is_disabled(&self) -> bool {
640        self.transport.is_disabled()
641    }
642
643    /// Send mail immediately.
644    ///
645    /// # Errors
646    ///
647    /// Returns an error from the selected transport.
648    pub async fn send(&self, mail: Mail) -> Result<(), MailError> {
649        self.transport
650            .send(mail.with_defaults(&self.defaults))
651            .await
652    }
653
654    /// Queue mail for later delivery.
655    ///
656    /// When called **inside a [`Db::tx`](autumn_web::db::Db::tx) block**, the
657    /// delivery is automatically deferred until the transaction commits. On
658    /// rollback the mail is silently dropped — no orphaned sends.
659    ///
660    /// This deferral is process-local. It prevents mail for rolled-back writes,
661    /// but it does not make the post-commit mail handoff crash-safe unless the
662    /// configured [`MailDeliveryQueue`] records a durable outbox/queue entry.
663    ///
664    /// When called outside any active transaction the behaviour is unchanged:
665    /// the mail is dispatched in a background Tokio task immediately.
666    ///
667    /// Use [`deliver_later_eager`](Self::deliver_later_eager) when you need the
668    /// mail to fire regardless of whether the surrounding transaction commits
669    /// (e.g. security alerts that must go out on any code path).
670    pub fn deliver_later(&self, mail: Mail) {
671        if let Err(error) = self.try_deliver_later(mail) {
672            tracing::error!(error = %error, "background mail delivery was not scheduled");
673        }
674    }
675
676    /// Queue mail for later delivery, **bypassing any active transaction**.
677    ///
678    /// Unlike [`deliver_later`](Self::deliver_later), this method always
679    /// spawns the delivery immediately — it does not check for an active
680    /// `db.tx` block. Use this when the mail must be sent even if the
681    /// surrounding transaction rolls back (e.g. "someone tried to log in"
682    /// security alerts, rate-limit notices).
683    pub fn deliver_later_eager(&self, mail: Mail) {
684        if let Err(error) = self.try_deliver_later_eager(mail) {
685            tracing::error!(error = %error, "background mail delivery was not scheduled");
686        }
687    }
688
689    /// Queue mail for later delivery, deferring when inside a `db.tx`.
690    ///
691    /// # Errors
692    ///
693    /// Returns an error when no active Tokio runtime is available to host the
694    /// background task.
695    ///
696    /// # Panics
697    ///
698    /// Panics if the internal after-commit registry mutex is poisoned.
699    pub fn try_deliver_later(&self, mail: Mail) -> Result<(), MailError> {
700        if self.transport.is_disabled() {
701            return Ok(());
702        }
703        let mail = mail.with_defaults(&self.defaults);
704
705        // When inside a db.tx, push the spawn as an after-commit callback so
706        // the mail only fires if the transaction commits successfully.
707        #[cfg(feature = "db")]
708        {
709            let mailer = self.clone();
710            let deferred = mail.clone();
711            let mut f_opt: Option<(Self, Mail)> = Some((mailer, deferred));
712            // Capture the caller's span now; the after-commit callback runs in a
713            // fresh task with no request span, so spawn_mail_delivery would see an
714            // empty span and lose trace correlation without this.
715            let deliver_span = tracing::Span::current();
716
717            crate::db::AFTER_COMMIT_REGISTRY
718                .try_with(|registry| {
719                    let (m, m_mail) = f_opt.take().expect("once");
720                    let span = deliver_span.clone();
721                    let boxed: crate::db::CommitCallback = Box::new(move || {
722                        Box::pin(tracing::Instrument::instrument(
723                            async move {
724                                if let Some(queue) = m.delivery_queue.clone() {
725                                    queue.enqueue(m_mail).await.map_err(|e| {
726                                        crate::AutumnError::internal_server_error_msg(e.to_string())
727                                    })
728                                } else {
729                                    m.spawn_mail_delivery(m_mail).map_err(|e| {
730                                        crate::AutumnError::internal_server_error_msg(e.to_string())
731                                    })
732                                }
733                            },
734                            span,
735                        ))
736                    });
737                    registry.lock().expect("registry lock").push(boxed);
738                })
739                .ok();
740
741            if f_opt.is_none() {
742                // Successfully registered for after-commit; skip the eager spawn.
743                return Ok(());
744            }
745        }
746
747        // Outside a transaction (or `db` feature not enabled) — spawn immediately.
748        self.spawn_mail_delivery(mail)
749    }
750
751    /// Queue mail for later delivery, always spawning immediately.
752    ///
753    /// # Errors
754    ///
755    /// Returns an error when no active Tokio runtime is available.
756    pub fn try_deliver_later_eager(&self, mail: Mail) -> Result<(), MailError> {
757        if self.transport.is_disabled() {
758            return Ok(());
759        }
760        let mail = mail.with_defaults(&self.defaults);
761        self.spawn_mail_delivery(mail)
762    }
763
764    fn spawn_mail_delivery(&self, mail: Mail) -> Result<(), MailError> {
765        // Honor the disabled-transport contract: if the operator turned mail off
766        // for this profile, deliver_later must drop the message just like
767        // immediate `send` does — even when a queue is attached.
768        let handle = tokio::runtime::Handle::try_current().map_err(|_| {
769            MailError::RuntimeUnavailable(
770                "deliver_later requires an active Tokio runtime".to_owned(),
771            )
772        })?;
773        let parent_span = tracing::Span::current();
774        if let Some(queue) = self.delivery_queue.clone() {
775            handle.spawn(tracing::Instrument::instrument(
776                async move {
777                    if let Err(error) = queue.enqueue(mail).await {
778                        tracing::error!(error = %error, "durable mail enqueue failed");
779                    }
780                },
781                parent_span,
782            ));
783        } else {
784            let mailer = self.clone();
785            handle.spawn(tracing::Instrument::instrument(
786                async move {
787                    if let Err(error) = mailer.send(mail).await {
788                        tracing::error!(error = %error, "background mail delivery failed");
789                    }
790                },
791                parent_span,
792            ));
793        }
794        Ok(())
795    }
796}
797
798impl FromRequestParts<AppState> for Mailer {
799    type Rejection = AutumnError;
800
801    async fn from_request_parts(
802        _parts: &mut http::request::Parts,
803        state: &AppState,
804    ) -> Result<Self, Self::Rejection> {
805        state
806            .extension::<Self>()
807            .as_deref()
808            .cloned()
809            .ok_or_else(|| AutumnError::service_unavailable_msg("Mailer is not configured"))
810    }
811}
812
813/// Builder for [`Mailer`].
814#[derive(Clone)]
815pub struct MailerBuilder {
816    transport: Transport,
817    from: Option<String>,
818    reply_to: Option<String>,
819    file_dir: PathBuf,
820    smtp: Option<SmtpConfig>,
821    delivery_queue: Option<Arc<dyn MailDeliveryQueue>>,
822    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
823}
824
825impl Default for MailerBuilder {
826    fn default() -> Self {
827        Self {
828            transport: Transport::Log,
829            from: None,
830            reply_to: None,
831            file_dir: default_file_dir(),
832            smtp: None,
833            delivery_queue: None,
834            resilience_config: None,
835        }
836    }
837}
838
839impl MailerBuilder {
840    /// Select the transport.
841    #[must_use]
842    pub const fn transport(mut self, transport: Transport) -> Self {
843        self.transport = transport;
844        self
845    }
846
847    /// Set default From header.
848    #[must_use]
849    pub fn from(mut self, from: impl Into<String>) -> Self {
850        self.from = Some(from.into());
851        self
852    }
853
854    /// Set default Reply-To header.
855    #[must_use]
856    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
857        self.reply_to = Some(reply_to.into());
858        self
859    }
860
861    /// Set file output directory.
862    #[must_use]
863    pub fn file_dir(mut self, dir: impl AsRef<Path>) -> Self {
864        self.file_dir = dir.as_ref().to_path_buf();
865        self
866    }
867
868    /// Set SMTP config.
869    #[must_use]
870    pub fn smtp(mut self, smtp: SmtpConfig) -> Self {
871        self.smtp = Some(smtp);
872        self
873    }
874
875    /// Attach a durable [`MailDeliveryQueue`] used by
876    /// [`Mailer::deliver_later`].
877    #[must_use]
878    pub fn delivery_queue(mut self, queue: impl MailDeliveryQueue + 'static) -> Self {
879        self.delivery_queue = Some(Arc::new(queue));
880        self
881    }
882
883    /// Attach an already-shared durable [`MailDeliveryQueue`].
884    #[must_use]
885    pub fn delivery_queue_arc(mut self, queue: Arc<dyn MailDeliveryQueue>) -> Self {
886        self.delivery_queue = Some(queue);
887        self
888    }
889
890    #[must_use]
891    pub fn resilience_config(mut self, rc: Option<Arc<crate::config::ResilienceConfig>>) -> Self {
892        self.resilience_config = rc;
893        self
894    }
895
896    /// Build the mailer.
897    ///
898    /// # Errors
899    ///
900    /// Returns an error when the SMTP transport or default addresses cannot be configured.
901    pub fn build(self) -> Result<Mailer, MailError> {
902        if let Some(from) = &self.from {
903            parse_mailbox(from)?;
904        }
905        if let Some(reply_to) = &self.reply_to {
906            parse_mailbox(reply_to)?;
907        }
908
909        let transport: Arc<dyn MailTransport> = match self.transport {
910            Transport::Log => Arc::new(LogTransport),
911            Transport::File => Arc::new(FileTransport { dir: self.file_dir }),
912            Transport::Disabled => Arc::new(DisabledTransport),
913            Transport::Smtp => Arc::new(SmtpTransport::new(
914                self.smtp.unwrap_or_default(),
915                self.resilience_config.clone(),
916            )?),
917        };
918
919        Ok(Mailer {
920            defaults: Arc::new(MailerDefaults {
921                from: self.from,
922                reply_to: self.reply_to,
923            }),
924            transport,
925            delivery_queue: self.delivery_queue,
926        })
927    }
928}
929
930struct DisabledTransport;
931
932impl MailTransport for DisabledTransport {
933    fn send<'a>(
934        &'a self,
935        _mail: Mail,
936    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
937        Box::pin(async { Ok(()) })
938    }
939
940    fn is_disabled(&self) -> bool {
941        true
942    }
943}
944
945struct LogTransport;
946
947impl MailTransport for LogTransport {
948    fn send<'a>(
949        &'a self,
950        mail: Mail,
951    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
952        Box::pin(async move {
953            tracing::info!(
954                from = ?mail.from,
955                reply_to = ?mail.reply_to,
956                to = ?mail.to,
957                subject = %mail.subject,
958                text = ?mail.text,
959                html = ?mail.html,
960                "mail captured by log transport"
961            );
962            Ok(())
963        })
964    }
965}
966
967struct FileTransport {
968    dir: PathBuf,
969}
970
971static FILE_TRANSPORT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
972
973impl MailTransport for FileTransport {
974    fn send<'a>(
975        &'a self,
976        mail: Mail,
977    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
978        Box::pin(async move {
979            tokio::fs::create_dir_all(&self.dir).await?;
980            let filename = file_transport_filename(&mail);
981            let path = self.dir.join(filename);
982            let mut file = tokio::fs::OpenOptions::new()
983                .write(true)
984                .create_new(true)
985                .open(path)
986                .await?;
987            let eml = render_eml(&mail);
988            tokio::io::AsyncWriteExt::write_all(&mut file, eml.as_bytes()).await?;
989            tokio::io::AsyncWriteExt::flush(&mut file).await?;
990            file.sync_all().await?;
991            Ok(())
992        })
993    }
994}
995
996struct SmtpTransport {
997    inner: AsyncSmtpTransport<Tokio1Executor>,
998    resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
999}
1000
1001impl SmtpTransport {
1002    fn new(
1003        config: SmtpConfig,
1004        resilience_config: Option<Arc<crate::config::ResilienceConfig>>,
1005    ) -> Result<Self, MailError> {
1006        let host = config
1007            .host
1008            .filter(|host| !host.trim().is_empty())
1009            .ok_or_else(|| MailError::InvalidMessage("mail.smtp.host is required".to_owned()))?;
1010        let mut builder = match config.tls {
1011            TlsMode::Disabled => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&host),
1012            TlsMode::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&host)?,
1013            TlsMode::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)?,
1014        };
1015        if let Some(port) = config.port {
1016            builder = builder.port(port);
1017        }
1018        if let Some(username) = config.username {
1019            let password_env = config.password_env.ok_or_else(|| {
1020                MailError::InvalidMessage(
1021                    "mail.smtp.password_env is required when mail.smtp.username is set".to_owned(),
1022                )
1023            })?;
1024            let password = std::env::var(&password_env).map_err(|error| {
1025                MailError::InvalidMessage(format!(
1026                    "mail.smtp.password_env={password_env:?} could not be resolved: {error}"
1027                ))
1028            })?;
1029            builder = builder.credentials(Credentials::new(username, password));
1030        }
1031        Ok(Self {
1032            inner: builder.build(),
1033            resilience_config,
1034        })
1035    }
1036}
1037
1038impl MailTransport for SmtpTransport {
1039    fn send<'a>(
1040        &'a self,
1041        mail: Mail,
1042    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1043        Box::pin(async move {
1044            let breaker = self.resilience_config.as_ref().map_or_else(
1045                || {
1046                    crate::circuit_breaker::global_registry().get_or_create(
1047                        "smtp_mailer",
1048                        crate::circuit_breaker::CircuitBreakerPolicy::default(),
1049                    )
1050                },
1051                |rc| {
1052                    let policy = crate::circuit_breaker::CircuitBreakerPolicy::from_config(
1053                        rc,
1054                        "smtp_mailer",
1055                    );
1056                    crate::circuit_breaker::global_registry()
1057                        .get_or_create_with_config("smtp_mailer", policy)
1058                },
1059            );
1060
1061            if breaker.before_call().is_err() {
1062                return Err(MailError::RuntimeUnavailable(
1063                    "smtp mailer circuit breaker is open".to_owned(),
1064                ));
1065            }
1066            let guard = crate::circuit_breaker::CircuitBreakerGuard::new(breaker.clone());
1067
1068            let message = lettre_message(&mail)?;
1069            let res = self.inner.send(message).await;
1070            if res.is_ok() {
1071                guard.success();
1072            } else {
1073                guard.failure();
1074            }
1075
1076            res.map(|_| ()).map_err(Into::into)
1077        })
1078    }
1079}
1080
1081fn sanitize_filename(value: &str) -> String {
1082    value
1083        .chars()
1084        .map(|ch| {
1085            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
1086                ch
1087            } else {
1088                '_'
1089            }
1090        })
1091        .collect()
1092}
1093
1094fn file_transport_filename(mail: &Mail) -> String {
1095    let sequence = FILE_TRANSPORT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1096    format!(
1097        "{}-{}-{:016x}-{}.eml",
1098        chrono::Utc::now().format("%Y%m%d%H%M%S%6f"),
1099        std::process::id(),
1100        sequence,
1101        sanitize_filename(mail.to.first().map_or("unknown", String::as_str))
1102    )
1103}
1104
1105fn render_eml(mail: &Mail) -> String {
1106    let mut out = String::new();
1107    if let Some(from) = &mail.from {
1108        out.push_str("From: ");
1109        out.push_str(from);
1110        out.push('\n');
1111    }
1112    for to in &mail.to {
1113        out.push_str("To: ");
1114        out.push_str(to);
1115        out.push('\n');
1116    }
1117    if let Some(reply_to) = &mail.reply_to {
1118        out.push_str("Reply-To: ");
1119        out.push_str(reply_to);
1120        out.push('\n');
1121    }
1122    out.push_str("Date: ");
1123    out.push_str(&chrono::Utc::now().to_rfc2822());
1124    out.push('\n');
1125    out.push_str("Message-Id: <");
1126    out.push_str(&uuid::Uuid::new_v4().to_string());
1127    out.push_str("@autumn.local>\n");
1128    out.push_str("Subject: ");
1129    out.push_str(&mail.subject);
1130    out.push_str("\nMIME-Version: 1.0\n");
1131    if mail.html.is_some() && mail.text.is_some() {
1132        out.push_str("Content-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n");
1133        if let Some(text) = &mail.text {
1134            out.push_str("--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\n");
1135            out.push_str(text);
1136            out.push('\n');
1137        }
1138        if let Some(html) = &mail.html {
1139            out.push_str("--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n");
1140            out.push_str(html);
1141            out.push('\n');
1142        }
1143        out.push_str("--autumn-mail--\n");
1144    } else if let Some(html) = &mail.html {
1145        out.push_str("Content-Type: text/html; charset=utf-8\n\n");
1146        out.push_str(html);
1147        out.push('\n');
1148    } else if let Some(text) = &mail.text {
1149        out.push_str("Content-Type: text/plain; charset=utf-8\n\n");
1150        out.push_str(text);
1151        out.push('\n');
1152    }
1153    out
1154}
1155
1156#[derive(Debug, Clone)]
1157struct ParsedMail {
1158    headers: Vec<(String, String)>,
1159    to: Vec<String>,
1160    subject: String,
1161    date: Option<String>,
1162    html: Option<String>,
1163    text: Option<String>,
1164    raw: String,
1165}
1166
1167impl ParsedMail {
1168    fn header_value(&self, name: &str) -> Option<&str> {
1169        self.headers
1170            .iter()
1171            .find(|(header, _)| header.eq_ignore_ascii_case(name))
1172            .map(|(_, value)| value.as_str())
1173    }
1174}
1175
1176#[derive(Debug, Clone)]
1177struct CapturedMailSummary {
1178    id: String,
1179    to: Vec<String>,
1180    subject: String,
1181    timestamp: String,
1182    modified: SystemTime,
1183}
1184
1185pub(crate) fn mail_preview_router<S>(file_dir: PathBuf) -> axum::Router<S>
1186where
1187    S: Clone + Send + Sync + 'static,
1188    AppState: axum::extract::FromRef<S>,
1189{
1190    let file_dir = Arc::new(file_dir);
1191    axum::Router::new()
1192        .route(
1193            MAIL_PREVIEW_PATH,
1194            axum::routing::get({
1195                let file_dir = Arc::clone(&file_dir);
1196                move |axum::extract::State(state): axum::extract::State<AppState>| {
1197                    let file_dir = Arc::clone(&file_dir);
1198                    async move { list_mail_preview(file_dir, state).await }
1199                }
1200            }),
1201        )
1202        .route(
1203            MAIL_PREVIEW_MESSAGE_PATH,
1204            axum::routing::get({
1205                let file_dir = Arc::clone(&file_dir);
1206                move |axum::extract::Path(message_id): axum::extract::Path<String>| {
1207                    let file_dir = Arc::clone(&file_dir);
1208                    async move { show_captured_mail(file_dir, message_id).await }
1209                }
1210            }),
1211        )
1212        .route(
1213            MAIL_PREVIEW_TEMPLATE_PATH,
1214            axum::routing::get(
1215                |axum::extract::Path((mailer, method)): axum::extract::Path<(String, String)>,
1216                 axum::extract::State(state): axum::extract::State<AppState>| async move {
1217                    show_template_preview(&state, &mailer, &method)
1218                },
1219            ),
1220        )
1221}
1222
1223async fn list_mail_preview(file_dir: Arc<PathBuf>, state: AppState) -> Response {
1224    match captured_messages(&file_dir).await {
1225        Ok(messages) => {
1226            let previews = state
1227                .extension::<MailPreviewRegistry>()
1228                .map(|registry| registry.previews().to_vec())
1229                .unwrap_or_default();
1230            html_response(render_mail_index(&messages, &previews, &file_dir))
1231        }
1232        Err(error) => preview_error_response(&error),
1233    }
1234}
1235
1236async fn show_captured_mail(file_dir: Arc<PathBuf>, message_id: String) -> Response {
1237    match read_captured_message(&file_dir, &message_id).await {
1238        Ok(parsed) => html_response(render_mail_detail(&parsed, "Captured message")),
1239        Err(error) => preview_error_response(&error),
1240    }
1241}
1242
1243fn show_template_preview(state: &AppState, mailer: &str, method: &str) -> Response {
1244    let preview = state
1245        .extension::<MailPreviewRegistry>()
1246        .and_then(|registry| registry.find(mailer, method));
1247    let Some(preview) = preview else {
1248        return preview_error_response(&MailPreviewError::NotFound(format!("{mailer}/{method}")));
1249    };
1250
1251    match preview.render() {
1252        Ok(mail) => {
1253            let raw = render_eml(&mail);
1254            let parsed = parse_eml(&raw);
1255            html_response(render_mail_detail(&parsed, "Template preview"))
1256        }
1257        Err(error) => preview_error_response(&error),
1258    }
1259}
1260
1261async fn captured_messages(dir: &Path) -> Result<Vec<CapturedMailSummary>, MailPreviewError> {
1262    let mut entries = match tokio::fs::read_dir(dir).await {
1263        Ok(entries) => entries,
1264        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1265        Err(error) => return Err(error.into()),
1266    };
1267
1268    let mut messages = Vec::new();
1269    while let Some(entry) = entries.next_entry().await? {
1270        let path = entry.path();
1271        if !path
1272            .extension()
1273            .and_then(|ext| ext.to_str())
1274            .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1275        {
1276            continue;
1277        }
1278        let Some(id) = path.file_name().and_then(|name| name.to_str()) else {
1279            continue;
1280        };
1281        let metadata = entry.metadata().await?;
1282        let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
1283        let raw = tokio::fs::read_to_string(&path).await?;
1284        let parsed = parse_eml(&raw);
1285        messages.push(CapturedMailSummary {
1286            id: id.to_owned(),
1287            to: parsed.to,
1288            subject: parsed.subject,
1289            timestamp: parsed.date.unwrap_or_else(|| format_system_time(modified)),
1290            modified,
1291        });
1292    }
1293
1294    messages.sort_by(|left, right| {
1295        right
1296            .modified
1297            .cmp(&left.modified)
1298            .then_with(|| right.id.cmp(&left.id))
1299    });
1300    Ok(messages)
1301}
1302
1303async fn read_captured_message(
1304    dir: &Path,
1305    message_id: &str,
1306) -> Result<ParsedMail, MailPreviewError> {
1307    if !valid_message_id(message_id) {
1308        return Err(MailPreviewError::InvalidMessageId(message_id.to_owned()));
1309    }
1310    let path = dir.join(message_id);
1311    let raw = match tokio::fs::read_to_string(&path).await {
1312        Ok(raw) => raw,
1313        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1314            return Err(MailPreviewError::NotFound(message_id.to_owned()));
1315        }
1316        Err(error) => return Err(error.into()),
1317    };
1318    Ok(parse_eml(&raw))
1319}
1320
1321fn valid_message_id(message_id: &str) -> bool {
1322    !message_id.is_empty()
1323        && Path::new(message_id)
1324            .extension()
1325            .and_then(|ext| ext.to_str())
1326            .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1327        && !message_id.contains('/')
1328        && !message_id.contains('\\')
1329        && !message_id.contains("..")
1330}
1331
1332fn parse_eml(raw: &str) -> ParsedMail {
1333    let normalized = raw.replace("\r\n", "\n");
1334    let (headers, body) = split_headers_body(&normalized);
1335    let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
1336    let (html, text) = parse_mail_body(&content_type, body);
1337    let to = header_values(&headers, "To");
1338    let subject = header_value(&headers, "Subject").unwrap_or_else(|| "(no subject)".to_owned());
1339    let date = header_value(&headers, "Date");
1340
1341    ParsedMail {
1342        headers,
1343        to,
1344        subject,
1345        date,
1346        html,
1347        text,
1348        raw: raw.to_owned(),
1349    }
1350}
1351
1352fn split_headers_body(raw: &str) -> (Vec<(String, String)>, &str) {
1353    let Some((header_block, body)) = raw.split_once("\n\n") else {
1354        return (parse_header_block(raw), "");
1355    };
1356    (parse_header_block(header_block), body)
1357}
1358
1359fn parse_header_block(header_block: &str) -> Vec<(String, String)> {
1360    let mut headers = Vec::new();
1361    let mut current: Option<(String, String)> = None;
1362
1363    for line in header_block.lines() {
1364        if line.starts_with(' ') || line.starts_with('\t') {
1365            if let Some((_, value)) = current.as_mut() {
1366                value.push(' ');
1367                value.push_str(line.trim());
1368            }
1369            continue;
1370        }
1371        if let Some(header) = current.take() {
1372            headers.push(header);
1373        }
1374        if let Some((name, value)) = line.split_once(':') {
1375            current = Some((name.trim().to_owned(), value.trim().to_owned()));
1376        }
1377    }
1378    if let Some(header) = current {
1379        headers.push(header);
1380    }
1381    headers
1382}
1383
1384fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
1385    headers
1386        .iter()
1387        .find(|(header, _)| header.eq_ignore_ascii_case(name))
1388        .map(|(_, value)| value.clone())
1389}
1390
1391fn header_values(headers: &[(String, String)], name: &str) -> Vec<String> {
1392    headers
1393        .iter()
1394        .filter(|(header, _)| header.eq_ignore_ascii_case(name))
1395        .map(|(_, value)| value.clone())
1396        .collect()
1397}
1398
1399fn parse_mail_body(content_type: &str, body: &str) -> (Option<String>, Option<String>) {
1400    if content_type
1401        .to_ascii_lowercase()
1402        .contains("multipart/alternative")
1403        && let Some(boundary) = content_type_boundary(content_type)
1404    {
1405        return parse_multipart_alternative(body, &boundary);
1406    }
1407
1408    if content_type.to_ascii_lowercase().contains("text/html") {
1409        (Some(trim_body(body)), None)
1410    } else {
1411        (None, Some(trim_body(body)))
1412    }
1413}
1414
1415fn parse_multipart_alternative(body: &str, boundary: &str) -> (Option<String>, Option<String>) {
1416    let marker = format!("--{boundary}");
1417    let mut html = None;
1418    let mut text = None;
1419
1420    for segment in body.split(&marker).skip(1) {
1421        let segment = segment.trim_start_matches(['\n', '\r']);
1422        if segment.starts_with("--") {
1423            break;
1424        }
1425        let (headers, part_body) = split_headers_body(segment);
1426        let content_type = header_value(&headers, "Content-Type").unwrap_or_default();
1427        if content_type.to_ascii_lowercase().contains("text/html") {
1428            html = Some(trim_body(part_body));
1429        } else if content_type.to_ascii_lowercase().contains("text/plain") {
1430            text = Some(trim_body(part_body));
1431        }
1432    }
1433
1434    (html, text)
1435}
1436
1437fn content_type_boundary(content_type: &str) -> Option<String> {
1438    content_type.split(';').find_map(|part| {
1439        let part = part.trim();
1440        let (name, value) = part.split_once('=')?;
1441        if !name.trim().eq_ignore_ascii_case("boundary") {
1442            return None;
1443        }
1444        Some(value.trim().trim_matches('"').to_owned())
1445    })
1446}
1447
1448fn trim_body(body: &str) -> String {
1449    body.trim_matches(['\r', '\n']).to_owned()
1450}
1451
1452fn format_system_time(time: SystemTime) -> String {
1453    let datetime: chrono::DateTime<chrono::Utc> = time.into();
1454    datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
1455}
1456
1457fn render_mail_index(
1458    messages: &[CapturedMailSummary],
1459    previews: &[MailPreview],
1460    file_dir: &Path,
1461) -> String {
1462    let mut body = String::new();
1463    body.push_str("<h1>Autumn Mail</h1>");
1464    body.push_str("<section><h2>Captured messages</h2>");
1465    if messages.is_empty() {
1466        body.push_str("<p class=\"empty\">No captured emails yet. Set <code>mail.transport = &quot;file&quot;</code>, send an email, then refresh this page. Autumn reads <code>");
1467        body.push_str(&escape_html(&file_dir.display().to_string()));
1468        body.push_str("</code>.</p>");
1469    } else {
1470        body.push_str(
1471            "<table><thead><tr><th>Timestamp</th><th>To</th><th>Subject</th></tr></thead><tbody>",
1472        );
1473        for message in messages {
1474            body.push_str("<tr><td>");
1475            body.push_str(&escape_html(&message.timestamp));
1476            body.push_str("</td><td>");
1477            body.push_str(&escape_html(&message.to.join(", ")));
1478            body.push_str("</td><td><a href=\"");
1479            body.push_str(MAIL_PREVIEW_PATH);
1480            body.push_str("/messages/");
1481            body.push_str(&escape_html(&message.id));
1482            body.push_str("\">");
1483            body.push_str(&escape_html(&message.subject));
1484            body.push_str("</a></td></tr>");
1485        }
1486        body.push_str("</tbody></table>");
1487    }
1488    body.push_str("</section><section><h2>Template previews</h2>");
1489    if previews.is_empty() {
1490        body.push_str("<p class=\"empty\">No mailer previews registered.</p>");
1491    } else {
1492        body.push_str("<table><thead><tr><th>Mailer</th><th>Preview</th></tr></thead><tbody>");
1493        for preview in previews {
1494            body.push_str("<tr><td>");
1495            body.push_str(&escape_html(preview.mailer()));
1496            body.push_str("</td><td><a href=\"");
1497            body.push_str(MAIL_PREVIEW_PATH);
1498            body.push_str("/previews/");
1499            body.push_str(&escape_html(preview.mailer()));
1500            body.push('/');
1501            body.push_str(&escape_html(preview.method()));
1502            body.push_str("\">");
1503            body.push_str(&escape_html(preview.method()));
1504            body.push_str("</a></td></tr>");
1505        }
1506        body.push_str("</tbody></table>");
1507    }
1508    body.push_str("</section>");
1509    render_mail_preview_layout("Autumn Mail", &body)
1510}
1511
1512fn render_mail_detail(parsed: &ParsedMail, label: &str) -> String {
1513    let mut body = String::new();
1514    body.push_str("<p><a href=\"");
1515    body.push_str(MAIL_PREVIEW_PATH);
1516    body.push_str("\">Back to mail</a></p><h1>");
1517    body.push_str(&escape_html(&parsed.subject));
1518    body.push_str("</h1><p class=\"muted\">");
1519    body.push_str(&escape_html(label));
1520    body.push_str("</p>");
1521
1522    if let Some(html) = &parsed.html {
1523        body.push_str("<iframe title=\"Rendered HTML email\" sandbox srcdoc=\"");
1524        body.push_str(&escape_html(html));
1525        body.push_str("\"></iframe>");
1526    } else {
1527        body.push_str("<p class=\"empty\">No HTML body was found for this email.</p>");
1528    }
1529
1530    body.push_str("<details><summary>Plain text</summary><pre>");
1531    body.push_str(&escape_html(parsed.text.as_deref().unwrap_or("")));
1532    body.push_str("</pre></details>");
1533
1534    body.push_str("<details><summary>Headers</summary><dl>");
1535    for header in ["From", "To", "Reply-To", "Subject", "Date", "Message-Id"] {
1536        if let Some(value) = parsed.header_value(header) {
1537            body.push_str("<dt>");
1538            body.push_str(header);
1539            body.push_str("</dt><dd>");
1540            body.push_str(&escape_html(value));
1541            body.push_str("</dd>");
1542        }
1543    }
1544    body.push_str("</dl></details>");
1545
1546    body.push_str("<details><summary>Raw .eml</summary><pre>");
1547    body.push_str(&escape_html(&parsed.raw));
1548    body.push_str("</pre></details>");
1549
1550    render_mail_preview_layout(&parsed.subject, &body)
1551}
1552
1553fn render_mail_preview_layout(title: &str, body: &str) -> String {
1554    format!(
1555        "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>{}</style></head><body>{}</body></html>",
1556        escape_html(title),
1557        MAIL_PREVIEW_CSS,
1558        body
1559    )
1560}
1561
1562const MAIL_PREVIEW_CSS: &str = r#"
1563body{margin:0;padding:24px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2933;background:#f6f8fa}
1564h1{margin:0 0 16px;font-size:28px}
1565h2{margin:28px 0 12px;font-size:18px}
1566table{width:100%;border-collapse:collapse;background:white;border:1px solid #d9e2ec}
1567th,td{padding:10px 12px;border-bottom:1px solid #e5eaf0;text-align:left;font-size:14px;vertical-align:top}
1568th{background:#edf2f7;color:#394b59;font-weight:650}
1569a{color:#0b63ce;text-decoration:none}
1570a:hover{text-decoration:underline}
1571.empty,.muted{color:#52616f}
1572code,pre{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
1573pre{white-space:pre-wrap;background:#111827;color:#f8fafc;padding:12px;overflow:auto}
1574iframe{width:100%;min-height:420px;border:1px solid #cbd5e1;background:white}
1575details{margin-top:14px;background:white;border:1px solid #d9e2ec;padding:10px 12px}
1576summary{cursor:pointer;font-weight:650}
1577dt{font-weight:650;margin-top:8px}
1578dd{margin:2px 0 8px}
1579"#;
1580
1581fn html_response(html: String) -> Response {
1582    Html(html).into_response()
1583}
1584
1585fn preview_error_response(error: &MailPreviewError) -> Response {
1586    let status = match error {
1587        MailPreviewError::NotFound(_) | MailPreviewError::InvalidMessageId(_) => {
1588            http::StatusCode::NOT_FOUND
1589        }
1590        MailPreviewError::Io(_) | MailPreviewError::PreviewPanicked { .. } => {
1591            http::StatusCode::INTERNAL_SERVER_ERROR
1592        }
1593    };
1594    (
1595        status,
1596        Html(render_mail_preview_layout(
1597            "Mail preview error",
1598            &format!(
1599                "<h1>Mail preview error</h1><p>{}</p>",
1600                escape_html(&error.to_string())
1601            ),
1602        )),
1603    )
1604        .into_response()
1605}
1606
1607fn escape_html(value: &str) -> String {
1608    let mut escaped = String::with_capacity(value.len());
1609    for ch in value.chars() {
1610        match ch {
1611            '&' => escaped.push_str("&amp;"),
1612            '<' => escaped.push_str("&lt;"),
1613            '>' => escaped.push_str("&gt;"),
1614            '"' => escaped.push_str("&quot;"),
1615            '\'' => escaped.push_str("&#39;"),
1616            _ => escaped.push(ch),
1617        }
1618    }
1619    escaped
1620}
1621
1622fn parse_mailbox(address: &str) -> Result<Mailbox, MailError> {
1623    address.parse().map_err(|source| MailError::InvalidAddress {
1624        address: address.to_owned(),
1625        source,
1626    })
1627}
1628
1629fn lettre_message(mail: &Mail) -> Result<Message, MailError> {
1630    let from = mail
1631        .from
1632        .as_deref()
1633        .ok_or_else(|| MailError::InvalidMessage("mail from address is required".to_owned()))?;
1634    let mut builder = Message::builder().from(parse_mailbox(from)?);
1635    for to in &mail.to {
1636        builder = builder.to(parse_mailbox(to)?);
1637    }
1638    if let Some(reply_to) = &mail.reply_to {
1639        builder = builder.reply_to(parse_mailbox(reply_to)?);
1640    }
1641    builder = builder.subject(mail.subject.clone());
1642
1643    match (&mail.text, &mail.html) {
1644        (Some(text), Some(html)) => Ok(builder.multipart(
1645            MultiPart::alternative()
1646                .singlepart(SinglePart::plain(text.clone()))
1647                .singlepart(SinglePart::html(html.clone())),
1648        )?),
1649        (Some(text), None) => Ok(builder.singlepart(SinglePart::plain(text.clone()))?),
1650        (None, Some(html)) => Ok(builder.singlepart(SinglePart::html(html.clone()))?),
1651        (None, None) => Err(MailError::InvalidMessage(
1652            "mail must include html or text body".to_owned(),
1653        )),
1654    }
1655}
1656
1657struct InterceptedMailTransport {
1658    inner: Arc<dyn MailTransport>,
1659    interceptor: Arc<dyn crate::interceptor::MailInterceptor>,
1660}
1661
1662impl MailTransport for InterceptedMailTransport {
1663    fn send<'a>(
1664        &'a self,
1665        mail: Mail,
1666    ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
1667        Box::pin(async move {
1668            let inner = Arc::clone(&self.inner);
1669            let mail_for_next = mail.clone();
1670            let next = Box::pin(async move { inner.send(mail_for_next).await });
1671            self.interceptor.intercept(&mail, next).await
1672        })
1673    }
1674
1675    fn is_disabled(&self) -> bool {
1676        self.inner.is_disabled()
1677    }
1678}
1679
1680/// Install the configured mailer into app state.
1681///
1682/// Picks up a runtime-installed [`MailDeliveryQueueHandle`] from
1683/// [`AppState`] extensions when present, so plugins (Harvest, Redis-backed,
1684/// etc.) can register durable delivery before this runs. In `prod` with a
1685/// non-`Disabled` transport, startup fails when neither a durable queue nor
1686/// [`MailConfig::allow_in_process_deliver_later_in_production`] is set, unless
1687/// `enforce_durable_guard` is `false` (used by short-lived contexts like
1688/// static-site builds where `deliver_later` semantics don't apply).
1689///
1690/// # Errors
1691///
1692/// Returns an Autumn error when the configured transport cannot be created or
1693/// when the production `deliver_later` guard is not satisfied.
1694pub(crate) fn install_mailer(
1695    state: &AppState,
1696    config: &MailConfig,
1697    enforce_durable_guard: bool,
1698) -> AutumnResult<()> {
1699    let resilience = state
1700        .extension::<crate::config::AutumnConfig>()
1701        .map(|c| Arc::new(c.resilience.clone()));
1702    let mut mailer =
1703        Mailer::from_config_inner(config, resilience).map_err(AutumnError::service_unavailable)?;
1704
1705    if let Some(interceptor) = state.extension::<Arc<dyn crate::interceptor::MailInterceptor>>() {
1706        mailer.transport = Arc::new(InterceptedMailTransport {
1707            inner: Arc::clone(&mailer.transport),
1708            interceptor: (*interceptor).clone(),
1709        });
1710    }
1711
1712    let in_production = matches!(state.profile(), "prod" | "production");
1713    let transport_sends_mail = config.transport != Transport::Disabled;
1714
1715    // Honor the disabled transport contract: if the operator turned mail off
1716    // for this profile (tests, review apps, etc.), `deliver_later` must also
1717    // be a no-op — even when a durable queue was registered globally.
1718    if transport_sends_mail {
1719        let queue_handle = state.extension::<MailDeliveryQueueHandle>();
1720        if let Some(handle) = queue_handle.as_ref() {
1721            mailer.delivery_queue = Some(Arc::clone(handle.inner()));
1722        }
1723    }
1724
1725    if enforce_durable_guard && in_production && transport_sends_mail {
1726        let has_durable_queue = mailer.delivery_queue.is_some();
1727        if !has_durable_queue && !config.allow_in_process_deliver_later_in_production {
1728            return Err(AutumnError::service_unavailable_msg(
1729                "mail.deliver_later has no durable backend in prod: register a MailDeliveryQueueHandle on AppState or set mail.allow_in_process_deliver_later_in_production = true to opt into the in-process Tokio fallback",
1730            ));
1731        }
1732        if !has_durable_queue {
1733            tracing::warn!(
1734                "mail.deliver_later is using the in-process Tokio fallback in prod; this is acknowledged via mail.allow_in_process_deliver_later_in_production but is not durable across restarts or replicas"
1735            );
1736        }
1737    }
1738
1739    state.insert_extension(mailer);
1740    Ok(())
1741}
1742
1743/// Run the optional [`MailDeliveryQueue`] factory and install the configured
1744/// mailer.
1745///
1746/// Centralizes the wiring used by every [`AppBuilder`](crate::app::AppBuilder)
1747/// build path: optionally invoke `queue_factory` against the live `AppState`,
1748/// register the resulting [`MailDeliveryQueueHandle`], then call
1749/// [`install_mailer`]. The factory is skipped entirely when
1750/// `enforce_durable_guard` is `false` (static-site builds), since the queue
1751/// may capture infrastructure (Redis, Harvest, etc.) that isn't available in
1752/// the asset-build environment.
1753///
1754/// # Errors
1755///
1756/// Propagates errors from the queue factory and from [`install_mailer`].
1757pub(crate) fn install_mailer_with_factory<F>(
1758    state: &AppState,
1759    config: &MailConfig,
1760    queue_factory: Option<F>,
1761    enforce_durable_guard: bool,
1762) -> AutumnResult<()>
1763where
1764    F: FnOnce(&AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>,
1765{
1766    // Honor the disabled transport contract: a profile that turned mail off
1767    // (tests, review apps, etc.) must not open queue infrastructure either,
1768    // since all sends — immediate and deferred — are supposed to be no-ops.
1769    let transport_sends_mail = config.transport != Transport::Disabled;
1770    if enforce_durable_guard
1771        && transport_sends_mail
1772        && let Some(factory) = queue_factory
1773    {
1774        let queue = factory(state)?;
1775        state.insert_extension(MailDeliveryQueueHandle::from_arc(queue));
1776    }
1777    install_mailer(state, config, enforce_durable_guard)
1778}
1779
1780#[cfg(test)]
1781mod tests {
1782    use super::*;
1783
1784    #[test]
1785    fn mail_builder_rejects_missing_body() {
1786        let err = Mail::builder()
1787            .to("user@example.com")
1788            .subject("Hello")
1789            .build()
1790            .expect_err("body should be required");
1791        assert!(err.to_string().contains("html or text"));
1792    }
1793
1794    #[test]
1795    fn filename_sanitizer_keeps_safe_characters() {
1796        assert_eq!(
1797            sanitize_filename("Ada Lovelace <ada@example.com>"),
1798            "Ada_Lovelace__ada_example.com_"
1799        );
1800    }
1801
1802    #[test]
1803    fn transport_default_is_disabled() {
1804        assert_eq!(Transport::default(), Transport::Disabled);
1805    }
1806
1807    #[test]
1808    fn smtp_config_validation_rejects_whitespace_only_host() {
1809        let config = MailConfig {
1810            transport: Transport::Smtp,
1811            smtp: SmtpConfig {
1812                host: Some("   ".to_owned()),
1813                ..Default::default()
1814            },
1815            ..Default::default()
1816        };
1817
1818        let error = config
1819            .validate(Some("dev"))
1820            .expect_err("whitespace SMTP host should be rejected");
1821
1822        assert!(error.to_string().contains("mail.smtp.host is required"));
1823    }
1824
1825    #[test]
1826    fn transport_env_value_is_trimmed_and_case_insensitive() {
1827        assert_eq!(Transport::from_env_value(" SMTP "), Some(Transport::Smtp));
1828        assert_eq!(Transport::from_env_value(" LoG "), Some(Transport::Log));
1829    }
1830
1831    #[test]
1832    fn tls_mode_env_value_is_trimmed_and_case_insensitive() {
1833        assert_eq!(TlsMode::from_env_value(" TLS "), Some(TlsMode::Tls));
1834        assert_eq!(
1835            TlsMode::from_env_value(" START_TLS "),
1836            Some(TlsMode::StartTls)
1837        );
1838        assert_eq!(
1839            TlsMode::from_env_value(" disabled "),
1840            Some(TlsMode::Disabled)
1841        );
1842    }
1843
1844    #[test]
1845    fn file_transport_filename_is_unique_for_same_recipient() {
1846        let mail = Mail::builder()
1847            .to("Ada Lovelace <ada@example.com>")
1848            .subject("Hello")
1849            .text("body")
1850            .build()
1851            .expect("mail should build");
1852
1853        let first = file_transport_filename(&mail);
1854        let second = file_transport_filename(&mail);
1855
1856        assert_ne!(first, second);
1857        assert!(
1858            Path::new(&first)
1859                .extension()
1860                .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1861        );
1862        assert!(
1863            Path::new(&second)
1864                .extension()
1865                .is_some_and(|ext| ext.eq_ignore_ascii_case("eml"))
1866        );
1867    }
1868
1869    #[test]
1870    fn smtp_transport_rejects_missing_password_env_when_username_is_set() {
1871        let missing_key = format!(
1872            "AUTUMN_TEST_MISSING_SMTP_PASSWORD_{}_{}",
1873            std::process::id(),
1874            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
1875        );
1876        let Err(error) = SmtpTransport::new(
1877            SmtpConfig {
1878                host: Some("smtp.example.com".to_owned()),
1879                port: Some(587),
1880                username: Some("mailer".to_owned()),
1881                password_env: Some(missing_key.clone()),
1882                tls: TlsMode::StartTls,
1883            },
1884            None,
1885        ) else {
1886            panic!("missing password env should fail at startup");
1887        };
1888
1889        assert!(error.to_string().contains(&missing_key));
1890    }
1891
1892    #[test]
1893    fn smtp_transport_rejects_missing_password_env_key_when_username_is_set() {
1894        let Err(error) = SmtpTransport::new(
1895            SmtpConfig {
1896                host: Some("smtp.example.com".to_owned()),
1897                port: Some(587),
1898                username: Some("mailer".to_owned()),
1899                password_env: None,
1900                tls: TlsMode::StartTls,
1901            },
1902            None,
1903        ) else {
1904            panic!("missing password_env setting should fail at startup");
1905        };
1906
1907        assert!(error.to_string().contains("mail.smtp.password_env"));
1908    }
1909
1910    #[test]
1911    fn mailer_builder_rejects_invalid_default_from_address() {
1912        let Err(error) = Mailer::builder().from("not an email address").build() else {
1913            panic!("invalid default from should fail fast");
1914        };
1915
1916        match error {
1917            MailError::InvalidAddress { address, .. } => {
1918                assert_eq!(address, "not an email address");
1919            }
1920            other => panic!("expected invalid address error, got {other:?}"),
1921        }
1922    }
1923
1924    #[test]
1925    fn mailer_from_config_rejects_invalid_default_reply_to_address() {
1926        let config = MailConfig {
1927            transport: Transport::Smtp,
1928            from: Some("Autumn <noreply@example.com>".to_owned()),
1929            reply_to: Some("definitely not an address".to_owned()),
1930            smtp: SmtpConfig {
1931                host: Some("smtp.example.com".to_owned()),
1932                ..Default::default()
1933            },
1934            ..Default::default()
1935        };
1936
1937        let Err(error) = Mailer::from_config(&config) else {
1938            panic!("invalid configured reply-to should fail at construction");
1939        };
1940
1941        match error {
1942            MailError::InvalidAddress { address, .. } => {
1943                assert_eq!(address, "definitely not an address");
1944            }
1945            other => panic!("expected invalid address error, got {other:?}"),
1946        }
1947    }
1948
1949    #[test]
1950    fn try_deliver_later_returns_error_without_runtime() {
1951        let mailer = Mailer::builder().build().expect("mailer should build");
1952        let mail = Mail::builder()
1953            .to("user@example.com")
1954            .subject("Hello")
1955            .text("hello")
1956            .build()
1957            .expect("mail should build");
1958
1959        let error = mailer
1960            .try_deliver_later(mail)
1961            .expect_err("missing runtime should return an error");
1962
1963        assert!(error.to_string().contains("active Tokio runtime"));
1964    }
1965
1966    #[test]
1967    fn deliver_later_does_not_panic_without_runtime() {
1968        let mailer = Mailer::builder().build().expect("mailer should build");
1969        let mail = Mail::builder()
1970            .to("user@example.com")
1971            .subject("Hello")
1972            .text("hello")
1973            .build()
1974            .expect("mail should build");
1975
1976        mailer.deliver_later(mail);
1977    }
1978
1979    fn sample_smtp_config() -> MailConfig {
1980        MailConfig {
1981            transport: Transport::Smtp,
1982            from: Some("Autumn <noreply@example.com>".to_owned()),
1983            smtp: SmtpConfig {
1984                host: Some("smtp.example.com".to_owned()),
1985                ..Default::default()
1986            },
1987            ..Default::default()
1988        }
1989    }
1990
1991    fn sample_mail() -> Mail {
1992        Mail::builder()
1993            .to("user@example.com")
1994            .subject("Hi")
1995            .text("hello")
1996            .build()
1997            .expect("mail should build")
1998    }
1999
2000    struct NoopQueue;
2001
2002    impl MailDeliveryQueue for NoopQueue {
2003        fn enqueue<'a>(
2004            &'a self,
2005            _mail: Mail,
2006        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2007            Box::pin(async { Ok(()) })
2008        }
2009    }
2010
2011    #[test]
2012    fn install_mailer_rejects_in_process_fallback_in_prod_without_ack() {
2013        let state = crate::AppState::for_test().with_profile("prod");
2014        let config = sample_smtp_config();
2015
2016        let error = install_mailer(&state, &config, true)
2017            .expect_err("prod must reject in-process deliver_later fallback without ack");
2018
2019        let message = error.to_string();
2020        assert!(
2021            message.contains("allow_in_process_deliver_later_in_production"),
2022            "error should explain how to opt in: {message}"
2023        );
2024    }
2025
2026    #[test]
2027    fn install_mailer_allows_in_process_fallback_in_prod_with_explicit_ack() {
2028        let state = crate::AppState::for_test().with_profile("prod");
2029        let config = MailConfig {
2030            allow_in_process_deliver_later_in_production: true,
2031            ..sample_smtp_config()
2032        };
2033
2034        install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
2035    }
2036
2037    #[test]
2038    fn install_mailer_allows_durable_queue_in_prod_without_ack() {
2039        let state = crate::AppState::for_test().with_profile("prod");
2040        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2041        let config = sample_smtp_config();
2042
2043        install_mailer(&state, &config, true)
2044            .expect("a registered durable queue should satisfy the prod guard");
2045    }
2046
2047    #[test]
2048    fn install_mailer_does_not_require_ack_outside_production() {
2049        let state = crate::AppState::for_test().with_profile("dev");
2050        let config = sample_smtp_config();
2051
2052        install_mailer(&state, &config, true).expect("non-prod profiles should not require an ack");
2053    }
2054
2055    #[test]
2056    fn install_mailer_does_not_require_ack_when_transport_is_disabled() {
2057        let state = crate::AppState::for_test().with_profile("prod");
2058        let config = MailConfig::default();
2059
2060        install_mailer(&state, &config, true)
2061            .expect("disabled transport never sends mail so it should not need an ack");
2062    }
2063
2064    struct CapturingQueue {
2065        tx: tokio::sync::mpsc::UnboundedSender<Mail>,
2066    }
2067
2068    impl MailDeliveryQueue for CapturingQueue {
2069        fn enqueue<'a>(
2070            &'a self,
2071            mail: Mail,
2072        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2073            let tx = self.tx.clone();
2074            Box::pin(async move {
2075                tx.send(mail)
2076                    .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
2077                Ok(())
2078            })
2079        }
2080    }
2081
2082    #[cfg(feature = "db")]
2083    struct FailingQueue {
2084        tx: tokio::sync::mpsc::UnboundedSender<Mail>,
2085    }
2086
2087    #[cfg(feature = "db")]
2088    impl MailDeliveryQueue for FailingQueue {
2089        fn enqueue<'a>(
2090            &'a self,
2091            mail: Mail,
2092        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2093            let tx = self.tx.clone();
2094            Box::pin(async move {
2095                tx.send(mail)
2096                    .map_err(|err| MailError::RuntimeUnavailable(err.to_string()))?;
2097                Err(MailError::RuntimeUnavailable("queue offline".to_owned()))
2098            })
2099        }
2100    }
2101
2102    #[cfg(feature = "db")]
2103    async fn drain_after_commit_callbacks_for_test(
2104        registry: &std::sync::Arc<std::sync::Mutex<Vec<crate::db::CommitCallback>>>,
2105    ) {
2106        let callbacks: Vec<crate::db::CommitCallback> = {
2107            let mut reg = registry.lock().expect("registry lock");
2108            std::mem::take(&mut *reg)
2109        };
2110
2111        for cb in callbacks {
2112            if let Err(error) = cb().await {
2113                crate::db::record_after_commit_failure();
2114                tracing::error!("test drain: after_commit callback failed: {error}");
2115            }
2116        }
2117    }
2118
2119    #[cfg(feature = "db")]
2120    #[tokio::test]
2121    async fn deferred_deliver_later_queue_failure_increments_after_commit_counter() {
2122        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
2123        let mailer = Mailer::builder()
2124            .delivery_queue(FailingQueue { tx })
2125            .build()
2126            .expect("mailer should build");
2127        let registry = std::sync::Arc::new(std::sync::Mutex::new(
2128            Vec::<crate::db::CommitCallback>::new(),
2129        ));
2130        let before =
2131            crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
2132
2133        crate::db::AFTER_COMMIT_REGISTRY
2134            .scope(registry.clone(), async {
2135                mailer
2136                    .try_deliver_later(sample_mail())
2137                    .expect("registering deferred mail should succeed");
2138            })
2139            .await;
2140
2141        drain_after_commit_callbacks_for_test(&registry).await;
2142
2143        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
2144            .await
2145            .expect("queue should be called within 1s")
2146            .expect("queue should receive the mail");
2147        assert_eq!(received.subject, "Hi");
2148
2149        let after =
2150            crate::db::AFTER_COMMIT_FAILURES_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
2151        assert!(
2152            after > before,
2153            "deferred durable mail handoff failures should count as after_commit failures"
2154        );
2155    }
2156
2157    #[tokio::test]
2158    async fn deliver_later_routes_through_configured_queue() {
2159        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
2160
2161        let mailer = Mailer::builder()
2162            .delivery_queue(CapturingQueue { tx })
2163            .build()
2164            .expect("mailer should build");
2165
2166        mailer
2167            .try_deliver_later(sample_mail())
2168            .expect("scheduling onto the queue should succeed");
2169
2170        let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
2171            .await
2172            .expect("queue should receive within 1s")
2173            .expect("queue should receive the mail");
2174
2175        assert_eq!(received.subject, "Hi");
2176    }
2177
2178    #[tokio::test]
2179    async fn deliver_later_without_queue_sends_via_transport_directly() {
2180        // When no delivery queue is configured, `spawn_mail_delivery` falls back to
2181        // calling `mailer.send()` in a background task.
2182        use std::sync::Arc;
2183        use std::sync::atomic::{AtomicBool, Ordering};
2184
2185        struct TrackingSend(Arc<AtomicBool>);
2186        impl MailTransport for TrackingSend {
2187            fn send<'a>(
2188                &'a self,
2189                _mail: Mail,
2190            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2191                self.0.store(true, Ordering::SeqCst);
2192                Box::pin(async { Ok(()) })
2193            }
2194        }
2195
2196        let sent = Arc::new(AtomicBool::new(false));
2197        let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
2198
2199        mailer
2200            .try_deliver_later(sample_mail())
2201            .expect("should succeed without queue");
2202
2203        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2204        assert!(
2205            sent.load(Ordering::SeqCst),
2206            "mail should have been sent directly via transport"
2207        );
2208    }
2209
2210    #[cfg(feature = "db")]
2211    #[tokio::test]
2212    async fn deferred_deliver_later_without_queue_sends_after_commit() {
2213        // After-commit callback with no queue falls back to `spawn_mail_delivery`
2214        // which calls `mailer.send()` in a spawned task.
2215        use std::sync::Arc;
2216        use std::sync::atomic::{AtomicBool, Ordering};
2217
2218        struct TrackingSend(Arc<AtomicBool>);
2219        impl MailTransport for TrackingSend {
2220            fn send<'a>(
2221                &'a self,
2222                _mail: Mail,
2223            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2224                self.0.store(true, Ordering::SeqCst);
2225                Box::pin(async { Ok(()) })
2226            }
2227        }
2228
2229        let sent = Arc::new(AtomicBool::new(false));
2230        let mailer = Mailer::with_transport(TrackingSend(sent.clone()));
2231        let registry = std::sync::Arc::new(std::sync::Mutex::new(
2232            Vec::<crate::db::CommitCallback>::new(),
2233        ));
2234
2235        crate::db::AFTER_COMMIT_REGISTRY
2236            .scope(registry.clone(), async {
2237                mailer
2238                    .try_deliver_later(sample_mail())
2239                    .expect("should succeed");
2240            })
2241            .await;
2242
2243        drain_after_commit_callbacks_for_test(&registry).await;
2244        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2245
2246        assert!(
2247            sent.load(Ordering::SeqCst),
2248            "mail should have been sent after commit via direct transport"
2249        );
2250    }
2251
2252    #[tokio::test]
2253    async fn mailer_with_transport_starts_without_delivery_queue() {
2254        let mailer = Mailer::with_transport(NoopTransport);
2255        assert!(
2256            !mailer.has_durable_delivery_queue(),
2257            "with_transport should default to no durable queue"
2258        );
2259        // Exercise NoopTransport::send so its body is also covered.
2260        mailer
2261            .send(sample_mail())
2262            .await
2263            .expect("noop transport should always succeed");
2264    }
2265
2266    struct NoopTransport;
2267    impl MailTransport for NoopTransport {
2268        fn send<'a>(
2269            &'a self,
2270            _mail: Mail,
2271        ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2272            Box::pin(async { Ok(()) })
2273        }
2274    }
2275
2276    #[tokio::test]
2277    async fn deliver_later_is_noop_when_transport_disabled_even_with_queue() {
2278        // The Mailer-level builder lets callers attach a queue *and* pick
2279        // Transport::Disabled. The disabled-transport contract requires
2280        // deliver_later to drop the message in that case — the queue must
2281        // not persist mail when the operator has turned mail off entirely.
2282        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Mail>();
2283        let mailer = Mailer::builder()
2284            .transport(Transport::Disabled)
2285            .delivery_queue(CapturingQueue { tx })
2286            .build()
2287            .expect("mailer should build");
2288
2289        mailer
2290            .try_deliver_later(sample_mail())
2291            .expect("disabled transport should succeed as a no-op");
2292
2293        // Wait briefly for any spawn that might erroneously fire to land.
2294        let received = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
2295        assert!(
2296            received.is_err(),
2297            "queue must not be invoked when transport is disabled"
2298        );
2299    }
2300
2301    #[tokio::test]
2302    async fn deliver_later_uses_in_process_fallback_when_no_queue() {
2303        // The default Mailer has no durable queue, so deliver_later should
2304        // still spawn the in-process Tokio task and not call any queue.
2305        let mailer = Mailer::builder().build().expect("mailer should build");
2306
2307        mailer
2308            .try_deliver_later(sample_mail())
2309            .expect("in-process fallback should still schedule");
2310    }
2311
2312    #[test]
2313    fn mail_delivery_queue_handle_round_trips_via_from_arc_and_inner() {
2314        let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
2315        let handle = MailDeliveryQueueHandle::from_arc(Arc::clone(&arc));
2316
2317        assert!(Arc::ptr_eq(handle.inner(), &arc));
2318    }
2319
2320    #[test]
2321    fn mail_delivery_queue_handle_debug_does_not_panic() {
2322        let handle = MailDeliveryQueueHandle::new(NoopQueue);
2323        let rendered = format!("{handle:?}");
2324        assert!(rendered.contains("MailDeliveryQueueHandle"));
2325    }
2326
2327    #[test]
2328    fn mailer_has_durable_delivery_queue_reflects_attachment() {
2329        let plain = Mailer::builder().build().expect("mailer should build");
2330        assert!(!plain.has_durable_delivery_queue());
2331
2332        let with_queue = Mailer::builder()
2333            .delivery_queue(NoopQueue)
2334            .build()
2335            .expect("mailer should build");
2336        assert!(with_queue.has_durable_delivery_queue());
2337    }
2338
2339    #[test]
2340    fn mailer_with_delivery_queue_post_build_attaches_queue() {
2341        let mailer = Mailer::builder()
2342            .build()
2343            .expect("mailer should build")
2344            .with_delivery_queue(NoopQueue);
2345
2346        assert!(mailer.has_durable_delivery_queue());
2347    }
2348
2349    #[test]
2350    fn mailer_builder_delivery_queue_arc_attaches_shared_queue() {
2351        let arc: Arc<dyn MailDeliveryQueue> = Arc::new(NoopQueue);
2352        let mailer = Mailer::builder()
2353            .delivery_queue_arc(arc)
2354            .build()
2355            .expect("mailer should build");
2356
2357        assert!(mailer.has_durable_delivery_queue());
2358    }
2359
2360    #[test]
2361    fn install_mailer_warns_but_succeeds_with_explicit_ack_in_prod() {
2362        // Same as the explicit-ack test, but also asserts the mailer was
2363        // actually inserted and has no durable queue attached.
2364        let state = crate::AppState::for_test().with_profile("prod");
2365        let config = MailConfig {
2366            allow_in_process_deliver_later_in_production: true,
2367            ..sample_smtp_config()
2368        };
2369
2370        install_mailer(&state, &config, true).expect("explicit ack should permit fallback in prod");
2371
2372        let installed = state
2373            .extension::<Mailer>()
2374            .expect("install_mailer should store a Mailer extension");
2375        assert!(
2376            !installed.has_durable_delivery_queue(),
2377            "no queue was registered, so installed mailer should fall back in-process"
2378        );
2379    }
2380
2381    #[test]
2382    fn install_mailer_attaches_registered_queue_to_mailer() {
2383        let state = crate::AppState::for_test().with_profile("prod");
2384        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2385        let config = sample_smtp_config();
2386
2387        install_mailer(&state, &config, true).expect("durable queue should permit prod startup");
2388
2389        let installed = state
2390            .extension::<Mailer>()
2391            .expect("install_mailer should store a Mailer extension");
2392        assert!(
2393            installed.has_durable_delivery_queue(),
2394            "registered queue handle should be attached to the installed mailer"
2395        );
2396    }
2397
2398    #[test]
2399    fn install_mailer_with_factory_runs_factory_and_attaches_queue() {
2400        let state = crate::AppState::for_test().with_profile("prod");
2401        let config = sample_smtp_config();
2402        let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2403        let captured = Arc::clone(&factory_called);
2404
2405        let factory = move |_state: &crate::AppState| {
2406            captured.store(true, std::sync::atomic::Ordering::SeqCst);
2407            Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
2408        };
2409
2410        install_mailer_with_factory(&state, &config, Some(factory), true)
2411            .expect("factory should produce a queue and satisfy the prod guard");
2412
2413        assert!(
2414            factory_called.load(std::sync::atomic::Ordering::SeqCst),
2415            "factory must run when enforce_durable_guard is true"
2416        );
2417        let installed = state
2418            .extension::<Mailer>()
2419            .expect("install_mailer should store a Mailer extension");
2420        assert!(
2421            installed.has_durable_delivery_queue(),
2422            "factory's queue should be wired into the installed Mailer"
2423        );
2424    }
2425
2426    #[test]
2427    fn install_mailer_with_factory_skips_factory_when_not_enforced() {
2428        let state = crate::AppState::for_test().with_profile("prod");
2429        let config = sample_smtp_config();
2430        let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2431        let captured = Arc::clone(&factory_called);
2432
2433        let factory = move |_state: &crate::AppState| {
2434            captured.store(true, std::sync::atomic::Ordering::SeqCst);
2435            Ok::<_, crate::AutumnError>(Arc::new(NoopQueue) as Arc<dyn MailDeliveryQueue>)
2436        };
2437
2438        install_mailer_with_factory(&state, &config, Some(factory), false)
2439            .expect("static-build path should skip factory and install cleanly");
2440
2441        assert!(
2442            !factory_called.load(std::sync::atomic::Ordering::SeqCst),
2443            "factory must be skipped when enforce_durable_guard is false"
2444        );
2445    }
2446
2447    #[test]
2448    fn install_mailer_with_factory_propagates_factory_errors() {
2449        let state = crate::AppState::for_test().with_profile("prod");
2450        let config = sample_smtp_config();
2451
2452        let factory = |_state: &crate::AppState| {
2453            Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
2454                "queue offline",
2455            ))
2456        };
2457
2458        let error = install_mailer_with_factory(&state, &config, Some(factory), true)
2459            .expect_err("factory error should propagate");
2460        assert!(error.to_string().contains("queue offline"));
2461    }
2462
2463    #[test]
2464    fn install_mailer_with_factory_skips_factory_when_transport_disabled() {
2465        // Even when enforce_durable_guard=true (normal server path), a
2466        // profile with transport=disabled must not run the factory: the
2467        // factory might open Redis/Harvest/DB connections, but all mail in
2468        // this profile is supposed to be a no-op.
2469        let state = crate::AppState::for_test().with_profile("dev");
2470        let config = MailConfig::default(); // transport = Disabled
2471        let factory_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
2472        let captured = Arc::clone(&factory_called);
2473
2474        let factory = move |_state: &crate::AppState| {
2475            captured.store(true, std::sync::atomic::Ordering::SeqCst);
2476            Err::<Arc<dyn MailDeliveryQueue>, _>(crate::AutumnError::service_unavailable_msg(
2477                "queue must not be reached",
2478            ))
2479        };
2480
2481        install_mailer_with_factory(&state, &config, Some(factory), true)
2482            .expect("disabled transport should bypass the factory entirely");
2483        assert!(
2484            !factory_called.load(std::sync::atomic::Ordering::SeqCst),
2485            "factory must not run when transport = disabled"
2486        );
2487    }
2488
2489    #[test]
2490    fn install_mailer_with_factory_works_without_factory() {
2491        type FactoryFn = fn(&crate::AppState) -> AutumnResult<Arc<dyn MailDeliveryQueue>>;
2492        let state = crate::AppState::for_test().with_profile("dev");
2493        let config = sample_smtp_config();
2494        let no_factory: Option<FactoryFn> = None;
2495
2496        install_mailer_with_factory(&state, &config, no_factory, true)
2497            .expect("absent factory should be fine in non-prod");
2498    }
2499
2500    #[test]
2501    fn install_mailer_does_not_run_factory_when_not_enforced_and_no_handle() {
2502        // Mirrors run_build_mode: queue factory is intentionally skipped, so
2503        // no MailDeliveryQueueHandle is on AppState. install_mailer must
2504        // tolerate this and not try to enforce or warn about a missing queue.
2505        let state = crate::AppState::for_test().with_profile("prod");
2506        let config = sample_smtp_config();
2507
2508        install_mailer(&state, &config, false)
2509            .expect("static-build mode should install cleanly with no queue handle");
2510
2511        let installed = state
2512            .extension::<Mailer>()
2513            .expect("install_mailer should store a Mailer extension");
2514        assert!(
2515            !installed.has_durable_delivery_queue(),
2516            "no queue is expected when run_build_mode skips the factory"
2517        );
2518    }
2519
2520    #[test]
2521    fn install_mailer_skips_production_guard_when_not_enforced() {
2522        // Static-site builds (run_build_mode) call install_mailer with
2523        // enforce_durable_guard=false because they don't run the request
2524        // loop and don't actually defer mail. Even with a prod profile,
2525        // an active SMTP transport, no queue, and no ack flag, install
2526        // must succeed in this mode.
2527        let state = crate::AppState::for_test().with_profile("prod");
2528        let config = sample_smtp_config();
2529
2530        install_mailer(&state, &config, false)
2531            .expect("static-build mode should not enforce the deliver_later guard");
2532    }
2533
2534    #[test]
2535    fn spawn_mail_delivery_inherits_parent_span() {
2536        use std::future::Future;
2537        use std::pin::Pin;
2538        use std::sync::{Arc, Mutex};
2539
2540        struct CapturingQueue(Arc<Mutex<Option<tracing::span::Id>>>);
2541        impl MailDeliveryQueue for CapturingQueue {
2542            fn enqueue<'a>(
2543                &'a self,
2544                _mail: Mail,
2545            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2546                let captured = self.0.clone();
2547                Box::pin(async move {
2548                    *captured.lock().unwrap() = tracing::Span::current().id();
2549                    Ok(())
2550                })
2551            }
2552        }
2553
2554        let captured_span_id: Arc<Mutex<Option<tracing::span::Id>>> = Arc::new(Mutex::new(None));
2555
2556        let mailer = Mailer::builder()
2557            .delivery_queue(CapturingQueue(captured_span_id.clone()))
2558            .build()
2559            .expect("mailer with queue should build");
2560        let mail = sample_mail();
2561
2562        // The subscriber must remain active for the entire duration — spanning
2563        // both the enqueue call and the spawned task's execution — so that
2564        // `tracing::Span::current()` inside the task sees the same span tree
2565        // that was active when `try_deliver_later` was called.
2566        tracing::subscriber::with_default(tracing_subscriber::registry(), || {
2567            let rt = tokio::runtime::Builder::new_current_thread()
2568                .enable_all()
2569                .build()
2570                .expect("build runtime");
2571
2572            let outer = tracing::info_span!("deliver_later_outer");
2573            let outer_id = outer.id();
2574
2575            rt.block_on(async {
2576                {
2577                    let _guard = outer.enter();
2578                    mailer
2579                        .try_deliver_later(mail)
2580                        .expect("deliver_later must not fail");
2581                }
2582
2583                tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2584            });
2585
2586            let in_task = captured_span_id.lock().unwrap().clone();
2587            assert_eq!(
2588                in_task, outer_id,
2589                "delivery task must run inside the span that called deliver_later"
2590            );
2591        });
2592    }
2593
2594    #[tokio::test]
2595    async fn spawn_mail_delivery_logs_error_when_queue_fails() {
2596        use std::future::Future;
2597        use std::pin::Pin;
2598
2599        struct AlwaysFailQueue;
2600        impl MailDeliveryQueue for AlwaysFailQueue {
2601            fn enqueue<'a>(
2602                &'a self,
2603                _mail: Mail,
2604            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2605                Box::pin(async { Err(MailError::RuntimeUnavailable("always fails".to_owned())) })
2606            }
2607        }
2608
2609        let mailer = Mailer::builder()
2610            .delivery_queue(AlwaysFailQueue)
2611            .build()
2612            .expect("build");
2613
2614        mailer
2615            .try_deliver_later(sample_mail())
2616            .expect("should schedule");
2617
2618        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2619    }
2620
2621    #[tokio::test]
2622    async fn spawn_mail_delivery_logs_error_when_transport_fails() {
2623        use std::future::Future;
2624        use std::pin::Pin;
2625
2626        struct AlwaysFailTransport;
2627        impl MailTransport for AlwaysFailTransport {
2628            fn send<'a>(
2629                &'a self,
2630                _mail: Mail,
2631            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2632                Box::pin(async {
2633                    Err(MailError::RuntimeUnavailable(
2634                        "transport offline".to_owned(),
2635                    ))
2636                })
2637            }
2638        }
2639
2640        let mailer = Mailer::with_transport(AlwaysFailTransport);
2641
2642        mailer
2643            .try_deliver_later(sample_mail())
2644            .expect("should schedule");
2645
2646        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2647    }
2648
2649    #[test]
2650    fn install_mailer_does_not_attach_queue_when_transport_disabled() {
2651        // When mail.transport = "disabled" the operator has explicitly turned
2652        // mail off for this profile (tests, review apps, etc.). A globally
2653        // registered queue must not turn deliver_later back into a durable
2654        // persist; it should remain a no-op.
2655        let state = crate::AppState::for_test().with_profile("dev");
2656        state.insert_extension(MailDeliveryQueueHandle::new(NoopQueue));
2657        let config = MailConfig::default(); // transport = Disabled
2658
2659        install_mailer(&state, &config, true).expect("disabled transport should install cleanly");
2660
2661        let installed = state
2662            .extension::<Mailer>()
2663            .expect("install_mailer should store a Mailer extension");
2664        assert!(
2665            !installed.has_durable_delivery_queue(),
2666            "disabled transport must suppress queue attachment so deliver_later is a no-op"
2667        );
2668    }
2669
2670    #[tokio::test]
2671    async fn intercepted_mail_transport_short_circuit_prevents_sync_execution() {
2672        use std::future::Future;
2673        use std::pin::Pin;
2674        use std::sync::atomic::{AtomicU32, Ordering};
2675
2676        static TRANSPORT_CALLS: AtomicU32 = AtomicU32::new(0);
2677
2678        struct CountingTransport;
2679        impl MailTransport for CountingTransport {
2680            fn send<'a>(
2681                &'a self,
2682                _mail: Mail,
2683            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2684                TRANSPORT_CALLS.fetch_add(1, Ordering::SeqCst);
2685                Box::pin(async move { Ok(()) })
2686            }
2687
2688            fn is_disabled(&self) -> bool {
2689                false
2690            }
2691        }
2692
2693        struct ShortCircuitMailInterceptor;
2694        impl crate::interceptor::MailInterceptor for ShortCircuitMailInterceptor {
2695            fn intercept<'a>(
2696                &'a self,
2697                _mail: &'a Mail,
2698                _next: Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>>,
2699            ) -> Pin<Box<dyn Future<Output = Result<(), MailError>> + Send + 'a>> {
2700                Box::pin(async move {
2701                    Err(MailError::RuntimeUnavailable(
2702                        "blocked by interceptor".to_owned(),
2703                    ))
2704                })
2705            }
2706        }
2707
2708        let transport = Arc::new(CountingTransport);
2709        let interceptor = Arc::new(ShortCircuitMailInterceptor);
2710        let intercepted = InterceptedMailTransport {
2711            inner: transport,
2712            interceptor,
2713        };
2714
2715        let mail = Mail::builder()
2716            .to("test@example.com")
2717            .subject("test")
2718            .text("body")
2719            .build()
2720            .unwrap();
2721
2722        TRANSPORT_CALLS.store(0, Ordering::SeqCst);
2723
2724        let res = intercepted.send(mail).await;
2725        assert!(res.is_err());
2726        assert_eq!(TRANSPORT_CALLS.load(Ordering::SeqCst), 0);
2727    }
2728
2729    #[tokio::test]
2730    #[allow(clippy::await_holding_lock)]
2731    async fn test_smtp_transport_circuit_breaker() {
2732        let _lock = crate::circuit_breaker::TEST_LOCK
2733            .lock()
2734            .unwrap_or_else(std::sync::PoisonError::into_inner);
2735        crate::circuit_breaker::global_registry().clear();
2736        let policy = crate::circuit_breaker::CircuitBreakerPolicy {
2737            failure_ratio_threshold: 0.5,
2738            sample_window: std::time::Duration::from_secs(10),
2739            minimum_sample_count: 3,
2740            open_duration: std::time::Duration::from_secs(60),
2741            half_open_trial_count: 2,
2742        };
2743        let breaker =
2744            crate::circuit_breaker::global_registry().get_or_create("smtp_mailer", policy);
2745
2746        // Ensure it is closed initially
2747        assert_eq!(
2748            breaker.state(),
2749            crate::circuit_breaker::CircuitState::Closed
2750        );
2751
2752        // Build an SMTP transport pointing to a bogus localhost port so it fails
2753        let config = SmtpConfig {
2754            host: Some("127.0.0.1".to_string()),
2755            port: Some(9999), // Bogus port
2756            tls: TlsMode::Disabled,
2757            username: None,
2758            password_env: None,
2759        };
2760        let transport = SmtpTransport::new(config, None).unwrap();
2761
2762        let mail = Mail::builder()
2763            .from("sender@example.com")
2764            .to("test@example.com")
2765            .subject("test")
2766            .text("body")
2767            .build()
2768            .unwrap();
2769
2770        // Send 3 times — all should fail and trip the breaker
2771        for _ in 0..3 {
2772            let res = transport.send(mail.clone()).await;
2773            assert!(res.is_err());
2774        }
2775
2776        assert_eq!(breaker.state(), crate::circuit_breaker::CircuitState::Open);
2777
2778        // 4th send should fail fast with a circuit breaker error
2779        let res = transport.send(mail.clone()).await;
2780        assert!(res.is_err());
2781        let err_str = res.err().unwrap().to_string();
2782        assert!(
2783            err_str.contains("circuit breaker")
2784                || err_str.contains("open")
2785                || err_str.contains("Open")
2786                || err_str.contains("runtime unavailable")
2787        );
2788
2789        crate::circuit_breaker::global_registry().clear();
2790    }
2791}