acton_htmx/email/backend/
smtp.rs1use 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#[derive(Debug, Clone)]
19pub struct SmtpConfig {
20 pub host: String,
22
23 pub port: u16,
25
26 pub username: String,
28
29 pub password: String,
31
32 pub use_tls: bool,
34}
35
36impl SmtpConfig {
37 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
79pub struct SmtpBackend {
103 config: SmtpConfig,
104}
105
106impl SmtpBackend {
107 #[must_use]
109 pub const fn new(config: SmtpConfig) -> Self {
110 Self { config }
111 }
112
113 pub fn from_env() -> Result<Self, EmailError> {
119 let config = SmtpConfig::from_env()?;
120 Ok(Self::new(config))
121 }
122
123 fn build_message(email: &Email) -> Result<Message, EmailError> {
125 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 let mut builder = Message::builder().from(from);
135
136 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 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 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 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 let subject = email.subject.as_ref().ok_or(EmailError::NoSubject)?;
170 builder = builder.subject(subject);
171
172 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 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); assert!(config.use_tls); }
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}