aster_forge_mail/
sender.rs

1//! Product-neutral mail sender implementations.
2//!
3//! This module owns the repeated mechanics for in-memory test delivery and SMTP delivery through
4//! `lettre`. Product crates still decide how runtime settings are read, how delivery errors map to
5//! their API error model, which test email copy they send, and how deliveries are audited.
6
7use std::any::Any;
8use std::error::Error;
9use std::fmt;
10use std::sync::{Arc, Mutex};
11use std::time::Duration;
12
13use async_trait::async_trait;
14use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
15use lettre::transport::smtp::authentication::Credentials;
16use lettre::{Address, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
17use tokio::time::timeout;
18
19use crate::{MailMessage, MailRecipient, MailRuntimeSettings, RenderedMail};
20
21/// Default timeout applied to one SMTP send attempt.
22pub const DEFAULT_SMTP_SEND_TIMEOUT_SECS: u64 = 15;
23
24/// Error returned by shared mail senders.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum MailDeliveryError {
27    /// Required runtime mail settings are missing.
28    NotConfigured(String),
29    /// The message envelope or body is invalid.
30    InvalidMessage(String),
31    /// SMTP transport configuration failed.
32    Config(String),
33    /// SMTP transport attempted delivery and failed.
34    Delivery(String),
35    /// Local sender state failed.
36    Internal(String),
37}
38
39impl fmt::Display for MailDeliveryError {
40    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::NotConfigured(message)
43            | Self::InvalidMessage(message)
44            | Self::Config(message)
45            | Self::Delivery(message)
46            | Self::Internal(message) => formatter.write_str(message),
47        }
48    }
49}
50
51impl Error for MailDeliveryError {}
52
53/// Result type returned by shared mail senders.
54pub type MailSendResult<T> = std::result::Result<T, MailDeliveryError>;
55
56/// Product-neutral mail sender trait.
57#[async_trait]
58pub trait MailSender: Send + Sync {
59    /// Sends a fully rendered message.
60    async fn send(&self, message: MailMessage) -> MailSendResult<()>;
61
62    /// Exposes concrete sender type for test downcasting.
63    fn as_any(&self) -> &dyn Any;
64}
65
66/// Creates an in-memory sender for tests and local service wiring.
67#[must_use]
68pub fn memory_sender() -> Arc<dyn MailSender> {
69    Arc::new(MemoryMailSender::default())
70}
71
72/// Downcasts a sender reference to [`MemoryMailSender`].
73pub fn memory_sender_ref(sender: &Arc<dyn MailSender>) -> Option<&MemoryMailSender> {
74    sender.as_ref().as_any().downcast_ref::<MemoryMailSender>()
75}
76
77/// Creates an SMTP sender backed by a runtime settings provider.
78pub fn smtp_sender<F>(settings_provider: F) -> Arc<dyn MailSender>
79where
80    F: Fn() -> MailRuntimeSettings + Send + Sync + 'static,
81{
82    Arc::new(SmtpMailSender::new(settings_provider))
83}
84
85/// In-memory sender that stores sent messages.
86#[derive(Default)]
87pub struct MemoryMailSender {
88    outbox: Mutex<Vec<MailMessage>>,
89}
90
91impl MemoryMailSender {
92    /// Returns all messages captured by this sender.
93    pub fn messages(&self) -> Vec<MailMessage> {
94        match self.outbox.lock() {
95            Ok(outbox) => outbox.clone(),
96            Err(error) => {
97                tracing::error!(%error, "memory mail sender lock poisoned");
98                Vec::new()
99            }
100        }
101    }
102
103    /// Returns the last captured message.
104    pub fn last_message(&self) -> Option<MailMessage> {
105        match self.outbox.lock() {
106            Ok(outbox) => outbox.last().cloned(),
107            Err(error) => {
108                tracing::error!(%error, "memory mail sender lock poisoned");
109                None
110            }
111        }
112    }
113}
114
115#[async_trait]
116impl MailSender for MemoryMailSender {
117    async fn send(&self, message: MailMessage) -> MailSendResult<()> {
118        self.outbox
119            .lock()
120            .map_err(|error| {
121                MailDeliveryError::Internal(format!("memory mail sender poisoned: {error}"))
122            })?
123            .push(message);
124        Ok(())
125    }
126
127    fn as_any(&self) -> &dyn Any {
128        self
129    }
130}
131
132/// SMTP sender that reads runtime settings immediately before each delivery.
133pub struct SmtpMailSender<F>
134where
135    F: Fn() -> MailRuntimeSettings + Send + Sync + 'static,
136{
137    settings_provider: F,
138    timeout: Duration,
139}
140
141impl<F> SmtpMailSender<F>
142where
143    F: Fn() -> MailRuntimeSettings + Send + Sync + 'static,
144{
145    /// Creates an SMTP sender using the default send timeout.
146    pub fn new(settings_provider: F) -> Self {
147        Self {
148            settings_provider,
149            timeout: Duration::from_secs(DEFAULT_SMTP_SEND_TIMEOUT_SECS),
150        }
151    }
152
153    /// Creates an SMTP sender with a custom send timeout.
154    pub fn with_timeout(settings_provider: F, timeout: Duration) -> Self {
155        Self {
156            settings_provider,
157            timeout,
158        }
159    }
160}
161
162#[async_trait]
163impl<F> MailSender for SmtpMailSender<F>
164where
165    F: Fn() -> MailRuntimeSettings + Send + Sync + 'static,
166{
167    async fn send(&self, message: MailMessage) -> MailSendResult<()> {
168        let settings = (self.settings_provider)();
169        validate_runtime_settings(&settings)?;
170
171        let to_address = message.to.address.clone();
172        let subject = message.subject.clone();
173        tracing::debug!(
174            smtp_host = %settings.smtp_host,
175            smtp_port = settings.smtp_port,
176            encryption_enabled = settings.encryption_enabled,
177            to = %to_address,
178            subject = %subject,
179            timeout_secs = self.timeout.as_secs(),
180            "mail: preparing runtime SMTP delivery"
181        );
182
183        let email = build_lettre_message(message)?;
184        let mailer = build_transport(&settings)?;
185        match timeout(self.timeout, mailer.send(email)).await {
186            Ok(Ok(_)) => {
187                tracing::debug!(
188                    smtp_host = %settings.smtp_host,
189                    smtp_port = settings.smtp_port,
190                    to = %to_address,
191                    subject = %subject,
192                    timeout_secs = self.timeout.as_secs(),
193                    "mail: SMTP delivery completed"
194                );
195                Ok(())
196            }
197            Ok(Err(error)) => {
198                tracing::debug!(
199                    smtp_host = %settings.smtp_host,
200                    smtp_port = settings.smtp_port,
201                    to = %to_address,
202                    subject = %subject,
203                    error = %error,
204                    timeout_secs = self.timeout.as_secs(),
205                    "mail: SMTP delivery failed"
206                );
207                Err(MailDeliveryError::Delivery(error.to_string()))
208            }
209            Err(_) => {
210                tracing::debug!(
211                    smtp_host = %settings.smtp_host,
212                    smtp_port = settings.smtp_port,
213                    to = %to_address,
214                    subject = %subject,
215                    timeout_secs = self.timeout.as_secs(),
216                    "mail: SMTP delivery timed out"
217                );
218                Err(MailDeliveryError::Delivery(format!(
219                    "mail delivery timed out after {} seconds",
220                    self.timeout.as_secs()
221                )))
222            }
223        }
224    }
225
226    fn as_any(&self) -> &dyn Any {
227        self
228    }
229}
230
231/// Sends a rendered message through a sender using the provided runtime settings for the sender
232/// envelope.
233///
234/// # Errors
235///
236/// Returns [`MailDeliveryError`] when settings are incomplete, the message is invalid, or delivery fails.
237pub async fn send_rendered_with(
238    mail_sender: &Arc<dyn MailSender>,
239    settings: &MailRuntimeSettings,
240    to: MailRecipient,
241    rendered: RenderedMail,
242) -> MailSendResult<()> {
243    let from = MailRecipient {
244        address: settings.from_address.clone(),
245        display_name: (!settings.from_name.is_empty()).then_some(settings.from_name.clone()),
246    };
247    tracing::debug!(
248        from = %from.address,
249        to = %to.address,
250        subject = %rendered.subject,
251        "mail: dispatching rendered message through configured sender"
252    );
253
254    mail_sender
255        .send(MailMessage {
256            from,
257            to,
258            subject: rendered.subject,
259            text_body: rendered.text_body,
260            html_body: rendered.html_body,
261        })
262        .await
263}
264
265fn validate_runtime_settings(settings: &MailRuntimeSettings) -> MailSendResult<()> {
266    if !settings.is_configured() {
267        return Err(MailDeliveryError::NotConfigured(
268            "mail service is not configured".to_string(),
269        ));
270    }
271    if !settings.is_ready_for_delivery() {
272        return Err(MailDeliveryError::NotConfigured(
273            "mail SMTP username and password must both be set or both be empty".to_string(),
274        ));
275    }
276    Ok(())
277}
278
279/// Returns whether SMTP auth credentials should be attached to the transport.
280///
281/// This must use the same trim semantics as
282/// [`MailRuntimeSettings::is_ready_for_delivery`]: a whitespace-only username
283/// counts as "no auth" there, so attaching it here would burn outbox retry
284/// budget on deliveries that can never authenticate.
285fn smtp_auth_enabled(settings: &MailRuntimeSettings) -> bool {
286    !settings.smtp_username.trim().is_empty()
287}
288
289fn build_transport(
290    settings: &MailRuntimeSettings,
291) -> MailSendResult<AsyncSmtpTransport<Tokio1Executor>> {
292    tracing::debug!(
293        smtp_host = %settings.smtp_host,
294        smtp_port = settings.smtp_port,
295        encryption_enabled = settings.encryption_enabled,
296        auth_enabled = smtp_auth_enabled(settings),
297        "mail: building SMTP transport"
298    );
299    let mut transport = if settings.encryption_enabled {
300        if settings.smtp_port == 465 {
301            AsyncSmtpTransport::<Tokio1Executor>::relay(&settings.smtp_host)
302                .map_err(|error| MailDeliveryError::Config(error.to_string()))?
303                .port(settings.smtp_port)
304        } else {
305            AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&settings.smtp_host)
306                .map_err(|error| MailDeliveryError::Config(error.to_string()))?
307                .port(settings.smtp_port)
308        }
309    } else {
310        AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&settings.smtp_host)
311            .port(settings.smtp_port)
312    };
313
314    if smtp_auth_enabled(settings) {
315        transport = transport.credentials(Credentials::new(
316            settings.smtp_username.clone(),
317            settings.smtp_password.clone(),
318        ));
319    }
320
321    Ok(transport.build())
322}
323
324fn build_lettre_message(message: MailMessage) -> MailSendResult<Message> {
325    let from = mailbox(message.from)?;
326    let to = mailbox(message.to)?;
327
328    Message::builder()
329        .from(from)
330        .to(to)
331        .subject(message.subject)
332        .multipart(
333            MultiPart::alternative()
334                .singlepart(SinglePart::plain(message.text_body))
335                .singlepart(
336                    SinglePart::builder()
337                        .header(ContentType::TEXT_HTML)
338                        .body(message.html_body),
339                ),
340        )
341        .map_err(|error| MailDeliveryError::Config(error.to_string()))
342}
343
344fn mailbox(recipient: MailRecipient) -> MailSendResult<Mailbox> {
345    let address = recipient
346        .address
347        .parse::<Address>()
348        .map_err(|error| MailDeliveryError::InvalidMessage(error.to_string()))?;
349    Ok(Mailbox::new(recipient.display_name, address))
350}
351
352#[cfg(test)]
353mod tests {
354    use super::{MailDeliveryError, MemoryMailSender, SmtpMailSender, send_rendered_with};
355    use crate::{
356        DEFAULT_MAIL_SECURITY, DEFAULT_MAIL_SMTP_PORT, MailMessage, MailRecipient,
357        MailRuntimeSettings, MailSender, RenderedMail,
358    };
359
360    fn settings() -> MailRuntimeSettings {
361        MailRuntimeSettings {
362            smtp_host: "smtp.example.com".to_string(),
363            smtp_port: DEFAULT_MAIL_SMTP_PORT,
364            smtp_username: String::new(),
365            smtp_password: String::new(),
366            from_address: "ops@example.com".to_string(),
367            from_name: "Aster Ops".to_string(),
368            encryption_enabled: DEFAULT_MAIL_SECURITY,
369        }
370    }
371
372    #[tokio::test]
373    async fn memory_sender_captures_messages() {
374        let sender = MemoryMailSender::default();
375        sender
376            .send(MailMessage {
377                from: MailRecipient {
378                    address: "ops@example.com".to_string(),
379                    display_name: None,
380                },
381                to: MailRecipient {
382                    address: "user@example.com".to_string(),
383                    display_name: None,
384                },
385                subject: "Hello".to_string(),
386                text_body: "Hello".to_string(),
387                html_body: "<p>Hello</p>".to_string(),
388            })
389            .await
390            .unwrap();
391
392        assert_eq!(sender.messages().len(), 1);
393        assert_eq!(sender.last_message().unwrap().subject, "Hello");
394    }
395
396    #[tokio::test]
397    async fn send_rendered_with_uses_runtime_sender_identity() {
398        let sender = crate::memory_sender();
399        send_rendered_with(
400            &sender,
401            &settings(),
402            MailRecipient {
403                address: "user@example.com".to_string(),
404                display_name: Some("User".to_string()),
405            },
406            RenderedMail {
407                subject: "Subject".to_string(),
408                text_body: "Text".to_string(),
409                html_body: "<p>Text</p>".to_string(),
410            },
411        )
412        .await
413        .unwrap();
414
415        let message = crate::memory_sender_ref(&sender)
416            .and_then(MemoryMailSender::last_message)
417            .unwrap();
418        assert_eq!(message.from.address, "ops@example.com");
419        assert_eq!(message.from.display_name.as_deref(), Some("Aster Ops"));
420        assert_eq!(message.to.display_name.as_deref(), Some("User"));
421    }
422
423    #[test]
424    fn smtp_auth_enabled_matches_readiness_trim_semantics() {
425        let mut trimmed = settings();
426        // Whitespace-only usernames count as "no auth" in readiness
427        // validation (config.rs trims), so transport construction must not
428        // attach credentials for them either.
429        trimmed.smtp_username = " ".to_string();
430        assert!(!super::smtp_auth_enabled(&trimmed));
431
432        trimmed.smtp_username = " mailer ".to_string();
433        assert!(super::smtp_auth_enabled(&trimmed));
434
435        trimmed.smtp_username = String::new();
436        assert!(!super::smtp_auth_enabled(&trimmed));
437    }
438
439    #[tokio::test]
440    async fn smtp_sender_rejects_incomplete_settings_before_transport() {
441        let sender = SmtpMailSender::new(|| MailRuntimeSettings {
442            smtp_host: String::new(),
443            smtp_port: DEFAULT_MAIL_SMTP_PORT,
444            smtp_username: String::new(),
445            smtp_password: String::new(),
446            from_address: "ops@example.com".to_string(),
447            from_name: String::new(),
448            encryption_enabled: DEFAULT_MAIL_SECURITY,
449        });
450
451        let err = sender
452            .send(MailMessage {
453                from: MailRecipient {
454                    address: "ops@example.com".to_string(),
455                    display_name: None,
456                },
457                to: MailRecipient {
458                    address: "user@example.com".to_string(),
459                    display_name: None,
460                },
461                subject: "Subject".to_string(),
462                text_body: "Text".to_string(),
463                html_body: "<p>Text</p>".to_string(),
464            })
465            .await
466            .unwrap_err();
467
468        assert_eq!(
469            err,
470            MailDeliveryError::NotConfigured("mail service is not configured".to_string())
471        );
472    }
473}