armature_mail/
template_handlebars.rs

1//! Handlebars template engine integration.
2
3use handlebars::Handlebars;
4use std::path::Path;
5use tracing::debug;
6
7use crate::{MailError, RenderedTemplate, Result, TemplateEngine};
8
9/// Handlebars-based template engine for emails.
10pub struct HandlebarsEngine {
11    handlebars: Handlebars<'static>,
12}
13
14impl HandlebarsEngine {
15    /// Create a new Handlebars engine.
16    pub fn new() -> Self {
17        let mut handlebars = Handlebars::new();
18        handlebars.set_strict_mode(true);
19        Self { handlebars }
20    }
21
22    /// Load templates from a directory.
23    ///
24    /// Expected structure:
25    /// ```text
26    /// templates/
27    ///   welcome/
28    ///     subject.hbs      (optional)
29    ///     html.hbs
30    ///     text.hbs         (optional)
31    ///   password_reset/
32    ///     subject.hbs
33    ///     html.hbs
34    ///     text.hbs
35    /// ```
36    pub fn from_directory(path: impl AsRef<Path>) -> Result<Self> {
37        let mut engine = Self::new();
38        let path = path.as_ref();
39
40        if !path.exists() {
41            return Err(MailError::Config(format!(
42                "Template directory not found: {}",
43                path.display()
44            )));
45        }
46
47        for entry in std::fs::read_dir(path)? {
48            let entry = entry?;
49            let entry_path = entry.path();
50
51            if entry_path.is_dir() {
52                let template_name =
53                    entry_path
54                        .file_name()
55                        .and_then(|n| n.to_str())
56                        .ok_or_else(|| {
57                            MailError::Config("Invalid template directory name".to_string())
58                        })?;
59
60                // Load HTML template
61                let html_path = entry_path.join("html.hbs");
62                if html_path.exists() {
63                    let content = std::fs::read_to_string(&html_path)?;
64                    engine
65                        .handlebars
66                        .register_template_string(&format!("{}/html", template_name), content)?;
67                }
68
69                // Load text template
70                let text_path = entry_path.join("text.hbs");
71                if text_path.exists() {
72                    let content = std::fs::read_to_string(&text_path)?;
73                    engine
74                        .handlebars
75                        .register_template_string(&format!("{}/text", template_name), content)?;
76                }
77
78                // Load subject template
79                let subject_path = entry_path.join("subject.hbs");
80                if subject_path.exists() {
81                    let content = std::fs::read_to_string(&subject_path)?;
82                    engine
83                        .handlebars
84                        .register_template_string(&format!("{}/subject", template_name), content)?;
85                }
86
87                debug!(template = template_name, "Loaded email template");
88            }
89        }
90
91        Ok(engine)
92    }
93
94    /// Register helpers.
95    pub fn register_helper<H: handlebars::HelperDef + Send + Sync + 'static>(
96        mut self,
97        name: &str,
98        helper: H,
99    ) -> Self {
100        self.handlebars.register_helper(name, Box::new(helper));
101        self
102    }
103
104    /// Register a partial template.
105    pub fn register_partial(mut self, name: &str, content: &str) -> Result<Self> {
106        self.handlebars.register_partial(name, content)?;
107        Ok(self)
108    }
109}
110
111impl Default for HandlebarsEngine {
112    fn default() -> Self {
113        Self::new()
114    }
115}
116
117impl TemplateEngine for HandlebarsEngine {
118    fn render(&self, name: &str, context: &serde_json::Value) -> Result<RenderedTemplate> {
119        let html = if self.handlebars.has_template(&format!("{}/html", name)) {
120            Some(self.handlebars.render(&format!("{}/html", name), context)?)
121        } else {
122            None
123        };
124
125        let text = if self.handlebars.has_template(&format!("{}/text", name)) {
126            Some(self.handlebars.render(&format!("{}/text", name), context)?)
127        } else {
128            None
129        };
130
131        let subject = if self.handlebars.has_template(&format!("{}/subject", name)) {
132            Some(
133                self.handlebars
134                    .render(&format!("{}/subject", name), context)?
135                    .trim()
136                    .to_string(),
137            )
138        } else {
139            None
140        };
141
142        if html.is_none() && text.is_none() {
143            return Err(MailError::TemplateNotFound(name.to_string()));
144        }
145
146        Ok(RenderedTemplate {
147            html,
148            text,
149            subject,
150        })
151    }
152
153    fn has_template(&self, name: &str) -> bool {
154        self.handlebars.has_template(&format!("{}/html", name))
155            || self.handlebars.has_template(&format!("{}/text", name))
156    }
157
158    fn register_template(&mut self, name: &str, content: &str) -> Result<()> {
159        self.handlebars
160            .register_template_string(&format!("{}/html", name), content)?;
161        Ok(())
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use serde_json::json;
169
170    #[test]
171    fn test_handlebars_render() {
172        let mut engine = HandlebarsEngine::new();
173        engine
174            .handlebars
175            .register_template_string("test/html", "<h1>Hello, {{name}}!</h1>")
176            .unwrap();
177        engine
178            .handlebars
179            .register_template_string("test/text", "Hello, {{name}}!")
180            .unwrap();
181        engine
182            .handlebars
183            .register_template_string("test/subject", "Welcome {{name}}")
184            .unwrap();
185
186        let result = engine.render("test", &json!({"name": "World"})).unwrap();
187
188        assert_eq!(result.html.as_deref(), Some("<h1>Hello, World!</h1>"));
189        assert_eq!(result.text.as_deref(), Some("Hello, World!"));
190        assert_eq!(result.subject.as_deref(), Some("Welcome World"));
191    }
192}