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
66pub fn memory_sender() -> Arc<dyn MailSender> {
68 Arc::new(MemoryMailSender::default())
69}
70
71pub fn memory_sender_ref(sender: &Arc<dyn MailSender>) -> Option<&MemoryMailSender> {
73 sender.as_ref().as_any().downcast_ref::<MemoryMailSender>()
74}
75
76pub 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#[derive(Default)]
86pub struct MemoryMailSender {
87 outbox: Mutex<Vec<MailMessage>>,
88}
89
90impl MemoryMailSender {
91 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 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
131pub 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 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 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
230pub 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
274fn 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 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}