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};
16use chrono::{Utc, DateTime};
17
18#[derive(Debug, Clone, serde::Serialize)]
20pub struct SmtpConfig {
21 pub host: String,
23
24 pub port: u16,
26
27 pub username: Option<String>,
29
30 pub password: Option<String>,
32
33 pub use_tls: bool,
35
36 pub use_ssl: bool,
38
39 pub timeout: u64,
41
42 pub hello_name: Option<String>,
44}
45
46impl Default for SmtpConfig {
47 fn default() -> Self {
48 Self {
49 host: "localhost".to_string(),
50 port: 587,
51 username: None,
52 password: None,
53 use_tls: true,
54 use_ssl: false,
55 timeout: 30,
56 hello_name: None,
57 }
58 }
59}
60
61impl EmailProviderConfig for SmtpConfig {
62 fn provider_name(&self) -> &'static str {
63 "smtp"
64 }
65
66 fn validate(&self) -> Result<(), String> {
67 if self.host.is_empty() {
68 return Err("SMTP host cannot be empty".to_string());
69 }
70
71 if self.port == 0 {
72 return Err("SMTP port must be greater than 0".to_string());
73 }
74
75 if self.use_tls && self.use_ssl {
76 return Err("Cannot use both TLS and SSL".to_string());
77 }
78
79 Ok(())
80 }
81
82 fn to_json(&self) -> Result<serde_json::Value, serde_json::Error> {
83 serde_json::to_value(self)
84 }
85}
86
87#[derive(Debug, Clone)]
89struct SmtpEmailTracking {
90 status: EmailStatus,
91 delivered_at: Option<DateTime<Utc>>,
92 error: Option<String>,
93 opened_at: Option<DateTime<Utc>>,
94 clicked_at: Vec<DateTime<Utc>>,
95}
96
97pub struct SmtpEmailService {
99 config: SmtpConfig,
100 transport: SmtpTransport,
101 tracking: Arc<Mutex<HashMap<String, SmtpEmailTracking>>>,
103 stats: Arc<Mutex<EmailServiceStats>>,
105}
106
107impl SmtpEmailService {
108 pub fn new(config: SmtpConfig) -> EmailResult<Self> {
110 config.validate()
111 .map_err(EmailError::ConfigError)?;
112
113 let tls_params = TlsParameters::builder(config.host.clone())
115 .build()
116 .map_err(|e| EmailError::SmtpConnection(format!("TLS parameters build failed: {}", e)))?;
117
118 let transport = if config.use_ssl || config.port == 465 {
120 tracing::debug!("Building SMTP transport with SSL/TLS wrapper for {}:{}", config.host, config.port);
122
123 let mut builder = SmtpTransport::builder_dangerous(&config.host)
124 .port(config.port)
125 .tls(Tls::Wrapper(tls_params));
126
127 if let (Some(username), Some(password)) = (&config.username, &config.password) {
128 tracing::debug!("SMTP configured with username: {}", username);
130 let creds = Credentials::new(username.clone(), password.clone());
131 builder = builder.credentials(creds);
132 }
133
134 builder.build()
135 } else if config.use_tls {
136 tracing::debug!("Building SMTP transport with opportunistic TLS for {}:{}", config.host, config.port);
138
139 let mut builder = SmtpTransport::relay(&config.host)
140 .map_err(|e| EmailError::SmtpConnection(format!("Relay build failed: {}", e)))?
141 .port(config.port)
142 .tls(Tls::Opportunistic(tls_params));
143
144 if let (Some(username), Some(password)) = (&config.username, &config.password) {
145 tracing::debug!("SMTP configured with username: {}", username);
147 let creds = Credentials::new(username.clone(), password.clone());
148 builder = builder.credentials(creds);
149 }
150
151 builder.build()
152 } else {
153 tracing::debug!("Building plain SMTP transport for {}:{}", config.host, config.port);
155
156 let mut builder = SmtpTransport::relay(&config.host)
157 .map_err(|e| EmailError::SmtpConnection(format!("Relay build failed: {}", e)))?
158 .port(config.port);
159
160 if let (Some(username), Some(password)) = (&config.username, &config.password) {
161 tracing::debug!("SMTP configured with username: {}", username);
163 let creds = Credentials::new(username.clone(), password.clone());
164 builder = builder.credentials(creds);
165 }
166
167 builder.build()
168 };
169
170 tracing::info!(
171 host = %config.host,
172 port = config.port,
173 use_ssl = config.use_ssl,
174 use_tls = config.use_tls,
175 has_credentials = config.username.is_some(),
176 "SMTP transport configured successfully"
177 );
178
179 Ok(Self {
180 config,
181 transport,
182 tracking: Arc::new(Mutex::new(HashMap::new())),
183 stats: Arc::new(Mutex::new(EmailServiceStats::default())),
184 })
185 }
186
187 pub fn builder() -> SmtpServiceBuilder {
189 SmtpServiceBuilder::new()
190 }
191
192 fn convert_message(&self, email_message: &EmailMessage) -> EmailResult<Message> {
194 let mut message = Message::builder();
195
196 let from_addr = if let Some(name) = &email_message.from.name {
198 if !name.is_empty() {
199 format!("{} <{}>", name, email_message.from.email)
200 .parse()
201 .map_err(|e| EmailError::InvalidEmail(format!("Invalid from address: {}", e)))?
202 } else {
203 email_message.from.email.clone()
204 .parse()
205 .map_err(|e| EmailError::InvalidEmail(format!("Invalid from address: {}", e)))?
206 }
207 } else {
208 email_message.from.email.clone()
209 .parse()
210 .map_err(|e| EmailError::InvalidEmail(format!("Invalid from address: {}", e)))?
211 };
212 message = message.from(from_addr);
213
214 if let Some(reply_to) = &email_message.reply_to {
216 let reply_addr = if let Some(name) = &reply_to.name {
217 if !name.is_empty() {
218 format!("{} <{}>", name, reply_to.email)
219 .parse()
220 .map_err(|e| EmailError::InvalidEmail(format!("Invalid reply-to address: {}", e)))?
221 } else {
222 reply_to.email.clone()
223 .parse()
224 .map_err(|e| EmailError::InvalidEmail(format!("Invalid reply-to address: {}", e)))?
225 }
226 } else {
227 reply_to.email.clone()
228 .parse()
229 .map_err(|e| EmailError::InvalidEmail(format!("Invalid reply-to address: {}", e)))?
230 };
231 message = message.reply_to(reply_addr);
232 }
233
234 for recipient in &email_message.recipients.to {
236 let to_addr = if let Some(name) = &recipient.name {
237 if !name.is_empty() {
238 format!("{} <{}>", name, recipient.email)
239 .parse()
240 .map_err(|e| EmailError::InvalidEmail(format!("Invalid to address: {}", e)))?
241 } else {
242 recipient.email.clone()
243 .parse()
244 .map_err(|e| EmailError::InvalidEmail(format!("Invalid to address: {}", e)))?
245 }
246 } else {
247 recipient.email.clone()
248 .parse()
249 .map_err(|e| EmailError::InvalidEmail(format!("Invalid to address: {}", e)))?
250 };
251 message = message.to(to_addr);
252 }
253
254 for recipient in &email_message.recipients.cc {
255 let cc_addr = if let Some(name) = &recipient.name {
256 if !name.is_empty() {
257 format!("{} <{}>", name, recipient.email)
258 .parse()
259 .map_err(|e| EmailError::InvalidEmail(format!("Invalid cc address: {}", e)))?
260 } else {
261 recipient.email.clone()
262 .parse()
263 .map_err(|e| EmailError::InvalidEmail(format!("Invalid cc address: {}", e)))?
264 }
265 } else {
266 recipient.email.clone()
267 .parse()
268 .map_err(|e| EmailError::InvalidEmail(format!("Invalid cc address: {}", e)))?
269 };
270 message = message.cc(cc_addr);
271 }
272
273 message = message.subject(&email_message.subject);
277
278 let message = if email_message.html.is_some() && email_message.text.is_some() {
284 let text_part = SinglePart::builder()
286 .header(ContentType::TEXT_PLAIN)
287 .body(email_message.text.as_ref().unwrap().clone());
288
289 let html_part = SinglePart::builder()
290 .header(ContentType::TEXT_HTML)
291 .body(email_message.html.as_ref().unwrap().clone());
292
293 let multipart = MultiPart::alternative()
294 .singlepart(text_part)
295 .singlepart(html_part);
296
297 message.multipart(multipart).unwrap()
298 } else if let Some(html_content) = &email_message.html {
299 message.singlepart(
301 SinglePart::builder()
302 .header(ContentType::TEXT_HTML)
303 .body(html_content.clone())
304 ).unwrap()
305 } else if let Some(text_content) = &email_message.text {
306 message.singlepart(
308 SinglePart::builder()
309 .header(ContentType::TEXT_PLAIN)
310 .body(text_content.clone())
311 ).unwrap()
312 } else {
313 return Err(EmailError::ConfigError("Email must have either text or HTML content".to_string()));
314 };
315
316 Ok(message)
317 }
318
319 fn render_template(&self, template: &str, data: &HashMap<String, serde_json::Value>) -> EmailResult<String> {
321 let mut rendered = template.to_string();
323
324 for (key, value) in data {
325 let placeholder = format!("{{{{{}}}}}", key);
326 let replacement = match value {
327 serde_json::Value::String(s) => s.clone(),
328 other => other.to_string(),
329 };
330 rendered = rendered.replace(&placeholder, &replacement);
331 }
332
333 let mut result = rendered;
335 for (key, value) in data {
336 let if_pattern = format!("{{{{#if {}}}}}", key);
337 let end_if_pattern = "{{/if}}";
338
339 if let Some(start_idx) = result.find(&if_pattern) {
340 if let Some(end_idx) = result.find(end_if_pattern) {
341 let content_between = &result[start_idx + if_pattern.len()..end_idx];
342 let should_include = match value {
343 serde_json::Value::Bool(true) => true,
344 serde_json::Value::String(s) if !s.is_empty() => true,
345 serde_json::Value::Number(n) if n.as_f64().unwrap_or(0.0) != 0.0 => true,
346 serde_json::Value::Array(a) if !a.is_empty() => true,
347 serde_json::Value::Object(o) if !o.is_empty() => true,
348 _ => false,
349 };
350
351 let replacement = if should_include { content_between } else { "" };
352 result = result.replace(&result[start_idx..=end_idx + end_if_pattern.len() - 1], replacement);
353 }
354 }
355 }
356
357 Ok(result)
358 }
359
360 fn update_stats(&self, status: EmailStatus) {
362 if let Ok(mut stats) = self.stats.lock() {
363 match status {
364 EmailStatus::Sent => {
365 stats.total_sent += 1;
366 }
367 EmailStatus::Failed => {
368 stats.total_failed += 1;
369 }
370 EmailStatus::Bounced => {
371 stats.total_bounced += 1;
372 }
373 EmailStatus::Opened => {
374 stats.total_opened += 1;
375 }
376 EmailStatus::Clicked => {
377 stats.total_clicked += 1;
378 }
379 _ => {}
380 }
381 }
382 }
383}
384
385#[async_trait]
386impl EmailService for SmtpEmailService {
387 async fn send(&self, message: EmailMessage) -> EmailResult<EmailDeliveryReport> {
388 message.validate()
390 .map_err(EmailError::Other)?;
391
392 let smtp_message = self.convert_message(&message)?;
394
395 tracing::debug!(
400 from = ?message.from,
401 from_email = %message.from.email,
402 from_name = ?message.from.name,
403 to = ?message.recipients.to.iter().map(|r| &r.email).collect::<Vec<_>>(),
404 subject = %message.subject,
405 "Sending SMTP email"
406 );
407
408 let send_result = self.transport.send(&smtp_message);
409
410 tracing::debug!(
411 send_result = ?send_result,
412 "SMTP send result"
413 );
414
415 let (status, error) = match send_result {
416 Ok(_) => (EmailStatus::Sent, None),
417 Err(e) => (EmailStatus::Failed, Some(e.to_string())),
418 };
419
420 let tracking_data = SmtpEmailTracking {
422 status,
423 delivered_at: if status == EmailStatus::Sent { Some(Utc::now()) } else { None },
424 error: error.clone(),
425 opened_at: None,
426 clicked_at: Vec::new(),
427 };
428
429 if let Ok(mut tracking) = self.tracking.lock() {
430 tracking.insert(message.id.clone(), tracking_data);
431 }
432
433 self.update_stats(status);
435
436 let report = EmailDeliveryReport {
438 message_id: message.id.clone(),
439 status,
440 provider_message_id: None, delivered_at: if status == EmailStatus::Sent { Some(Utc::now()) } else { None },
442 error,
443 bounce_reason: None,
444 opened_at: None,
445 clicked_at: Vec::new(),
446 metadata: {
447 let mut meta = HashMap::new();
448 meta.insert("provider".to_string(), "smtp".to_string());
449 meta.insert("host".to_string(), self.config.host.clone());
450 meta.insert("port".to_string(), self.config.port.to_string());
451 meta
452 },
453 };
454
455 Ok(report)
456 }
457
458 async fn send_template(
459 &self,
460 template_name: &str,
461 recipients: Vec<String>,
462 template_data: HashMap<String, serde_json::Value>,
463 ) -> EmailResult<Vec<EmailDeliveryReport>> {
464 #[allow(clippy::useless_format)]
470 let html_template = format!(
471 "<html><body><h1>Hello {{{{name}}}}!</h1><p>{{#if message}}<p>{{{{message}}}}</p>{{/if}}</p><p>Best regards,<br>{{{{company}}}}</p></body></html>"
472 );
473
474 #[allow(clippy::useless_format)]
475 let text_template = format!(
476 "Hello {{{{name}}}}!\n\n{{#if message}}<p>{{{{message}}}}</p>{{/if}}\n\nBest regards,\n{{{{company}}}}"
477 );
478
479 let mut reports = Vec::new();
480
481 for recipient_email in recipients {
482 let rendered_html = self.render_template(&html_template, &template_data.clone())
484 .unwrap_or_else(|_| "Template rendering failed".to_string());
485
486 let rendered_text = self.render_template(&text_template, &template_data.clone())
488 .unwrap_or_else(|_| "Template rendering failed".to_string());
489
490 let email_message = EmailMessage::builder()
492 .from("noreply@example.com")
493 .to(recipient_email.as_str())
494 .subject(format!("Template: {}", template_name))
495 .html(rendered_html)
496 .text(rendered_text)
497 .build();
498
499 let report = self.send(email_message).await?;
501 reports.push(report);
502 }
503
504 Ok(reports)
505 }
506
507 async fn get_delivery_status(&self, message_id: &str) -> EmailResult<Option<EmailDeliveryReport>> {
508 if let Ok(tracking) = self.tracking.lock() {
510 if let Some(tracking_data) = tracking.get(message_id) {
511 let report = EmailDeliveryReport {
512 message_id: message_id.to_string(),
513 status: tracking_data.status,
514 provider_message_id: None,
515 delivered_at: tracking_data.delivered_at,
516 error: tracking_data.error.clone(),
517 bounce_reason: None,
518 opened_at: tracking_data.opened_at,
519 clicked_at: tracking_data.clicked_at.clone(),
520 metadata: {
521 let mut meta = HashMap::new();
522 meta.insert("provider".to_string(), "smtp".to_string());
523 meta.insert("tracking_source".to_string(), "internal".to_string());
524 meta
525 },
526 };
527 return Ok(Some(report));
528 }
529 }
530 Ok(None)
531 }
532
533 async fn cancel_scheduled(&self, message_id: &str) -> EmailResult<bool> {
534 if let Ok(mut tracking) = self.tracking.lock() {
537 if let Some(tracking_data) = tracking.get_mut(message_id) {
538 match tracking_data.status {
540 EmailStatus::Pending => {
541 tracking_data.status = EmailStatus::Failed;
542 tracking_data.error = Some("Email cancelled by user".to_string());
543 self.update_stats(EmailStatus::Failed);
544 return Ok(true);
545 }
546 _ => return Ok(false), }
548 }
549 }
550 Ok(false)
551 }
552
553 async fn get_stats(&self) -> EmailResult<EmailServiceStats> {
554 if let Ok(stats) = self.stats.lock() {
556 Ok(stats.clone())
557 } else {
558 Ok(EmailServiceStats::default())
559 }
560 }
561
562 async fn validate_config(&self) -> EmailResult<bool> {
563 Ok(self.config.validate().is_ok())
564 }
565
566 async fn test_connection(&self) -> EmailResult<bool> {
567 match self.transport.test_connection() {
569 Ok(_) => Ok(true),
570 Err(e) => Err(EmailError::SmtpConnection(e.to_string())),
571 }
572 }
573}
574
575pub struct SmtpServiceBuilder {
577 config: SmtpConfig,
578}
579
580impl SmtpServiceBuilder {
581 pub fn new() -> Self {
583 Self {
584 config: SmtpConfig::default(),
585 }
586 }
587
588 pub fn host(mut self, host: impl Into<String>) -> Self {
590 self.config.host = host.into();
591 self
592 }
593
594 pub fn port(mut self, port: u16) -> Self {
596 self.config.port = port;
597 self
598 }
599
600 pub fn credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
602 self.config.username = Some(username.into());
603 self.config.password = Some(password.into());
604 self
605 }
606
607 pub fn use_tls(mut self, enabled: bool) -> Self {
609 self.config.use_tls = enabled;
610 self
611 }
612
613 pub fn use_ssl(mut self, enabled: bool) -> Self {
615 self.config.use_ssl = enabled;
616 self
617 }
618
619 pub fn timeout(mut self, seconds: u64) -> Self {
621 self.config.timeout = seconds;
622 self
623 }
624
625 pub fn hello_name(mut self, name: impl Into<String>) -> Self {
627 self.config.hello_name = Some(name.into());
628 self
629 }
630
631 pub fn build(self) -> EmailResult<SmtpEmailService> {
633 SmtpEmailService::new(self.config)
634 }
635}
636
637impl Default for SmtpServiceBuilder {
638 fn default() -> Self {
639 Self::new()
640 }
641}