1use async_trait::async_trait;
4use lettre::{
5 Message, SmtpTransport, Transport,
6 message::{header::ContentType, MultiPart, SinglePart},
7 transport::smtp::authentication::Credentials,
8 transport::smtp::client::{Tls, TlsParameters},
9};
10use crate::{
11 EmailResult, EmailError, EmailService, EmailMessage, EmailDeliveryReport,
12 EmailStatus, EmailServiceStats, EmailProviderConfig
13};
14use std::collections::HashMap;
15use std::sync::{Arc, Mutex};
16
17const BUILDER_HEADERS: &[&str] = &[
20 "from", "to", "cc", "bcc", "reply-to", "subject", "date", "mime-version",
21 "content-type", "content-transfer-encoding", "sender",
22];
23
24fn apply_custom_headers(
28 mut message: lettre::message::MessageBuilder,
29 headers: &HashMap<String, String>,
30) -> lettre::message::MessageBuilder {
31 use lettre::message::header::{HeaderName, HeaderValue};
32 let mut names: Vec<&String> = headers.keys().collect();
33 names.sort();
34 for name in names {
35 let value = &headers[name];
36 let lower = name.to_ascii_lowercase();
37 if BUILDER_HEADERS.contains(&lower.as_str()) {
38 continue;
39 }
40 if value.contains('\r') || value.contains('\n') {
41 tracing::warn!(header = %name, "dropping a custom header whose value carries a line break");
42 continue;
43 }
44 if lower == "message-id" {
45 message = message.message_id(Some(value.clone()));
46 continue;
47 }
48 match HeaderName::new_from_ascii(name.clone()) {
49 Ok(header_name) => message = message.raw_header(HeaderValue::new(header_name, value.clone())),
50 Err(_) => tracing::warn!(header = %name, "dropping a custom header whose name is not a valid header name"),
51 }
52 }
53 message
54}
55use chrono::{Utc, DateTime};
56
57#[derive(Debug, Clone, serde::Serialize)]
59pub struct SmtpConfig {
60 pub host: String,
62
63 pub port: u16,
65
66 pub username: Option<String>,
68
69 pub password: Option<String>,
71
72 pub use_tls: bool,
74
75 pub use_ssl: bool,
77
78 pub timeout: u64,
80
81 pub hello_name: Option<String>,
83}
84
85impl Default for SmtpConfig {
86 fn default() -> Self {
87 Self {
88 host: "localhost".to_string(),
89 port: 587,
90 username: None,
91 password: None,
92 use_tls: true,
93 use_ssl: false,
94 timeout: 30,
95 hello_name: None,
96 }
97 }
98}
99
100impl EmailProviderConfig for SmtpConfig {
101 fn provider_name(&self) -> &'static str {
102 "smtp"
103 }
104
105 fn validate(&self) -> Result<(), String> {
106 if self.host.is_empty() {
107 return Err("SMTP host cannot be empty".to_string());
108 }
109
110 if self.port == 0 {
111 return Err("SMTP port must be greater than 0".to_string());
112 }
113
114 if self.use_tls && self.use_ssl {
115 return Err("Cannot use both TLS and SSL".to_string());
116 }
117
118 Ok(())
119 }
120
121 fn to_json(&self) -> Result<serde_json::Value, serde_json::Error> {
122 serde_json::to_value(self)
123 }
124}
125
126#[derive(Debug, Clone)]
128struct SmtpEmailTracking {
129 status: EmailStatus,
130 delivered_at: Option<DateTime<Utc>>,
131 error: Option<String>,
132 opened_at: Option<DateTime<Utc>>,
133 clicked_at: Vec<DateTime<Utc>>,
134}
135
136pub struct SmtpEmailService {
138 config: SmtpConfig,
139 transport: SmtpTransport,
140 tracking: Arc<Mutex<HashMap<String, SmtpEmailTracking>>>,
142 stats: Arc<Mutex<EmailServiceStats>>,
144}
145
146impl SmtpEmailService {
147 pub fn new(config: SmtpConfig) -> EmailResult<Self> {
149 config.validate()
150 .map_err(EmailError::ConfigError)?;
151
152 let tls_params = TlsParameters::builder(config.host.clone())
154 .build()
155 .map_err(|e| EmailError::SmtpConnection(format!("TLS parameters build failed: {}", e)))?;
156
157 let transport = if config.use_ssl || config.port == 465 {
159 tracing::debug!("Building SMTP transport with SSL/TLS wrapper for {}:{}", config.host, config.port);
161
162 let mut builder = SmtpTransport::builder_dangerous(&config.host)
163 .port(config.port)
164 .tls(Tls::Wrapper(tls_params));
165
166 if let (Some(username), Some(password)) = (&config.username, &config.password) {
167 tracing::debug!("SMTP configured with username: {}", username);
169 let creds = Credentials::new(username.clone(), password.clone());
170 builder = builder.credentials(creds);
171 }
172
173 builder.build()
174 } else if config.use_tls {
175 tracing::debug!("Building SMTP transport with opportunistic TLS for {}:{}", config.host, config.port);
177
178 let mut builder = SmtpTransport::relay(&config.host)
179 .map_err(|e| EmailError::SmtpConnection(format!("Relay build failed: {}", e)))?
180 .port(config.port)
181 .tls(Tls::Opportunistic(tls_params));
182
183 if let (Some(username), Some(password)) = (&config.username, &config.password) {
184 tracing::debug!("SMTP configured with username: {}", username);
186 let creds = Credentials::new(username.clone(), password.clone());
187 builder = builder.credentials(creds);
188 }
189
190 builder.build()
191 } else {
192 tracing::debug!("Building plain SMTP transport for {}:{}", config.host, config.port);
194
195 let mut builder = SmtpTransport::relay(&config.host)
196 .map_err(|e| EmailError::SmtpConnection(format!("Relay build failed: {}", e)))?
197 .port(config.port);
198
199 if let (Some(username), Some(password)) = (&config.username, &config.password) {
200 tracing::debug!("SMTP configured with username: {}", username);
202 let creds = Credentials::new(username.clone(), password.clone());
203 builder = builder.credentials(creds);
204 }
205
206 builder.build()
207 };
208
209 tracing::info!(
210 host = %config.host,
211 port = config.port,
212 use_ssl = config.use_ssl,
213 use_tls = config.use_tls,
214 has_credentials = config.username.is_some(),
215 "SMTP transport configured successfully"
216 );
217
218 Ok(Self {
219 config,
220 transport,
221 tracking: Arc::new(Mutex::new(HashMap::new())),
222 stats: Arc::new(Mutex::new(EmailServiceStats::default())),
223 })
224 }
225
226 pub fn builder() -> SmtpServiceBuilder {
228 SmtpServiceBuilder::new()
229 }
230
231 fn convert_message(&self, email_message: &EmailMessage) -> EmailResult<Message> {
233 let mut message = Message::builder();
234
235 let from_addr = if let Some(name) = &email_message.from.name {
237 if !name.is_empty() {
238 format!("{} <{}>", name, email_message.from.email)
239 .parse()
240 .map_err(|e| EmailError::InvalidEmail(format!("Invalid from address: {}", e)))?
241 } else {
242 email_message.from.email.clone()
243 .parse()
244 .map_err(|e| EmailError::InvalidEmail(format!("Invalid from address: {}", e)))?
245 }
246 } else {
247 email_message.from.email.clone()
248 .parse()
249 .map_err(|e| EmailError::InvalidEmail(format!("Invalid from address: {}", e)))?
250 };
251 message = message.from(from_addr);
252
253 if let Some(reply_to) = &email_message.reply_to {
255 let reply_addr = if let Some(name) = &reply_to.name {
256 if !name.is_empty() {
257 format!("{} <{}>", name, reply_to.email)
258 .parse()
259 .map_err(|e| EmailError::InvalidEmail(format!("Invalid reply-to address: {}", e)))?
260 } else {
261 reply_to.email.clone()
262 .parse()
263 .map_err(|e| EmailError::InvalidEmail(format!("Invalid reply-to address: {}", e)))?
264 }
265 } else {
266 reply_to.email.clone()
267 .parse()
268 .map_err(|e| EmailError::InvalidEmail(format!("Invalid reply-to address: {}", e)))?
269 };
270 message = message.reply_to(reply_addr);
271 }
272
273 for recipient in &email_message.recipients.to {
275 let to_addr = if let Some(name) = &recipient.name {
276 if !name.is_empty() {
277 format!("{} <{}>", name, recipient.email)
278 .parse()
279 .map_err(|e| EmailError::InvalidEmail(format!("Invalid to address: {}", e)))?
280 } else {
281 recipient.email.clone()
282 .parse()
283 .map_err(|e| EmailError::InvalidEmail(format!("Invalid to address: {}", e)))?
284 }
285 } else {
286 recipient.email.clone()
287 .parse()
288 .map_err(|e| EmailError::InvalidEmail(format!("Invalid to address: {}", e)))?
289 };
290 message = message.to(to_addr);
291 }
292
293 for recipient in &email_message.recipients.cc {
294 let cc_addr = if let Some(name) = &recipient.name {
295 if !name.is_empty() {
296 format!("{} <{}>", name, recipient.email)
297 .parse()
298 .map_err(|e| EmailError::InvalidEmail(format!("Invalid cc address: {}", e)))?
299 } else {
300 recipient.email.clone()
301 .parse()
302 .map_err(|e| EmailError::InvalidEmail(format!("Invalid cc address: {}", e)))?
303 }
304 } else {
305 recipient.email.clone()
306 .parse()
307 .map_err(|e| EmailError::InvalidEmail(format!("Invalid cc address: {}", e)))?
308 };
309 message = message.cc(cc_addr);
310 }
311
312 message = message.subject(&email_message.subject);
316
317 message = apply_custom_headers(message, &email_message.headers);
319
320 let message = if email_message.html.is_some() && email_message.text.is_some() {
322 let text_part = SinglePart::builder()
324 .header(ContentType::TEXT_PLAIN)
325 .body(email_message.text.as_ref().unwrap().clone());
326
327 let html_part = SinglePart::builder()
328 .header(ContentType::TEXT_HTML)
329 .body(email_message.html.as_ref().unwrap().clone());
330
331 let multipart = MultiPart::alternative()
332 .singlepart(text_part)
333 .singlepart(html_part);
334
335 message.multipart(multipart).unwrap()
336 } else if let Some(html_content) = &email_message.html {
337 message.singlepart(
339 SinglePart::builder()
340 .header(ContentType::TEXT_HTML)
341 .body(html_content.clone())
342 ).unwrap()
343 } else if let Some(text_content) = &email_message.text {
344 message.singlepart(
346 SinglePart::builder()
347 .header(ContentType::TEXT_PLAIN)
348 .body(text_content.clone())
349 ).unwrap()
350 } else {
351 return Err(EmailError::ConfigError("Email must have either text or HTML content".to_string()));
352 };
353
354 Ok(message)
355 }
356
357 fn render_template(&self, template: &str, data: &HashMap<String, serde_json::Value>) -> EmailResult<String> {
359 let mut rendered = template.to_string();
361
362 for (key, value) in data {
363 let placeholder = format!("{{{{{}}}}}", key);
364 let replacement = match value {
365 serde_json::Value::String(s) => s.clone(),
366 other => other.to_string(),
367 };
368 rendered = rendered.replace(&placeholder, &replacement);
369 }
370
371 let mut result = rendered;
373 for (key, value) in data {
374 let if_pattern = format!("{{{{#if {}}}}}", key);
375 let end_if_pattern = "{{/if}}";
376
377 if let Some(start_idx) = result.find(&if_pattern) {
378 if let Some(end_idx) = result.find(end_if_pattern) {
379 let content_between = &result[start_idx + if_pattern.len()..end_idx];
380 let should_include = match value {
381 serde_json::Value::Bool(true) => true,
382 serde_json::Value::String(s) if !s.is_empty() => true,
383 serde_json::Value::Number(n) if n.as_f64().unwrap_or(0.0) != 0.0 => true,
384 serde_json::Value::Array(a) if !a.is_empty() => true,
385 serde_json::Value::Object(o) if !o.is_empty() => true,
386 _ => false,
387 };
388
389 let replacement = if should_include { content_between } else { "" };
390 result = result.replace(&result[start_idx..=end_idx + end_if_pattern.len() - 1], replacement);
391 }
392 }
393 }
394
395 Ok(result)
396 }
397
398 fn update_stats(&self, status: EmailStatus) {
400 if let Ok(mut stats) = self.stats.lock() {
401 match status {
402 EmailStatus::Sent => {
403 stats.total_sent += 1;
404 }
405 EmailStatus::Failed => {
406 stats.total_failed += 1;
407 }
408 EmailStatus::Bounced => {
409 stats.total_bounced += 1;
410 }
411 EmailStatus::Opened => {
412 stats.total_opened += 1;
413 }
414 EmailStatus::Clicked => {
415 stats.total_clicked += 1;
416 }
417 _ => {}
418 }
419 }
420 }
421}
422
423#[async_trait]
424impl EmailService for SmtpEmailService {
425 async fn send(&self, message: EmailMessage) -> EmailResult<EmailDeliveryReport> {
426 message.validate()
428 .map_err(EmailError::Other)?;
429
430 let smtp_message = self.convert_message(&message)?;
432
433 tracing::debug!(
438 from = ?message.from,
439 from_email = %message.from.email,
440 from_name = ?message.from.name,
441 to = ?message.recipients.to.iter().map(|r| &r.email).collect::<Vec<_>>(),
442 subject = %message.subject,
443 "Sending SMTP email"
444 );
445
446 let send_result = self.transport.send(&smtp_message);
447
448 tracing::debug!(
449 send_result = ?send_result,
450 "SMTP send result"
451 );
452
453 let (status, error) = match send_result {
454 Ok(_) => (EmailStatus::Sent, None),
455 Err(e) => (EmailStatus::Failed, Some(e.to_string())),
456 };
457
458 let tracking_data = SmtpEmailTracking {
460 status,
461 delivered_at: if status == EmailStatus::Sent { Some(Utc::now()) } else { None },
462 error: error.clone(),
463 opened_at: None,
464 clicked_at: Vec::new(),
465 };
466
467 if let Ok(mut tracking) = self.tracking.lock() {
468 tracking.insert(message.id.clone(), tracking_data);
469 }
470
471 self.update_stats(status);
473
474 let report = EmailDeliveryReport {
476 message_id: message.id.clone(),
477 status,
478 provider_message_id: None, delivered_at: if status == EmailStatus::Sent { Some(Utc::now()) } else { None },
480 error,
481 bounce_reason: None,
482 opened_at: None,
483 clicked_at: Vec::new(),
484 metadata: {
485 let mut meta = HashMap::new();
486 meta.insert("provider".to_string(), "smtp".to_string());
487 meta.insert("host".to_string(), self.config.host.clone());
488 meta.insert("port".to_string(), self.config.port.to_string());
489 meta
490 },
491 };
492
493 Ok(report)
494 }
495
496 async fn send_template(
497 &self,
498 template_name: &str,
499 recipients: Vec<String>,
500 template_data: HashMap<String, serde_json::Value>,
501 ) -> EmailResult<Vec<EmailDeliveryReport>> {
502 #[allow(clippy::useless_format)]
508 let html_template = format!(
509 "<html><body><h1>Hello {{{{name}}}}!</h1><p>{{#if message}}<p>{{{{message}}}}</p>{{/if}}</p><p>Best regards,<br>{{{{company}}}}</p></body></html>"
510 );
511
512 #[allow(clippy::useless_format)]
513 let text_template = format!(
514 "Hello {{{{name}}}}!\n\n{{#if message}}<p>{{{{message}}}}</p>{{/if}}\n\nBest regards,\n{{{{company}}}}"
515 );
516
517 let mut reports = Vec::new();
518
519 for recipient_email in recipients {
520 let rendered_html = self.render_template(&html_template, &template_data.clone())
522 .unwrap_or_else(|_| "Template rendering failed".to_string());
523
524 let rendered_text = self.render_template(&text_template, &template_data.clone())
526 .unwrap_or_else(|_| "Template rendering failed".to_string());
527
528 let email_message = EmailMessage::builder()
530 .from("noreply@example.com")
531 .to(recipient_email.as_str())
532 .subject(format!("Template: {}", template_name))
533 .html(rendered_html)
534 .text(rendered_text)
535 .build();
536
537 let report = self.send(email_message).await?;
539 reports.push(report);
540 }
541
542 Ok(reports)
543 }
544
545 async fn get_delivery_status(&self, message_id: &str) -> EmailResult<Option<EmailDeliveryReport>> {
546 if let Ok(tracking) = self.tracking.lock() {
548 if let Some(tracking_data) = tracking.get(message_id) {
549 let report = EmailDeliveryReport {
550 message_id: message_id.to_string(),
551 status: tracking_data.status,
552 provider_message_id: None,
553 delivered_at: tracking_data.delivered_at,
554 error: tracking_data.error.clone(),
555 bounce_reason: None,
556 opened_at: tracking_data.opened_at,
557 clicked_at: tracking_data.clicked_at.clone(),
558 metadata: {
559 let mut meta = HashMap::new();
560 meta.insert("provider".to_string(), "smtp".to_string());
561 meta.insert("tracking_source".to_string(), "internal".to_string());
562 meta
563 },
564 };
565 return Ok(Some(report));
566 }
567 }
568 Ok(None)
569 }
570
571 async fn cancel_scheduled(&self, message_id: &str) -> EmailResult<bool> {
572 if let Ok(mut tracking) = self.tracking.lock() {
575 if let Some(tracking_data) = tracking.get_mut(message_id) {
576 match tracking_data.status {
578 EmailStatus::Pending => {
579 tracking_data.status = EmailStatus::Failed;
580 tracking_data.error = Some("Email cancelled by user".to_string());
581 self.update_stats(EmailStatus::Failed);
582 return Ok(true);
583 }
584 _ => return Ok(false), }
586 }
587 }
588 Ok(false)
589 }
590
591 async fn get_stats(&self) -> EmailResult<EmailServiceStats> {
592 if let Ok(stats) = self.stats.lock() {
594 Ok(stats.clone())
595 } else {
596 Ok(EmailServiceStats::default())
597 }
598 }
599
600 async fn validate_config(&self) -> EmailResult<bool> {
601 Ok(self.config.validate().is_ok())
602 }
603
604 async fn test_connection(&self) -> EmailResult<bool> {
605 match self.transport.test_connection() {
607 Ok(_) => Ok(true),
608 Err(e) => Err(EmailError::SmtpConnection(e.to_string())),
609 }
610 }
611}
612
613pub struct SmtpServiceBuilder {
615 config: SmtpConfig,
616}
617
618impl SmtpServiceBuilder {
619 pub fn new() -> Self {
621 Self {
622 config: SmtpConfig::default(),
623 }
624 }
625
626 pub fn host(mut self, host: impl Into<String>) -> Self {
628 self.config.host = host.into();
629 self
630 }
631
632 pub fn port(mut self, port: u16) -> Self {
634 self.config.port = port;
635 self
636 }
637
638 pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
640 self.config.username = Some(username.into());
641 self.config.password = Some(password.into());
642 self
643 }
644
645 pub fn use_tls(mut self, enabled: bool) -> Self {
647 self.config.use_tls = enabled;
648 self
649 }
650
651 pub fn use_ssl(mut self, enabled: bool) -> Self {
653 self.config.use_ssl = enabled;
654 self
655 }
656
657 pub fn timeout(mut self, seconds: u64) -> Self {
659 self.config.timeout = seconds;
660 self
661 }
662
663 pub fn hello_name(mut self, name: impl Into<String>) -> Self {
665 self.config.hello_name = Some(name.into());
666 self
667 }
668
669 pub fn build(self) -> EmailResult<SmtpEmailService> {
671 SmtpEmailService::new(self.config)
672 }
673}
674
675impl Default for SmtpServiceBuilder {
676 fn default() -> Self {
677 Self::new()
678 }
679}
680
681#[cfg(test)]
682mod header_tests {
683 use super::*;
684
685 fn rendered(headers: &[(&str, &str)]) -> String {
686 let map: HashMap<String, String> = headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
687 let builder = Message::builder()
688 .from("sender@example.test".parse().unwrap())
689 .to("someone@example.test".parse().unwrap())
690 .subject("hello");
691 let message = apply_custom_headers(builder, &map).body(String::from("body")).unwrap();
692 String::from_utf8(message.formatted()).unwrap()
693 }
694
695 #[test]
696 fn custom_headers_reach_the_message() {
697 let out = rendered(&[
698 ("List-Unsubscribe", "<https://example.test/u/1>"),
699 ("List-Unsubscribe-Post", "List-Unsubscribe=One-Click"),
700 ("In-Reply-To", "<a@example.test>"),
701 ]);
702 assert!(out.contains("List-Unsubscribe: <https://example.test/u/1>"), "{out}");
703 assert!(out.contains("List-Unsubscribe-Post: List-Unsubscribe=One-Click"), "{out}");
704 assert!(out.contains("In-Reply-To: <a@example.test>"), "{out}");
705 }
706
707 #[test]
708 fn builder_headers_and_injection_attempts_are_not_added() {
709 let out = rendered(&[("To", "evil@example.test"), ("X-Note", "a\r\nBcc: evil@example.test")]);
710 assert!(!out.contains("evil@example.test"), "{out}");
711 assert_eq!(out.matches("To: ").count(), 1, "{out}");
712 }
713}