Skip to main content

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