1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::ChorusError;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Template {
10 pub slug: String,
12 pub name: String,
14 pub subject: String,
16 pub html_body: String,
18 pub text_body: String,
20 pub variables: Vec<String>,
22}
23
24#[derive(Debug, Clone)]
26pub struct RenderedTemplate {
27 pub subject: String,
28 pub html_body: String,
29 pub text_body: String,
30}
31
32impl Template {
33 pub fn render(
38 &self,
39 variables: &HashMap<String, String>,
40 ) -> Result<RenderedTemplate, ChorusError> {
41 let subject = render_string(&self.subject, variables)?;
42 let html_body = render_string(&self.html_body, variables)?;
43 let text_body = render_string(&self.text_body, variables)?;
44
45 Ok(RenderedTemplate {
46 subject,
47 html_body,
48 text_body,
49 })
50 }
51}
52
53fn render_string(
55 template: &str,
56 variables: &HashMap<String, String>,
57) -> Result<String, ChorusError> {
58 let env = minijinja::Environment::new();
59 let ctx = minijinja::value::Value::from_serialize(variables);
60 env.render_str(template, ctx)
61 .map_err(|e| ChorusError::Validation(format!("template render error: {}", e)))
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 fn make_template(subject: &str, html: &str, text: &str) -> Template {
69 Template {
70 slug: "test".into(),
71 name: "Test".into(),
72 subject: subject.into(),
73 html_body: html.into(),
74 text_body: text.into(),
75 variables: vec![],
76 }
77 }
78
79 #[test]
80 fn renders_simple_variables() {
81 let t = make_template(
82 "Hello {{ name }}",
83 "<p>Hi {{ name }}, code: {{ code }}</p>",
84 "Hi {{ name }}, code: {{ code }}",
85 );
86 let vars = HashMap::from([
87 ("name".into(), "Alice".into()),
88 ("code".into(), "123456".into()),
89 ]);
90 let r = t.render(&vars).unwrap();
91 assert_eq!(r.subject, "Hello Alice");
92 assert_eq!(r.html_body, "<p>Hi Alice, code: 123456</p>");
93 assert_eq!(r.text_body, "Hi Alice, code: 123456");
94 }
95
96 #[test]
97 fn undefined_variables_render_empty() {
98 let t = make_template("{{ missing }}", "", "");
99 let r = t.render(&HashMap::new()).unwrap();
100 assert_eq!(r.subject, "");
101 }
102
103 #[test]
104 fn repeated_variables() {
105 let t = make_template("{{ x }} and {{ x }}", "", "");
106 let vars = HashMap::from([("x".into(), "hi".into())]);
107 let r = t.render(&vars).unwrap();
108 assert_eq!(r.subject, "hi and hi");
109 }
110
111 #[test]
112 fn empty_template() {
113 let t = make_template("", "", "");
114 let r = t.render(&HashMap::new()).unwrap();
115 assert_eq!(r.subject, "");
116 }
117
118 #[test]
119 fn no_placeholders() {
120 let t = make_template("Hello world", "<p>Hi</p>", "Hi");
121 let r = t.render(&HashMap::new()).unwrap();
122 assert_eq!(r.subject, "Hello world");
123 }
124
125 #[test]
126 fn if_else_conditional() {
127 let t = make_template(
128 "{% if name %}Hi {{ name }}{% else %}Hi there{% endif %}",
129 "",
130 "",
131 );
132 let with_name = HashMap::from([("name".into(), "Bob".into())]);
133 assert_eq!(t.render(&with_name).unwrap().subject, "Hi Bob");
134
135 let without_name = HashMap::new();
136 assert_eq!(t.render(&without_name).unwrap().subject, "Hi there");
137 }
138
139 #[test]
140 fn for_loop() {
141 let env = minijinja::Environment::new();
142 let ctx = minijinja::context! { items => vec!["a", "b", "c"] };
143 let result = env
144 .render_str("{% for item in items %}{{ item }} {% endfor %}", ctx)
145 .unwrap();
146 assert_eq!(result, "a b c ");
147 }
148
149 #[test]
150 fn default_filter() {
151 let t = make_template("{{ name | default('Guest') }}", "", "");
152 let r = t.render(&HashMap::new()).unwrap();
153 assert_eq!(r.subject, "Guest");
154 }
155
156 #[test]
157 fn special_characters_in_values() {
158 let t = make_template("{{ val }}", "", "");
159 let vars = HashMap::from([("val".into(), "<script>alert('xss')</script>".into())]);
160 let r = t.render(&vars).unwrap();
161 assert!(r.subject.contains("<script>") || r.subject.contains("<script>"));
163 }
164}