Skip to main content

rustack_ses_core/
template.rs

1//! Template store and rendering for SES email templates.
2//!
3//! Templates contain `{{variable}}` placeholders that are substituted
4//! with values from a JSON data object during rendering.
5
6use dashmap::{DashMap, mapref::entry::Entry};
7use rustack_ses_model::{
8    error::{SesError, SesErrorCode},
9    types::{Template, TemplateMetadata},
10};
11
12/// Store for email templates.
13///
14/// Templates are keyed by name and contain subject, text body, and HTML body
15/// with `{{variable}}` placeholders for substitution.
16#[derive(Debug)]
17pub struct TemplateStore {
18    templates: DashMap<String, StoredTemplate>,
19}
20
21/// An email template as stored internally.
22#[derive(Debug, Clone)]
23pub struct StoredTemplate {
24    /// The model template data.
25    pub template: Template,
26    /// Creation timestamp.
27    pub created_timestamp: chrono::DateTime<chrono::Utc>,
28}
29
30impl Default for TemplateStore {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl TemplateStore {
37    /// Create a new empty template store.
38    #[must_use]
39    pub fn new() -> Self {
40        Self {
41            templates: DashMap::new(),
42        }
43    }
44
45    /// Create a new template.
46    ///
47    /// # Errors
48    ///
49    /// Returns `AlreadyExistsException` if a template with the same name already exists.
50    pub fn create(&self, template: Template) -> Result<(), SesError> {
51        let name = template.template_name.clone();
52        match self.templates.entry(name) {
53            Entry::Occupied(_) => Err(SesError::with_message(
54                SesErrorCode::AlreadyExistsException,
55                format!("Template {} already exists.", template.template_name),
56            )),
57            Entry::Vacant(e) => {
58                e.insert(StoredTemplate {
59                    template,
60                    created_timestamp: chrono::Utc::now(),
61                });
62                Ok(())
63            }
64        }
65    }
66
67    /// Get a template by name.
68    ///
69    /// # Errors
70    ///
71    /// Returns `TemplateDoesNotExistException` if the template is not found.
72    pub fn get(&self, name: &str) -> Result<Template, SesError> {
73        self.templates
74            .get(name)
75            .map(|entry| entry.template.clone())
76            .ok_or_else(|| {
77                SesError::with_message(
78                    SesErrorCode::TemplateDoesNotExistException,
79                    format!("Template {name} does not exist."),
80                )
81            })
82    }
83
84    /// Update an existing template.
85    ///
86    /// # Errors
87    ///
88    /// Returns `TemplateDoesNotExistException` if the template is not found.
89    pub fn update(&self, template: Template) -> Result<(), SesError> {
90        let name = template.template_name.clone();
91        let mut entry = self.templates.get_mut(&name).ok_or_else(|| {
92            SesError::with_message(
93                SesErrorCode::TemplateDoesNotExistException,
94                format!("Template {name} does not exist."),
95            )
96        })?;
97        entry.template = template;
98        Ok(())
99    }
100
101    /// Delete a template by name. No error if the template does not exist.
102    pub fn delete(&self, name: &str) {
103        self.templates.remove(name);
104    }
105
106    /// List all templates as metadata.
107    #[must_use]
108    pub fn list(&self) -> Vec<TemplateMetadata> {
109        self.templates
110            .iter()
111            .map(|entry| TemplateMetadata {
112                name: Some(entry.template.template_name.clone()),
113                created_timestamp: Some(entry.created_timestamp),
114            })
115            .collect()
116    }
117}
118
119/// Render a template by substituting `{{variable}}` placeholders
120/// with values from the `template_data` JSON.
121///
122/// Uses simple Mustache-style substitution. Does not support conditionals,
123/// loops, or any advanced Handlebars features.
124///
125/// # Errors
126///
127/// Returns `InvalidTemplateException` if the template data is not valid JSON
128/// or is not a JSON object.
129pub fn render_template(template_text: &str, template_data: &str) -> Result<String, SesError> {
130    let data: serde_json::Value = serde_json::from_str(template_data).map_err(|e| {
131        SesError::with_message(
132            SesErrorCode::InvalidTemplateException,
133            format!("Invalid template data JSON: {e}"),
134        )
135    })?;
136
137    let data_map = data.as_object().ok_or_else(|| {
138        SesError::with_message(
139            SesErrorCode::InvalidTemplateException,
140            "Template data must be a JSON object",
141        )
142    })?;
143
144    let mut result = template_text.to_owned();
145    for (key, value) in data_map {
146        let placeholder = format!("{{{{{key}}}}}");
147        let replacement = match value {
148            serde_json::Value::String(s) => s.clone(),
149            serde_json::Value::Null => String::new(),
150            other => other.to_string(),
151        };
152        result = result.replace(&placeholder, &replacement);
153    }
154
155    Ok(result)
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn make_template(name: &str) -> Template {
163        Template {
164            template_name: name.to_owned(),
165            subject_part: Some("Hello {{name}}".to_owned()),
166            text_part: Some("Dear {{name}}, welcome!".to_owned()),
167            html_part: Some("<p>Dear {{name}}, welcome!</p>".to_owned()),
168        }
169    }
170
171    #[test]
172    fn test_should_create_and_get_template() {
173        let store = TemplateStore::new();
174        let tmpl = make_template("welcome");
175        store.create(tmpl).unwrap_or_default();
176        let retrieved = store.get("welcome");
177        assert!(retrieved.is_ok());
178        assert_eq!(retrieved.unwrap_or_default().template_name, "welcome");
179    }
180
181    #[test]
182    fn test_should_reject_duplicate_template() {
183        let store = TemplateStore::new();
184        store.create(make_template("dup")).unwrap_or_default();
185        let result = store.create(make_template("dup"));
186        assert!(result.is_err());
187    }
188
189    #[test]
190    fn test_should_update_template() {
191        let store = TemplateStore::new();
192        store.create(make_template("upd")).unwrap_or_default();
193        let mut updated = make_template("upd");
194        updated.subject_part = Some("Updated {{name}}".to_owned());
195        store.update(updated).unwrap_or_default();
196        let retrieved = store.get("upd").unwrap_or_default();
197        assert_eq!(retrieved.subject_part, Some("Updated {{name}}".to_owned()));
198    }
199
200    #[test]
201    fn test_should_return_error_on_update_nonexistent() {
202        let store = TemplateStore::new();
203        let result = store.update(make_template("nope"));
204        assert!(result.is_err());
205    }
206
207    #[test]
208    fn test_should_delete_template() {
209        let store = TemplateStore::new();
210        store.create(make_template("del")).unwrap_or_default();
211        store.delete("del");
212        assert!(store.get("del").is_err());
213    }
214
215    #[test]
216    fn test_should_list_templates() {
217        let store = TemplateStore::new();
218        store.create(make_template("a")).unwrap_or_default();
219        store.create(make_template("b")).unwrap_or_default();
220        let list = store.list();
221        assert_eq!(list.len(), 2);
222    }
223
224    #[test]
225    fn test_should_render_simple_template() {
226        let result = render_template("Hello {{name}}", r#"{"name":"World"}"#);
227        assert_eq!(result.unwrap_or_default(), "Hello World");
228    }
229
230    #[test]
231    fn test_should_render_multiple_variables() {
232        let result = render_template(
233            "{{greeting}} {{name}}!",
234            r#"{"greeting":"Hi","name":"Alice"}"#,
235        );
236        assert_eq!(result.unwrap_or_default(), "Hi Alice!");
237    }
238
239    #[test]
240    fn test_should_leave_unmatched_placeholders() {
241        let result = render_template("Hello {{name}} {{missing}}", r#"{"name":"World"}"#);
242        assert_eq!(result.unwrap_or_default(), "Hello World {{missing}}");
243    }
244
245    #[test]
246    fn test_should_handle_null_values() {
247        let result = render_template("Value: {{x}}", r#"{"x":null}"#);
248        assert_eq!(result.unwrap_or_default(), "Value: ");
249    }
250
251    #[test]
252    fn test_should_handle_numeric_values() {
253        let result = render_template("Count: {{n}}", r#"{"n":42}"#);
254        assert_eq!(result.unwrap_or_default(), "Count: 42");
255    }
256
257    #[test]
258    fn test_should_reject_invalid_json() {
259        let result = render_template("Hello", "not json");
260        assert!(result.is_err());
261    }
262
263    #[test]
264    fn test_should_reject_non_object_json() {
265        let result = render_template("Hello", "[1,2,3]");
266        assert!(result.is_err());
267    }
268
269    #[test]
270    fn test_should_handle_empty_data() {
271        let result = render_template("Hello {{name}}", "{}");
272        assert_eq!(result.unwrap_or_default(), "Hello {{name}}");
273    }
274}