Skip to main content

what_core/email/
mod.rs

1//! Email sending for What projects.
2//!
3//! Supports SMTP (via `lettre`) and API providers (Resend via `reqwest`).
4//! Email templates live in the project's `emails/` directory and are rendered
5//! through the same `#variable#` engine used for pages.
6
7use 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/// An email message ready to send.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct EmailMessage {
20    /// Recipient email address
21    pub to: String,
22
23    /// Email subject line (supports `#variable#` syntax)
24    pub subject: String,
25
26    /// HTML body
27    pub html_body: String,
28
29    /// Optional plain-text body (fallback for non-HTML clients)
30    #[serde(default)]
31    pub text_body: Option<String>,
32}
33
34/// Send an email using the configured transport.
35///
36/// Resolves `${ENV_VAR}` references in SMTP credentials / API keys at send time.
37pub 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
49/// Render an email template from the `emails/` directory.
50///
51/// Reads `<project_root>/<template_dir>/<template_name>.html`, then runs
52/// the template engine's variable replacement with the given context.
53pub 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
74// ---------------------------------------------------------------------------
75// SMTP transport (lettre)
76// ---------------------------------------------------------------------------
77
78async 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    // Build "From" address
88    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    // Build the email
105    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    // Build SMTP transport
134    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
154// ---------------------------------------------------------------------------
155// API transport (Resend)
156// ---------------------------------------------------------------------------
157
158async 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
219// ---------------------------------------------------------------------------
220// Helpers
221// ---------------------------------------------------------------------------
222
223/// Resolve `${ENV_VAR}` references in a string value.
224fn 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// ---------------------------------------------------------------------------
236// Tests
237// ---------------------------------------------------------------------------
238
239#[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        // Falls back to the raw string when env var is missing
355        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"); // default
417        assert!(email.from_name.is_none());
418
419        let smtp = email.smtp.unwrap();
420        assert_eq!(smtp.port, 587); // default
421        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}