1use 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
21pub const DEFAULT_SMTP_SEND_TIMEOUT_SECS: u64 = 15;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum MailDeliveryError {
27 NotConfigured(String),
29 InvalidMessage(String),
31 Config(String),
33 Delivery(String),
35 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
53pub type MailSendResult<T> = std::result::Result<T, MailDeliveryError>;
55
56#[async_trait]
58pub trait MailSender: Send + Sync {
59 async fn send(&self, message: MailMessage) -> MailSendResult<()>;
61
62 fn as_any(&self) -> &dyn Any;
64}
65
66#[must_use]
68pub fn memory_sender() -> Arc<dyn MailSender> {
69 Arc::new(MemoryMailSender::default())
70}
71
72pub fn memory_sender_ref(sender: &Arc<dyn MailSender>) -> Option<&MemoryMailSender> {
74 sender.as_ref().as_any().downcast_ref::<MemoryMailSender>()
75}
76
77pub 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#[derive(Default)]
87pub struct MemoryMailSender {
88 outbox: Mutex<Vec<MailMessage>>,
89}
90
91impl MemoryMailSender {
92 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 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
132pub 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 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 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
231pub 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
279fn 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 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}