acton_htmx/email/backend/
smtp.rs

1//! SMTP backend for sending emails
2//!
3//! Uses the `lettre` crate to send emails via SMTP servers.
4
5use async_trait::async_trait;
6use lettre::{
7    message::{header, Mailbox, MultiPart, SinglePart},
8    transport::smtp::{
9        authentication::Credentials,
10        client::{Tls, TlsParameters},
11    },
12    AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
13};
14
15use crate::email::{Email, EmailError, EmailSender};
16
17/// SMTP email backend configuration
18#[derive(Debug, Clone)]
19pub struct SmtpConfig {
20    /// SMTP server hostname
21    pub host: String,
22
23    /// SMTP server port (usually 587 for STARTTLS, 465 for TLS)
24    pub port: u16,
25
26    /// SMTP username
27    pub username: String,
28
29    /// SMTP password
30    pub password: String,
31
32    /// Use STARTTLS (default: true)
33    pub use_tls: bool,
34}
35
36impl SmtpConfig {
37    /// Create SMTP configuration from environment variables
38    ///
39    /// Expects the following environment variables:
40    /// - `SMTP_HOST`: SMTP server hostname
41    /// - `SMTP_PORT`: SMTP server port (default: 587)
42    /// - `SMTP_USERNAME`: SMTP username
43    /// - `SMTP_PASSWORD`: SMTP password
44    /// - `SMTP_USE_TLS`: Use TLS (default: true)
45    ///
46    /// # Errors
47    ///
48    /// Returns `EmailError::ConfigError` if required environment variables are missing
49    pub fn from_env() -> Result<Self, EmailError> {
50        let host = std::env::var("SMTP_HOST")
51            .map_err(|_| EmailError::config("SMTP_HOST environment variable not set"))?;
52
53        let port = std::env::var("SMTP_PORT")
54            .unwrap_or_else(|_| "587".to_string())
55            .parse()
56            .map_err(|_| EmailError::config("SMTP_PORT must be a valid port number"))?;
57
58        let username = std::env::var("SMTP_USERNAME")
59            .map_err(|_| EmailError::config("SMTP_USERNAME environment variable not set"))?;
60
61        let password = std::env::var("SMTP_PASSWORD")
62            .map_err(|_| EmailError::config("SMTP_PASSWORD environment variable not set"))?;
63
64        let use_tls = std::env::var("SMTP_USE_TLS")
65            .unwrap_or_else(|_| "true".to_string())
66            .parse()
67            .unwrap_or(true);
68
69        Ok(Self {
70            host,
71            port,
72            username,
73            password,
74            use_tls,
75        })
76    }
77}
78
79/// SMTP email backend
80///
81/// Sends emails via SMTP using the `lettre` crate.
82///
83/// # Examples
84///
85/// ```rust,no_run
86/// use acton_htmx::email::{Email, EmailSender, SmtpBackend};
87///
88/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
89/// // Create backend from environment variables
90/// let backend = SmtpBackend::from_env()?;
91///
92/// let email = Email::new()
93///     .to("user@example.com")
94///     .from("noreply@myapp.com")
95///     .subject("Hello!")
96///     .text("Hello, World!");
97///
98/// backend.send(email).await?;
99/// # Ok(())
100/// # }
101/// ```
102pub struct SmtpBackend {
103    config: SmtpConfig,
104}
105
106impl SmtpBackend {
107    /// Create a new SMTP backend with the given configuration
108    #[must_use]
109    pub const fn new(config: SmtpConfig) -> Self {
110        Self { config }
111    }
112
113    /// Create a new SMTP backend from environment variables
114    ///
115    /// # Errors
116    ///
117    /// Returns `EmailError::ConfigError` if required environment variables are missing
118    pub fn from_env() -> Result<Self, EmailError> {
119        let config = SmtpConfig::from_env()?;
120        Ok(Self::new(config))
121    }
122
123    /// Build lettre Message from Email
124    fn build_message(email: &Email) -> Result<Message, EmailError> {
125        // Validate email first
126        email.validate()?;
127
128        let from_addr = email.from.as_ref().ok_or(EmailError::NoSender)?;
129        let from: Mailbox = from_addr
130            .parse()
131            .map_err(|_| EmailError::InvalidAddress(from_addr.clone()))?;
132
133        // Start building message
134        let mut builder = Message::builder().from(from);
135
136        // Add recipients
137        for to_addr in &email.to {
138            let to: Mailbox = to_addr
139                .parse()
140                .map_err(|_| EmailError::InvalidAddress(to_addr.clone()))?;
141            builder = builder.to(to);
142        }
143
144        // Add CC recipients
145        for cc_addr in &email.cc {
146            let cc: Mailbox = cc_addr
147                .parse()
148                .map_err(|_| EmailError::InvalidAddress(cc_addr.clone()))?;
149            builder = builder.cc(cc);
150        }
151
152        // Add BCC recipients
153        for bcc_addr in &email.bcc {
154            let bcc: Mailbox = bcc_addr
155                .parse()
156                .map_err(|_| EmailError::InvalidAddress(bcc_addr.clone()))?;
157            builder = builder.bcc(bcc);
158        }
159
160        // Add Reply-To
161        if let Some(reply_to_addr) = &email.reply_to {
162            let reply_to: Mailbox = reply_to_addr
163                .parse()
164                .map_err(|_| EmailError::InvalidAddress(reply_to_addr.clone()))?;
165            builder = builder.reply_to(reply_to);
166        }
167
168        // Add subject
169        let subject = email.subject.as_ref().ok_or(EmailError::NoSubject)?;
170        builder = builder.subject(subject);
171
172        // Note: Custom headers (X-Priority, X-Campaign-ID, etc.) are not currently supported.
173        // This is intentional to keep the API simple and backend-agnostic.
174        // See docs/guides/09-email.md "Custom Headers" section for workarounds.
175        // Planned for Phase 3 if there's sufficient user demand.
176
177        // Build multipart message if we have both HTML and text
178        let message = if let (Some(html), Some(text)) = (&email.html, &email.text) {
179            builder
180                .multipart(
181                    MultiPart::alternative()
182                        .singlepart(
183                            SinglePart::builder()
184                                .header(header::ContentType::TEXT_PLAIN)
185                                .body(text.clone()),
186                        )
187                        .singlepart(
188                            SinglePart::builder()
189                                .header(header::ContentType::TEXT_HTML)
190                                .body(html.clone()),
191                        ),
192                )
193                .map_err(|e| EmailError::smtp(e.to_string()))?
194        } else if let Some(html) = &email.html {
195            builder
196                .header(header::ContentType::TEXT_HTML)
197                .body(html.clone())
198                .map_err(|e| EmailError::smtp(e.to_string()))?
199        } else if let Some(text) = &email.text {
200            builder
201                .header(header::ContentType::TEXT_PLAIN)
202                .body(text.clone())
203                .map_err(|e| EmailError::smtp(e.to_string()))?
204        } else {
205            return Err(EmailError::NoContent);
206        };
207
208        Ok(message)
209    }
210
211    /// Create SMTP transport from config
212    fn create_transport(&self) -> Result<AsyncSmtpTransport<Tokio1Executor>, EmailError> {
213        let credentials = Credentials::new(
214            self.config.username.clone(),
215            self.config.password.clone(),
216        );
217
218        let mut transport = if self.config.use_tls {
219            let tls_parameters = TlsParameters::new(self.config.host.clone())
220                .map_err(|e| EmailError::smtp(format!("TLS parameters error: {e}")))?;
221
222            AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.config.host)
223                .map_err(|e| EmailError::smtp(e.to_string()))?
224                .credentials(credentials)
225                .tls(Tls::Required(tls_parameters))
226        } else {
227            AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.config.host)
228                .credentials(credentials)
229        };
230
231        transport = transport.port(self.config.port);
232
233        Ok(transport.build())
234    }
235}
236
237#[async_trait]
238impl EmailSender for SmtpBackend {
239    async fn send(&self, email: Email) -> Result<(), EmailError> {
240        let message = Self::build_message(&email)?;
241        let transport = self.create_transport()?;
242
243        transport
244            .send(message)
245            .await
246            .map_err(|e| EmailError::smtp(e.to_string()))?;
247
248        Ok(())
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_smtp_config_from_env() {
258        std::env::set_var("SMTP_HOST", "smtp.example.com");
259        std::env::set_var("SMTP_PORT", "587");
260        std::env::set_var("SMTP_USERNAME", "user@example.com");
261        std::env::set_var("SMTP_PASSWORD", "password123");
262        std::env::set_var("SMTP_USE_TLS", "true");
263
264        let config = SmtpConfig::from_env().unwrap();
265
266        assert_eq!(config.host, "smtp.example.com");
267        assert_eq!(config.port, 587);
268        assert_eq!(config.username, "user@example.com");
269        assert_eq!(config.password, "password123");
270        assert!(config.use_tls);
271    }
272
273    #[test]
274    fn test_smtp_config_defaults() {
275        std::env::remove_var("SMTP_PORT");
276        std::env::remove_var("SMTP_USE_TLS");
277        std::env::set_var("SMTP_HOST", "smtp.example.com");
278        std::env::set_var("SMTP_USERNAME", "user@example.com");
279        std::env::set_var("SMTP_PASSWORD", "password123");
280
281        let config = SmtpConfig::from_env().unwrap();
282
283        assert_eq!(config.port, 587); // default
284        assert!(config.use_tls); // default
285    }
286
287    #[test]
288    fn test_build_message_simple() {
289        let email = Email::new()
290            .to("recipient@example.com")
291            .from("sender@example.com")
292            .subject("Test Email")
293            .text("This is a test email");
294
295        let message = SmtpBackend::build_message(&email);
296        assert!(message.is_ok());
297    }
298
299    #[test]
300    fn test_build_message_with_html_and_text() {
301        let email = Email::new()
302            .to("recipient@example.com")
303            .from("sender@example.com")
304            .subject("Test Email")
305            .text("This is plain text")
306            .html("<h1>This is HTML</h1>");
307
308        let message = SmtpBackend::build_message(&email);
309        assert!(message.is_ok());
310    }
311
312    #[test]
313    fn test_build_message_with_cc_and_bcc() {
314        let email = Email::new()
315            .to("recipient@example.com")
316            .cc("cc@example.com")
317            .bcc("bcc@example.com")
318            .from("sender@example.com")
319            .subject("Test Email")
320            .text("Test content");
321
322        let message = SmtpBackend::build_message(&email);
323        assert!(message.is_ok());
324    }
325}