1use std::collections::HashMap;
8use std::path::Path;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::Result;
14use crate::config::{EmailApiConfig, EmailConfig, SmtpConfig};
15use crate::parser::replace_variables;
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct EmailMessage {
20 pub to: String,
22
23 pub subject: String,
25
26 pub html_body: String,
28
29 #[serde(default)]
31 pub text_body: Option<String>,
32}
33
34pub async fn send_email(config: &EmailConfig, message: &EmailMessage) -> Result<()> {
38 if let Some(ref smtp) = config.smtp {
39 send_via_smtp(config, smtp, message).await
40 } else if let Some(ref api) = config.api {
41 send_via_api(config, api, message).await
42 } else {
43 Err(crate::Error::Config(
44 "Email config has neither [email.smtp] nor [email.api] section".into(),
45 ))
46 }
47}
48
49pub fn render_email_template(
54 project_root: &Path,
55 template_dir: &str,
56 template_name: &str,
57 context: &HashMap<String, Value>,
58) -> Result<String> {
59 let template_path = project_root
60 .join(template_dir)
61 .join(format!("{}.html", template_name));
62
63 let template = std::fs::read_to_string(&template_path).map_err(|e| {
64 crate::Error::Config(format!(
65 "Failed to read email template {}: {}",
66 template_path.display(),
67 e
68 ))
69 })?;
70
71 Ok(replace_variables(&template, context))
72}
73
74async fn send_via_smtp(
79 config: &EmailConfig,
80 smtp: &SmtpConfig,
81 message: &EmailMessage,
82) -> Result<()> {
83 use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
84 use lettre::transport::smtp::authentication::Credentials;
85 use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
86
87 let from: Mailbox = if let Some(ref name) = config.from_name {
89 format!("{} <{}>", name, config.from)
90 .parse()
91 .map_err(|e| crate::Error::Config(format!("Invalid from address: {}", e)))?
92 } else {
93 config
94 .from
95 .parse()
96 .map_err(|e| crate::Error::Config(format!("Invalid from address: {}", e)))?
97 };
98
99 let to: Mailbox = message
100 .to
101 .parse()
102 .map_err(|e| crate::Error::Config(format!("Invalid to address: {}", e)))?;
103
104 let builder = Message::builder()
106 .from(from)
107 .to(to)
108 .subject(&message.subject);
109
110 let email = if let Some(ref text) = message.text_body {
111 builder
112 .multipart(
113 MultiPart::alternative()
114 .singlepart(
115 SinglePart::builder()
116 .header(ContentType::TEXT_PLAIN)
117 .body(text.clone()),
118 )
119 .singlepart(
120 SinglePart::builder()
121 .header(ContentType::TEXT_HTML)
122 .body(message.html_body.clone()),
123 ),
124 )
125 .map_err(|e| crate::Error::Config(format!("Failed to build email: {}", e)))?
126 } else {
127 builder
128 .header(ContentType::TEXT_HTML)
129 .body(message.html_body.clone())
130 .map_err(|e| crate::Error::Config(format!("Failed to build email: {}", e)))?
131 };
132
133 let mut transport_builder = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&smtp.host)
135 .map_err(|e| crate::Error::Config(format!("SMTP relay error: {}", e)))?
136 .port(smtp.port);
137
138 if let (Some(user), Some(pass)) = (&smtp.username, &smtp.password) {
139 let creds = Credentials::new(resolve_env(user), resolve_env(pass));
140 transport_builder = transport_builder.credentials(creds);
141 }
142
143 let transport = transport_builder.build();
144
145 transport
146 .send(email)
147 .await
148 .map_err(|e| crate::Error::Config(format!("SMTP send failed: {}", e)))?;
149
150 tracing::info!("Email sent via SMTP to {}", message.to);
151 Ok(())
152}
153
154async fn send_via_api(
159 config: &EmailConfig,
160 api: &EmailApiConfig,
161 message: &EmailMessage,
162) -> Result<()> {
163 match api.provider.as_str() {
164 "resend" => send_via_resend(config, api, message).await,
165 other => Err(crate::Error::Config(format!(
166 "Unknown email API provider: {}",
167 other
168 ))),
169 }
170}
171
172async fn send_via_resend(
173 config: &EmailConfig,
174 api: &EmailApiConfig,
175 message: &EmailMessage,
176) -> Result<()> {
177 let api_key = resolve_env(&api.api_key);
178
179 let from_str = if let Some(ref name) = config.from_name {
180 format!("{} <{}>", name, config.from)
181 } else {
182 config.from.clone()
183 };
184
185 let mut body = serde_json::json!({
186 "from": from_str,
187 "to": [&message.to],
188 "subject": &message.subject,
189 "html": &message.html_body,
190 });
191
192 if let Some(ref text) = message.text_body {
193 body["text"] = serde_json::json!(text);
194 }
195
196 let client = crate::http_client::build_http_client(None)
197 .map_err(|e| crate::Error::Config(format!("Failed to build Resend HTTP client: {}", e)))?;
198 let resp = client
199 .post("https://api.resend.com/emails")
200 .header("Authorization", format!("Bearer {}", api_key))
201 .json(&body)
202 .send()
203 .await
204 .map_err(|e| crate::Error::Config(format!("Resend API request failed: {}", e)))?;
205
206 if !resp.status().is_success() {
207 let status = resp.status();
208 let text = resp.text().await.unwrap_or_default();
209 return Err(crate::Error::Config(format!(
210 "Resend API error ({}): {}",
211 status, text
212 )));
213 }
214
215 tracing::info!("Email sent via Resend API to {}", message.to);
216 Ok(())
217}
218
219fn resolve_env(value: &str) -> String {
225 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
226 std::env::var(var_name).unwrap_or_else(|_| {
227 tracing::warn!("Environment variable {} not found", var_name);
228 value.to_string()
229 })
230 } else {
231 value.to_string()
232 }
233}
234
235#[cfg(test)]
240mod tests {
241 use super::*;
242 use tempfile::TempDir;
243
244 #[test]
245 fn test_email_message_fields() {
246 let msg = EmailMessage {
247 to: "user@example.com".into(),
248 subject: "Hello".into(),
249 html_body: "<h1>Hi</h1>".into(),
250 text_body: Some("Hi".into()),
251 };
252 assert_eq!(msg.to, "user@example.com");
253 assert_eq!(msg.subject, "Hello");
254 assert_eq!(msg.html_body, "<h1>Hi</h1>");
255 assert_eq!(msg.text_body.as_deref(), Some("Hi"));
256 }
257
258 #[test]
259 fn test_email_message_without_text_body() {
260 let msg = EmailMessage {
261 to: "user@example.com".into(),
262 subject: "Hello".into(),
263 html_body: "<h1>Hi</h1>".into(),
264 text_body: None,
265 };
266 assert!(msg.text_body.is_none());
267 }
268
269 #[test]
270 fn test_email_message_serialize_deserialize() {
271 let msg = EmailMessage {
272 to: "a@b.com".into(),
273 subject: "Test".into(),
274 html_body: "<p>body</p>".into(),
275 text_body: Some("body".into()),
276 };
277 let json = serde_json::to_string(&msg).unwrap();
278 let back: EmailMessage = serde_json::from_str(&json).unwrap();
279 assert_eq!(back.to, "a@b.com");
280 assert_eq!(back.subject, "Test");
281 assert_eq!(back.html_body, "<p>body</p>");
282 assert_eq!(back.text_body.as_deref(), Some("body"));
283 }
284
285 #[test]
286 fn test_email_message_deserialize_without_text_body() {
287 let json = r#"{"to":"a@b.com","subject":"Hi","html_body":"<b>hi</b>"}"#;
288 let msg: EmailMessage = serde_json::from_str(json).unwrap();
289 assert!(msg.text_body.is_none());
290 }
291
292 #[test]
293 fn test_render_email_template_basic() {
294 let tmp = TempDir::new().unwrap();
295 let emails_dir = tmp.path().join("emails");
296 std::fs::create_dir_all(&emails_dir).unwrap();
297 std::fs::write(
298 emails_dir.join("welcome.html"),
299 "<h1>Hello #name#!</h1><p>Your order #order_id# is confirmed.</p>",
300 )
301 .unwrap();
302
303 let mut ctx = HashMap::new();
304 ctx.insert("name".into(), serde_json::json!("Alice"));
305 ctx.insert("order_id".into(), serde_json::json!("ORD-123"));
306
307 let result = render_email_template(tmp.path(), "emails", "welcome", &ctx).unwrap();
308 assert!(result.contains("Hello Alice!"), "got: {}", result);
309 assert!(result.contains("ORD-123"), "got: {}", result);
310 }
311
312 #[test]
313 fn test_render_email_template_missing_file() {
314 let tmp = TempDir::new().unwrap();
315 let result = render_email_template(tmp.path(), "emails", "nonexistent", &HashMap::new());
316 assert!(result.is_err());
317 }
318
319 #[test]
320 fn test_render_email_template_with_filters() {
321 let tmp = TempDir::new().unwrap();
322 let emails_dir = tmp.path().join("emails");
323 std::fs::create_dir_all(&emails_dir).unwrap();
324 std::fs::write(emails_dir.join("notify.html"), "<p>#name|uppercase#</p>").unwrap();
325
326 let mut ctx = HashMap::new();
327 ctx.insert("name".into(), serde_json::json!("alice"));
328
329 let result = render_email_template(tmp.path(), "emails", "notify", &ctx).unwrap();
330 assert!(result.contains("ALICE"), "got: {}", result);
331 }
332
333 #[test]
334 fn test_resolve_env_with_env_var() {
335 unsafe {
336 std::env::set_var("WHAT_TEST_EMAIL_KEY", "secret123");
337 }
338 let resolved = resolve_env("${WHAT_TEST_EMAIL_KEY}");
339 assert_eq!(resolved, "secret123");
340 unsafe {
341 std::env::remove_var("WHAT_TEST_EMAIL_KEY");
342 }
343 }
344
345 #[test]
346 fn test_resolve_env_plain_string() {
347 let resolved = resolve_env("plain-value");
348 assert_eq!(resolved, "plain-value");
349 }
350
351 #[test]
352 fn test_resolve_env_missing_var() {
353 let resolved = resolve_env("${WHAT_NONEXISTENT_EMAIL_VAR_XYZ}");
354 assert_eq!(resolved, "${WHAT_NONEXISTENT_EMAIL_VAR_XYZ}");
356 }
357
358 #[test]
359 fn test_email_config_parsing() {
360 let toml_str = r#"
361[email]
362from = "app@example.com"
363from_name = "My App"
364template_dir = "mail-templates"
365
366[email.smtp]
367host = "smtp.example.com"
368port = 465
369username = "${SMTP_USER}"
370password = "${SMTP_PASS}"
371"#;
372 let config: crate::config::Config = toml::from_str(toml_str).unwrap();
373 let email = config.email.unwrap();
374 assert_eq!(email.from, "app@example.com");
375 assert_eq!(email.from_name.as_deref(), Some("My App"));
376 assert_eq!(email.template_dir, "mail-templates");
377
378 let smtp = email.smtp.unwrap();
379 assert_eq!(smtp.host, "smtp.example.com");
380 assert_eq!(smtp.port, 465);
381 assert_eq!(smtp.username.as_deref(), Some("${SMTP_USER}"));
382 assert_eq!(smtp.password.as_deref(), Some("${SMTP_PASS}"));
383 }
384
385 #[test]
386 fn test_email_config_api_provider() {
387 let toml_str = r#"
388[email]
389from = "noreply@example.com"
390
391[email.api]
392provider = "resend"
393api_key = "${RESEND_API_KEY}"
394"#;
395 let config: crate::config::Config = toml::from_str(toml_str).unwrap();
396 let email = config.email.unwrap();
397 assert_eq!(email.from, "noreply@example.com");
398 assert!(email.smtp.is_none());
399
400 let api = email.api.unwrap();
401 assert_eq!(api.provider, "resend");
402 assert_eq!(api.api_key, "${RESEND_API_KEY}");
403 }
404
405 #[test]
406 fn test_email_config_defaults() {
407 let toml_str = r#"
408[email]
409from = "app@example.com"
410
411[email.smtp]
412host = "smtp.example.com"
413"#;
414 let config: crate::config::Config = toml::from_str(toml_str).unwrap();
415 let email = config.email.unwrap();
416 assert_eq!(email.template_dir, "emails"); assert!(email.from_name.is_none());
418
419 let smtp = email.smtp.unwrap();
420 assert_eq!(smtp.port, 587); assert!(smtp.username.is_none());
422 assert!(smtp.password.is_none());
423 }
424
425 #[test]
426 fn test_config_without_email_section() {
427 let config: crate::config::Config = toml::from_str("").unwrap();
428 assert!(config.email.is_none());
429 }
430
431 #[tokio::test]
432 async fn test_send_email_no_transport_configured() {
433 let config = EmailConfig {
434 from: "test@example.com".into(),
435 from_name: None,
436 smtp: None,
437 api: None,
438 template_dir: "emails".into(),
439 };
440 let msg = EmailMessage {
441 to: "user@example.com".into(),
442 subject: "Test".into(),
443 html_body: "<p>Hi</p>".into(),
444 text_body: None,
445 };
446 let result = send_email(&config, &msg).await;
447 assert!(result.is_err());
448 let err = result.unwrap_err().to_string();
449 assert!(err.contains("neither"), "got: {}", err);
450 }
451
452 #[tokio::test]
453 async fn test_send_email_unknown_api_provider() {
454 let config = EmailConfig {
455 from: "test@example.com".into(),
456 from_name: None,
457 smtp: None,
458 api: Some(EmailApiConfig {
459 provider: "mailchimp".into(),
460 api_key: "key".into(),
461 }),
462 template_dir: "emails".into(),
463 };
464 let msg = EmailMessage {
465 to: "user@example.com".into(),
466 subject: "Test".into(),
467 html_body: "<p>Hi</p>".into(),
468 text_body: None,
469 };
470 let result = send_email(&config, &msg).await;
471 assert!(result.is_err());
472 let err = result.unwrap_err().to_string();
473 assert!(err.contains("Unknown email API provider"), "got: {}", err);
474 }
475}