1use crate::headers::{
2 ListUnsubscribe, ListUnsubscribePost, Precedence, XEntityRefId, XMailer, XPriority,
3};
4use crate::message::EmailMessage;
5use crate::{EmailError, EmailResult};
6use lettre::message::header::{HeaderName, HeaderValue};
7use lettre::message::{Mailbox, MultiPart, SinglePart, header};
8use lettre::transport::smtp::authentication::{Credentials, Mechanism};
9use lettre::transport::smtp::client::{Tls, TlsParameters};
10use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
11use std::time::Duration;
12use zeroize::Zeroize;
13
14#[async_trait::async_trait]
16pub trait EmailBackend: Send + Sync {
17 async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize>;
18}
19
20pub fn backend_from_settings(
33 settings: &reinhardt_conf::settings::EmailSettings,
34) -> crate::EmailResult<Box<dyn EmailBackend>> {
35 if !settings.from_email.is_empty() {
37 crate::validation::validate_email(&settings.from_email)?;
38 }
39
40 match settings.backend.to_lowercase().as_str() {
41 "smtp" => {
42 let security = match (settings.use_tls, settings.use_ssl) {
43 (true, _) => SmtpSecurity::StartTls,
44 (_, true) => SmtpSecurity::Tls,
45 _ => SmtpSecurity::None,
46 };
47
48 let timeout = settings
49 .timeout
50 .map(std::time::Duration::from_secs)
51 .unwrap_or(std::time::Duration::from_secs(60));
52
53 let mut config = SmtpConfig::new(&settings.host, settings.port)
54 .with_security(security)
55 .with_timeout(timeout);
56
57 if let (Some(username), Some(password)) = (&settings.username, &settings.password) {
58 config = config.with_credentials(username.clone(), password.clone());
59 }
60
61 let backend = SmtpBackend::new(config)?;
62 Ok(Box::new(backend))
63 }
64 "console" => Ok(Box::new(ConsoleBackend)),
65 "file" => {
66 let directory = settings
67 .file_path
68 .clone()
69 .ok_or_else(|| crate::EmailError::MissingField("file_path".to_string()))?;
70 Ok(Box::new(FileBackend::new(directory)))
71 }
72 "memory" => Ok(Box::new(MemoryBackend::new())),
73 unknown => Err(crate::EmailError::BackendError(format!(
74 "Unknown email backend type: '{}'. Valid options: smtp, console, file, memory",
75 unknown
76 ))),
77 }
78}
79
80pub struct ConsoleBackend;
84
85#[async_trait::async_trait]
86impl EmailBackend for ConsoleBackend {
87 async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
88 for (i, msg) in messages.iter().enumerate() {
89 println!("========== Email {} ==========", i + 1);
90 println!("From: {}", msg.from_email());
91 println!("To: {}", msg.to().join(", "));
92 if !msg.cc().is_empty() {
93 println!("Cc: {}", msg.cc().join(", "));
94 }
95 if !msg.bcc().is_empty() {
96 println!("Bcc: {}", msg.bcc().join(", "));
97 }
98 println!("Subject: {}", msg.subject());
99 for (name, value) in msg.headers() {
100 println!("{}: {}", name, value);
101 }
102 println!("\n{}", msg.body());
103 if let Some(html) = msg.html_body() {
104 println!("\n--- HTML ---\n{}", html);
105 }
106 for attachment in msg.attachments() {
107 println!(
108 "\n--- Attachment: {} (Content-Type: {}, {} bytes) ---",
109 attachment.filename(),
110 attachment.mime_type(),
111 attachment.content().len()
112 );
113 }
114 println!("==============================\n");
115 }
116 Ok(messages.len())
117 }
118}
119
120pub struct FileBackend {
122 directory: std::path::PathBuf,
123}
124
125impl FileBackend {
126 pub fn new(directory: impl Into<std::path::PathBuf>) -> Self {
127 Self {
128 directory: directory.into(),
129 }
130 }
131}
132
133#[async_trait::async_trait]
134impl EmailBackend for FileBackend {
135 async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
136 std::fs::create_dir_all(&self.directory)?;
137
138 for msg in messages.iter() {
139 let filename = format!(
140 "email_{}.eml",
141 chrono::Utc::now().format("%Y%m%d_%H%M%S_%f")
142 );
143 let path = self.directory.join(filename);
144
145 let mut content = format!(
146 "From: {}\nTo: {}\nSubject: {}",
147 msg.from_email(),
148 msg.to().join(", "),
149 msg.subject()
150 );
151
152 for (name, value) in msg.headers() {
154 content.push_str(&format!("\n{}: {}", name, value));
155 }
156
157 content.push_str(&format!("\n\n{}", msg.body()));
158
159 if let Some(html) = msg.html_body() {
161 content.push_str("\n\n--- HTML Body ---\n");
162 content.push_str(html);
163 }
164
165 for attachment in msg.attachments() {
167 content.push_str(&format!(
168 "\n\n--- Attachment: {} ---\nContent-Type: {}\nSize: {} bytes\n",
169 attachment.filename(),
170 attachment.mime_type(),
171 attachment.content().len()
172 ));
173 }
174
175 tokio::fs::write(path, content).await?;
176 }
177
178 Ok(messages.len())
179 }
180}
181
182pub struct MemoryBackend {
184 messages: std::sync::Arc<tokio::sync::Mutex<Vec<EmailMessage>>>,
185}
186
187impl MemoryBackend {
188 pub fn new() -> Self {
189 Self {
190 messages: std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())),
191 }
192 }
193
194 pub async fn count(&self) -> usize {
195 self.messages.lock().await.len()
196 }
197
198 pub async fn get_messages(&self) -> Vec<EmailMessage> {
199 self.messages.lock().await.clone()
200 }
201
202 pub async fn clear(&self) {
203 self.messages.lock().await.clear();
204 }
205}
206
207impl Default for MemoryBackend {
208 fn default() -> Self {
209 Self::new()
210 }
211}
212
213#[async_trait::async_trait]
214impl EmailBackend for MemoryBackend {
215 async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
216 let mut stored = self.messages.lock().await;
217 stored.extend_from_slice(messages);
218 Ok(messages.len())
219 }
220}
221
222#[derive(Debug, Clone)]
224pub enum SmtpSecurity {
225 None,
227 StartTls,
229 Tls,
231}
232
233#[derive(Debug, Clone)]
235pub enum SmtpAuthMechanism {
236 Plain,
238 Login,
240 Auto,
242}
243
244#[derive(Debug, Clone)]
246pub struct SmtpConfig {
247 pub host: String,
248 pub port: u16,
249 pub username: Option<String>,
250 pub password: Option<String>,
251 pub security: SmtpSecurity,
252 pub auth_mechanism: SmtpAuthMechanism,
253 pub timeout: Duration,
254}
255
256impl Default for SmtpConfig {
257 fn default() -> Self {
258 Self {
259 host: "localhost".to_string(),
260 port: 25,
261 username: None,
262 password: None,
263 security: SmtpSecurity::None,
264 auth_mechanism: SmtpAuthMechanism::Auto,
265 timeout: Duration::from_secs(30),
266 }
267 }
268}
269
270impl SmtpConfig {
271 pub fn new(host: impl Into<String>, port: u16) -> Self {
272 Self {
273 host: host.into(),
274 port,
275 username: None,
276 password: None,
277 security: SmtpSecurity::None,
278 auth_mechanism: SmtpAuthMechanism::Auto,
279 timeout: Duration::from_secs(30),
280 }
281 }
282
283 pub fn with_credentials(mut self, username: String, password: String) -> Self {
284 self.username = Some(username);
285 self.password = Some(password);
286 self
287 }
288
289 pub fn with_security(mut self, security: SmtpSecurity) -> Self {
290 self.security = security;
291 self
292 }
293
294 pub fn with_auth_mechanism(mut self, mechanism: SmtpAuthMechanism) -> Self {
295 self.auth_mechanism = mechanism;
296 self
297 }
298
299 pub fn with_timeout(mut self, timeout: Duration) -> Self {
300 self.timeout = timeout;
301 self
302 }
303
304 pub fn validate(&self) -> EmailResult<()> {
308 if let Some(username) = &self.username
310 && username.contains('@')
311 {
312 crate::validation::validate_email(username)?;
313 }
314 Ok(())
315 }
316}
317
318impl Drop for SmtpConfig {
324 fn drop(&mut self) {
325 if let Some(ref mut username) = self.username {
326 username.zeroize();
327 }
328 if let Some(ref mut password) = self.password {
329 password.zeroize();
330 }
331 }
332}
333
334pub struct SmtpBackend {
352 config: SmtpConfig,
353}
354
355impl SmtpBackend {
356 pub fn new(config: SmtpConfig) -> EmailResult<Self> {
357 config.validate()?;
358 Ok(Self { config })
359 }
360
361 fn build_transport(&self) -> EmailResult<AsyncSmtpTransport<Tokio1Executor>> {
362 match (&self.config.security, self.config.port) {
365 (SmtpSecurity::Tls, 465) => {
367 let builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&self.config.host)
368 .map_err(|e| EmailError::SmtpError(format!("TLS relay error: {}", e)))?
369 .timeout(Some(self.config.timeout));
370 let builder = self.configure_auth(builder);
371 Ok(builder.build())
372 }
373 (SmtpSecurity::StartTls, 587) => {
375 let builder =
376 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.config.host)
377 .map_err(|e| EmailError::SmtpError(format!("STARTTLS relay error: {}", e)))?
378 .timeout(Some(self.config.timeout));
379 let builder = self.configure_auth(builder);
380 Ok(builder.build())
381 }
382 _ => self.build_transport_with_custom_port(),
385 }
386 }
387
388 fn configure_auth(
390 &self,
391 mut builder: lettre::transport::smtp::AsyncSmtpTransportBuilder,
392 ) -> lettre::transport::smtp::AsyncSmtpTransportBuilder {
393 if let (Some(username), Some(password)) = (&self.config.username, &self.config.password) {
394 let credentials = Credentials::new(username.clone(), password.clone());
395
396 builder = match &self.config.auth_mechanism {
397 SmtpAuthMechanism::Plain => builder
398 .credentials(credentials)
399 .authentication(vec![Mechanism::Plain]),
400 SmtpAuthMechanism::Login => builder
401 .credentials(credentials)
402 .authentication(vec![Mechanism::Login]),
403 SmtpAuthMechanism::Auto => builder.credentials(credentials),
404 };
405 }
406 builder
407 }
408
409 fn build_transport_with_custom_port(&self) -> EmailResult<AsyncSmtpTransport<Tokio1Executor>> {
414 let mut builder =
415 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.config.host)
416 .port(self.config.port)
417 .timeout(Some(self.config.timeout));
418
419 match &self.config.security {
421 SmtpSecurity::None => {
422 }
424 SmtpSecurity::StartTls => {
425 let tls_params = TlsParameters::builder(self.config.host.clone())
426 .build()
427 .map_err(|e| EmailError::SmtpError(format!("TLS error: {}", e)))?;
428 builder = builder.tls(Tls::Required(tls_params));
429 }
430 SmtpSecurity::Tls => {
431 let tls_params = TlsParameters::builder(self.config.host.clone())
432 .build()
433 .map_err(|e| EmailError::SmtpError(format!("TLS error: {}", e)))?;
434 builder = builder.tls(Tls::Wrapper(tls_params));
435 }
436 }
437
438 builder = self.configure_auth(builder);
439
440 Ok(builder.build())
441 }
442
443 fn build_message(&self, email: &EmailMessage) -> EmailResult<Message> {
444 let from = email
446 .from_email()
447 .parse::<Mailbox>()
448 .map_err(|e| EmailError::InvalidAddress(format!("Invalid from address: {}", e)))?;
449
450 let mut builder = Message::builder().from(from).subject(email.subject());
452
453 for to in email.to() {
455 let mailbox = to
456 .parse::<Mailbox>()
457 .map_err(|e| EmailError::InvalidAddress(format!("Invalid to address: {}", e)))?;
458 builder = builder.to(mailbox);
459 }
460
461 for cc in email.cc() {
463 let mailbox = cc
464 .parse::<Mailbox>()
465 .map_err(|e| EmailError::InvalidAddress(format!("Invalid cc address: {}", e)))?;
466 builder = builder.cc(mailbox);
467 }
468
469 for bcc in email.bcc() {
471 let mailbox = bcc
472 .parse::<Mailbox>()
473 .map_err(|e| EmailError::InvalidAddress(format!("Invalid bcc address: {}", e)))?;
474 builder = builder.bcc(mailbox);
475 }
476
477 for reply_to in email.reply_to() {
479 let mailbox = reply_to.parse::<Mailbox>().map_err(|e| {
480 EmailError::InvalidAddress(format!("Invalid reply-to address: {}", e))
481 })?;
482 builder = builder.reply_to(mailbox);
483 }
484
485 let mut deferred_headers: Vec<(String, String)> = Vec::new();
489 for (name, value) in email.headers() {
490 let name_lower = name.to_lowercase();
491 match name_lower.as_str() {
492 "x-mailer" => {
493 builder = builder.header(XMailer::new(value));
494 }
495 "x-priority" => {
496 builder = builder.header(XPriority::new(value));
497 }
498 "list-unsubscribe" => {
499 builder = builder.header(ListUnsubscribe::new(value));
500 }
501 "list-unsubscribe-post" => {
502 builder = builder.header(ListUnsubscribePost::new(value));
503 }
504 "x-entity-ref-id" => {
505 builder = builder.header(XEntityRefId::new(value));
506 }
507 "precedence" => {
508 builder = builder.header(Precedence::new(value));
509 }
510 _ => {
511 deferred_headers.push((name.clone(), value.clone()));
513 }
514 }
515 }
516
517 let has_html = email.html_body().is_some();
519 let has_attachments = !email.attachments().is_empty();
520 let body = email.body().to_string();
521
522 let message = if has_html && has_attachments {
523 let alternative = MultiPart::alternative()
526 .singlepart(SinglePart::plain(body))
527 .singlepart(SinglePart::html(email.html_body().unwrap().to_string()));
528
529 let mut mixed = MultiPart::mixed().multipart(alternative);
530
531 for attachment in email.attachments() {
532 let content_type = header::ContentType::parse(attachment.mime_type())
533 .unwrap_or(header::ContentType::parse("application/octet-stream").unwrap());
534
535 let part = if let Some(cid) = attachment.content_id() {
536 SinglePart::builder()
537 .header(content_type)
538 .header(header::ContentDisposition::inline())
539 .header(header::ContentId::from(cid.to_string()))
540 .body(attachment.content().to_vec())
541 } else {
542 SinglePart::builder()
543 .header(content_type)
544 .header(header::ContentDisposition::attachment(
545 attachment.filename(),
546 ))
547 .body(attachment.content().to_vec())
548 };
549
550 mixed = mixed.singlepart(part);
551 }
552
553 builder
554 .multipart(mixed)
555 .map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
556 } else if has_html {
557 let multipart = MultiPart::alternative()
559 .singlepart(SinglePart::plain(body))
560 .singlepart(SinglePart::html(email.html_body().unwrap().to_string()));
561
562 builder
563 .multipart(multipart)
564 .map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
565 } else if has_attachments {
566 let mut multipart = MultiPart::mixed().singlepart(SinglePart::plain(body));
568
569 for attachment in email.attachments() {
570 let content_type = header::ContentType::parse(attachment.mime_type())
571 .unwrap_or(header::ContentType::parse("application/octet-stream").unwrap());
572
573 let part = if let Some(cid) = attachment.content_id() {
574 SinglePart::builder()
576 .header(content_type)
577 .header(header::ContentDisposition::inline())
578 .header(header::ContentId::from(cid.to_string()))
579 .body(attachment.content().to_vec())
580 } else {
581 SinglePart::builder()
583 .header(content_type)
584 .header(header::ContentDisposition::attachment(
585 attachment.filename(),
586 ))
587 .body(attachment.content().to_vec())
588 };
589
590 multipart = multipart.singlepart(part);
591 }
592
593 builder
594 .multipart(multipart)
595 .map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
596 } else {
597 builder
599 .body(body)
600 .map_err(|e| EmailError::BackendError(format!("Failed to build message: {}", e)))?
601 };
602
603 let mut message = message;
605 for (name, value) in deferred_headers {
606 match HeaderName::new_from_ascii(name.clone()) {
607 Ok(header_name) => {
608 let header_value = HeaderValue::new(header_name, value);
609 message.headers_mut().insert_raw(header_value);
610 }
611 Err(_) => {
612 return Err(EmailError::InvalidHeader(format!(
613 "Invalid header name: '{}'",
614 name
615 )));
616 }
617 }
618 }
619
620 Ok(message)
621 }
622}
623
624#[async_trait::async_trait]
625impl EmailBackend for SmtpBackend {
626 async fn send_messages(&self, messages: &[EmailMessage]) -> EmailResult<usize> {
627 let transport = self.build_transport()?;
628
629 let mut sent_count = 0;
630 for email in messages {
631 let message = self.build_message(email)?;
632
633 transport
634 .send(message)
635 .await
636 .map_err(|e| EmailError::SmtpError(format!("Failed to send email: {}", e)))?;
637
638 sent_count += 1;
639 }
640
641 Ok(sent_count)
642 }
643}