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};
16
17/// Headers the message builder writes itself; a custom header of the same name would be a
18/// duplicate, so it is not added.
19const BUILDER_HEADERS: &[&str] = &[
20    "from", "to", "cc", "bcc", "reply-to", "subject", "date", "mime-version",
21    "content-type", "content-transfer-encoding", "sender",
22];
23
24/// Add a message's custom headers to the builder. A header the builder already writes is left to
25/// the builder; `Message-ID` goes through its own setter; a value carrying CR or LF (an attempt to
26/// inject headers) and a name that is not a valid header name are dropped with a warning.
27fn 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/// SMTP configuration
58#[derive(Debug, Clone, serde::Serialize)]
59pub struct SmtpConfig {
60    /// SMTP server hostname
61    pub host: String,
62
63    /// SMTP server port
64    pub port: u16,
65
66    /// Username for authentication
67    pub username: Option<String>,
68
69    /// Password for authentication
70    pub password: Option<String>,
71
72    /// Use TLS/SSL
73    pub use_tls: bool,
74
75    /// Use SSL (implicit TLS)
76    pub use_ssl: bool,
77
78    /// Connection timeout in seconds
79    pub timeout: u64,
80
81    /// Hello name/hostname
82    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/// Internal tracking data for SMTP emails
127#[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
136/// SMTP email service
137pub struct SmtpEmailService {
138    config: SmtpConfig,
139    transport: SmtpTransport,
140    // In-memory tracking for demonstration (in production, use database)
141    tracking: Arc<Mutex<HashMap<String, SmtpEmailTracking>>>,
142    // Simple statistics tracking
143    stats: Arc<Mutex<EmailServiceStats>>,
144}
145
146impl SmtpEmailService {
147    /// Create new SMTP email service
148    pub fn new(config: SmtpConfig) -> EmailResult<Self> {
149        config.validate()
150            .map_err(EmailError::ConfigError)?;
151
152        // Create TLS parameters with proper certificate chain
153        let tls_params = TlsParameters::builder(config.host.clone())
154            .build()
155            .map_err(|e| EmailError::SmtpConnection(format!("TLS parameters build failed: {}", e)))?;
156
157        // Create transport - handle SSL (port 465) vs STARTTLS (port 587)
158        let transport = if config.use_ssl || config.port == 465 {
159            // For SSL (port 465), use implicit TLS with builder_dangerous
160            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                // Security: Don't log password metadata (length, chars) - only username
168                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            // For STARTTLS (port 587)
176            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                // Security: Don't log password metadata (length, chars) - only username
185                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            // Plain connection (no TLS)
193            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                // Security: Don't log password metadata (length, chars) - only username
201                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    /// Builder for SMTP email service
227    pub fn builder() -> SmtpServiceBuilder {
228        SmtpServiceBuilder::new()
229    }
230
231    /// Convert EmailMessage to lettre Message
232    fn convert_message(&self, email_message: &EmailMessage) -> EmailResult<Message> {
233        let mut message = Message::builder();
234
235        // Set sender - use proper format
236        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        // Set reply-to if specified
254        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        // Add recipients
274        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        // BCC recipients are added during sending
313
314        // Set subject
315        message = message.subject(&email_message.subject);
316
317        // Custom headers ride on the message: unsubscribe pairs, threading headers and the like.
318        message = apply_custom_headers(message, &email_message.headers);
319
320        // Build message content
321        let message = if email_message.html.is_some() && email_message.text.is_some() {
322            // Multipart message with both HTML and text
323            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            // HTML only
338            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            // Text only
345            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    /// Render template with provided data
358    fn render_template(&self, template: &str, data: &HashMap<String, serde_json::Value>) -> EmailResult<String> {
359        // Simple template rendering (in production, use a proper template engine like Handlebars or Tera)
360        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        // Also handle conditional blocks {{#if key}}...{{/if}}
372        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    /// Update statistics
399    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        // Validate message
427        message.validate()
428            .map_err(EmailError::Other)?;
429
430        // Convert message
431        let smtp_message = self.convert_message(&message)?;
432
433        // Note: BCC recipients need to be handled differently in lettre
434        // For simplicity, we'll skip BCC in this implementation
435
436        // Send message
437        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        // Store tracking information
459        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        // Update statistics
472        self.update_stats(status);
473
474        // Create delivery report
475        let report = EmailDeliveryReport {
476            message_id: message.id.clone(),
477            status,
478            provider_message_id: None, // SMTP doesn't always provide message IDs
479            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        // For SMTP, we need to implement template rendering ourselves
503        // This is a simplified implementation - in production, use a proper template engine
504
505        // Get template content (in a real implementation, this would come from a template store)
506        // Double braces are intentional for Handlebars/Mustache templating syntax
507        #[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            // Render HTML template
521            let rendered_html = self.render_template(&html_template, &template_data.clone())
522                .unwrap_or_else(|_| "Template rendering failed".to_string());
523
524            // Render text template
525            let rendered_text = self.render_template(&text_template, &template_data.clone())
526                .unwrap_or_else(|_| "Template rendering failed".to_string());
527
528            // Create email message
529            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            // Send the rendered email
538            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        // Use our internal tracking system
547        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        // For SMTP, scheduled emails are typically handled at the application level
573        // Check if the message exists and update its status
574        if let Ok(mut tracking) = self.tracking.lock() {
575            if let Some(tracking_data) = tracking.get_mut(message_id) {
576                // If the message is still pending, we can "cancel" it by marking as failed
577                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), // Cannot cancel already sent emails
585                }
586            }
587        }
588        Ok(false)
589    }
590
591    async fn get_stats(&self) -> EmailResult<EmailServiceStats> {
592        // Return our internal statistics
593        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        // Test connection by sending a test message (without actually sending)
606        match self.transport.test_connection() {
607            Ok(_) => Ok(true),
608            Err(e) => Err(EmailError::SmtpConnection(e.to_string())),
609        }
610    }
611}
612
613/// SMTP service builder
614pub struct SmtpServiceBuilder {
615    config: SmtpConfig,
616}
617
618impl SmtpServiceBuilder {
619    /// Create new builder
620    pub fn new() -> Self {
621        Self {
622            config: SmtpConfig::default(),
623        }
624    }
625
626    /// Set SMTP host
627    pub fn host(mut self, host: impl Into<String>) -> Self {
628        self.config.host = host.into();
629        self
630    }
631
632    /// Set SMTP port
633    pub fn port(mut self, port: u16) -> Self {
634        self.config.port = port;
635        self
636    }
637
638    /// Set credentials
639    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    /// Enable/disable TLS
646    pub fn use_tls(mut self, enabled: bool) -> Self {
647        self.config.use_tls = enabled;
648        self
649    }
650
651    /// Enable/disable SSL
652    pub fn use_ssl(mut self, enabled: bool) -> Self {
653        self.config.use_ssl = enabled;
654        self
655    }
656
657    /// Set timeout
658    pub fn timeout(mut self, seconds: u64) -> Self {
659        self.config.timeout = seconds;
660        self
661    }
662
663    /// Set hello name
664    pub fn hello_name(mut self, name: impl Into<String>) -> Self {
665        self.config.hello_name = Some(name.into());
666        self
667    }
668
669    /// Build SMTP service
670    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}