Skip to main content

backbone_email/
smtp.rs

1//! SMTP email provider implementation
2
3use 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/// SMTP configuration
19#[derive(Debug, Clone, serde::Serialize)]
20pub struct SmtpConfig {
21    /// SMTP server hostname
22    pub host: String,
23
24    /// SMTP server port
25    pub port: u16,
26
27    /// Username for authentication
28    pub username: Option<String>,
29
30    /// Password for authentication
31    pub password: Option<String>,
32
33    /// Use TLS/SSL
34    pub use_tls: bool,
35
36    /// Use SSL (implicit TLS)
37    pub use_ssl: bool,
38
39    /// Connection timeout in seconds
40    pub timeout: u64,
41
42    /// Hello name/hostname
43    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/// Internal tracking data for SMTP emails
88#[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
97/// SMTP email service
98pub struct SmtpEmailService {
99    config: SmtpConfig,
100    transport: SmtpTransport,
101    // In-memory tracking for demonstration (in production, use database)
102    tracking: Arc<Mutex<HashMap<String, SmtpEmailTracking>>>,
103    // Simple statistics tracking
104    stats: Arc<Mutex<EmailServiceStats>>,
105}
106
107impl SmtpEmailService {
108    /// Create new SMTP email service
109    pub fn new(config: SmtpConfig) -> EmailResult<Self> {
110        config.validate()
111            .map_err(EmailError::ConfigError)?;
112
113        // Create TLS parameters with proper certificate chain
114        let tls_params = TlsParameters::builder(config.host.clone())
115            .build()
116            .map_err(|e| EmailError::SmtpConnection(format!("TLS parameters build failed: {}", e)))?;
117
118        // Create transport - handle SSL (port 465) vs STARTTLS (port 587)
119        let transport = if config.use_ssl || config.port == 465 {
120            // For SSL (port 465), use implicit TLS with builder_dangerous
121            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                // Security: Don't log password metadata (length, chars) - only username
129                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            // For STARTTLS (port 587)
137            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                // Security: Don't log password metadata (length, chars) - only username
146                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            // Plain connection (no TLS)
154            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                // Security: Don't log password metadata (length, chars) - only username
162                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    /// Builder for SMTP email service
188    pub fn builder() -> SmtpServiceBuilder {
189        SmtpServiceBuilder::new()
190    }
191
192    /// Convert EmailMessage to lettre Message
193    fn convert_message(&self, email_message: &EmailMessage) -> EmailResult<Message> {
194        let mut message = Message::builder();
195
196        // Set sender - use proper format
197        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        // Set reply-to if specified
215        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        // Add recipients
235        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        // BCC recipients are added during sending
274
275        // Set subject
276        message = message.subject(&email_message.subject);
277
278        // Add custom headers (lettre doesn't support arbitrary custom headers easily)
279        // This is a simplified approach - for production, consider using a more advanced email library
280        // for now, we'll skip custom headers in SMTP implementation
281
282        // Build message content
283        let message = if email_message.html.is_some() && email_message.text.is_some() {
284            // Multipart message with both HTML and text
285            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            // HTML only
300            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            // Text only
307            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    /// Render template with provided data
320    fn render_template(&self, template: &str, data: &HashMap<String, serde_json::Value>) -> EmailResult<String> {
321        // Simple template rendering (in production, use a proper template engine like Handlebars or Tera)
322        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        // Also handle conditional blocks {{#if key}}...{{/if}}
334        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    /// Update statistics
361    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        // Validate message
389        message.validate()
390            .map_err(EmailError::Other)?;
391
392        // Convert message
393        let smtp_message = self.convert_message(&message)?;
394
395        // Note: BCC recipients need to be handled differently in lettre
396        // For simplicity, we'll skip BCC in this implementation
397
398        // Send message
399        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        // Store tracking information
421        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        // Update statistics
434        self.update_stats(status);
435
436        // Create delivery report
437        let report = EmailDeliveryReport {
438            message_id: message.id.clone(),
439            status,
440            provider_message_id: None, // SMTP doesn't always provide message IDs
441            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        // For SMTP, we need to implement template rendering ourselves
465        // This is a simplified implementation - in production, use a proper template engine
466
467        // Get template content (in a real implementation, this would come from a template store)
468        // Double braces are intentional for Handlebars/Mustache templating syntax
469        #[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            // Render HTML template
483            let rendered_html = self.render_template(&html_template, &template_data.clone())
484                .unwrap_or_else(|_| "Template rendering failed".to_string());
485
486            // Render text template
487            let rendered_text = self.render_template(&text_template, &template_data.clone())
488                .unwrap_or_else(|_| "Template rendering failed".to_string());
489
490            // Create email message
491            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            // Send the rendered email
500            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        // Use our internal tracking system
509        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        // For SMTP, scheduled emails are typically handled at the application level
535        // Check if the message exists and update its status
536        if let Ok(mut tracking) = self.tracking.lock() {
537            if let Some(tracking_data) = tracking.get_mut(message_id) {
538                // If the message is still pending, we can "cancel" it by marking as failed
539                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), // Cannot cancel already sent emails
547                }
548            }
549        }
550        Ok(false)
551    }
552
553    async fn get_stats(&self) -> EmailResult<EmailServiceStats> {
554        // Return our internal statistics
555        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        // Test connection by sending a test message (without actually sending)
568        match self.transport.test_connection() {
569            Ok(_) => Ok(true),
570            Err(e) => Err(EmailError::SmtpConnection(e.to_string())),
571        }
572    }
573}
574
575/// SMTP service builder
576pub struct SmtpServiceBuilder {
577    config: SmtpConfig,
578}
579
580impl SmtpServiceBuilder {
581    /// Create new builder
582    pub fn new() -> Self {
583        Self {
584            config: SmtpConfig::default(),
585        }
586    }
587
588    /// Set SMTP host
589    pub fn host(mut self, host: impl Into<String>) -> Self {
590        self.config.host = host.into();
591        self
592    }
593
594    /// Set SMTP port
595    pub fn port(mut self, port: u16) -> Self {
596        self.config.port = port;
597        self
598    }
599
600    /// Set credentials
601    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    /// Enable/disable TLS
608    pub fn use_tls(mut self, enabled: bool) -> Self {
609        self.config.use_tls = enabled;
610        self
611    }
612
613    /// Enable/disable SSL
614    pub fn use_ssl(mut self, enabled: bool) -> Self {
615        self.config.use_ssl = enabled;
616        self
617    }
618
619    /// Set timeout
620    pub fn timeout(mut self, seconds: u64) -> Self {
621        self.config.timeout = seconds;
622        self
623    }
624
625    /// Set hello name
626    pub fn hello_name(mut self, name: impl Into<String>) -> Self {
627        self.config.hello_name = Some(name.into());
628        self
629    }
630
631    /// Build SMTP service
632    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}