1use crate::prelude::*;
10use cloudillo_core::settings::service::SettingsService;
11use cloudillo_core::settings::SettingValue;
12use handlebars::Handlebars;
13use serde::Deserialize;
14use std::sync::Arc;
15
16#[derive(Debug, Default, Deserialize)]
18pub struct TemplateMetadata {
19 #[serde(default)]
21 pub layout: Option<String>,
22 #[serde(default)]
24 pub subject: Option<String>,
25}
26
27#[derive(Debug)]
29pub struct RenderResult {
30 pub subject: Option<String>,
32 pub html_body: String,
34 pub text_body: String,
36}
37
38struct LayoutRenderParams<'a> {
40 template_dir: &'a str,
41 layout_name: &'a str,
42 extension: &'a str,
43 lang: Option<&'a str>,
44 body: &'a str,
45 title: Option<&'a str>,
46 vars: &'a serde_json::Value,
47}
48
49pub struct TemplateEngine {
51 handlebars: Handlebars<'static>,
52 settings_service: Arc<SettingsService>,
53}
54
55impl TemplateEngine {
56 pub fn new(settings_service: Arc<SettingsService>) -> ClResult<Self> {
58 let mut handlebars = Handlebars::new();
59
60 handlebars.set_strict_mode(true);
62
63 Ok(Self { handlebars, settings_service })
64 }
65
66 fn parse_frontmatter(content: &str) -> (TemplateMetadata, &str) {
79 let content = content.trim_start();
80
81 if !content.starts_with("---") {
83 return (TemplateMetadata::default(), content);
84 }
85
86 let after_first = &content[3..];
88 if let Some(end_pos) = after_first.find("\n---") {
89 let yaml_content = &after_first[..end_pos];
90 let template_content = &after_first[end_pos + 4..]; match serde_yaml::from_str(yaml_content) {
94 Ok(metadata) => (metadata, template_content.trim_start_matches('\n')),
95 Err(e) => {
96 warn!("Failed to parse frontmatter YAML: {}", e);
97 (TemplateMetadata::default(), content)
98 }
99 }
100 } else {
101 (TemplateMetadata::default(), content)
103 }
104 }
105
106 fn try_load_template(path: &str) -> Option<String> {
108 std::fs::read_to_string(path).ok()
109 }
110
111 fn resolve_template_path(
117 template_dir: &str,
118 template_name: &str,
119 extension: &str,
120 lang: Option<&str>,
121 ) -> ClResult<(String, String)> {
122 if let Some(lang) = lang {
124 let lang_path = format!("{}/{}.{}.{}", template_dir, template_name, lang, extension);
125 if let Some(content) = Self::try_load_template(&lang_path) {
126 debug!("Loaded language-specific template: {}", lang_path);
127 return Ok((lang_path, content));
128 }
129 }
130
131 let default_path = format!("{}/{}.{}", template_dir, template_name, extension);
133 match Self::try_load_template(&default_path) {
134 Some(content) => {
135 debug!("Loaded default template: {}", default_path);
136 Ok((default_path, content))
137 }
138 None => Err(Error::ConfigError(format!(
139 "Template not found: {} (tried language: {:?})",
140 default_path, lang
141 ))),
142 }
143 }
144
145 fn render_layout(&self, params: LayoutRenderParams<'_>) -> ClResult<String> {
147 let layouts_dir = format!("{}/layouts", params.template_dir);
148
149 let layout_content = if let Some(lang) = params.lang {
151 let lang_path =
152 format!("{}/{}.{}.{}", layouts_dir, params.layout_name, lang, params.extension);
153 Self::try_load_template(&lang_path)
154 } else {
155 None
156 };
157
158 let layout_content = layout_content.or_else(|| {
160 let default_path =
161 format!("{}/{}.{}", layouts_dir, params.layout_name, params.extension);
162 Self::try_load_template(&default_path)
163 });
164
165 let layout_content = layout_content.ok_or_else(|| {
166 Error::ConfigError(format!(
167 "Layout template not found: {}/{}.{} (lang: {:?})",
168 layouts_dir, params.layout_name, params.extension, params.lang
169 ))
170 })?;
171
172 let mut layout_vars = params.vars.clone();
174 if let serde_json::Value::Object(ref mut map) = layout_vars {
175 map.insert("body".to_string(), serde_json::Value::String(params.body.to_string()));
176 if let Some(title) = params.title {
177 map.insert("title".to_string(), serde_json::Value::String(title.to_string()));
178 }
179 }
180
181 self.handlebars.render_template(&layout_content, &layout_vars).map_err(|e| {
182 Error::ValidationError(format!(
183 "Failed to render layout '{}': {}",
184 params.layout_name, e
185 ))
186 })
187 }
188
189 pub async fn render(
202 &self,
203 tn_id: TnId,
204 template_name: &str,
205 vars: &serde_json::Value,
206 lang: Option<&str>,
207 ) -> ClResult<RenderResult> {
208 let template_dir = self.settings_service.get(tn_id, "email.template_dir").await?;
210
211 let template_dir = match template_dir {
212 SettingValue::String(dir) => dir,
213 _ => return Err(Error::ConfigError("Invalid template_dir setting".into())),
214 };
215
216 let (html_path, html_content) =
218 Self::resolve_template_path(&template_dir, template_name, "html.hbs", lang)?;
219
220 let (html_metadata, html_template) = Self::parse_frontmatter(&html_content);
222
223 let (text_path, text_content) =
225 Self::resolve_template_path(&template_dir, template_name, "txt.hbs", lang)?;
226
227 let (text_metadata, text_template) = Self::parse_frontmatter(&text_content);
229
230 let subject = match html_metadata.subject.as_ref().or(text_metadata.subject.as_ref()) {
233 Some(subj) => {
234 let rendered = self.handlebars.render_template(subj, vars).map_err(|e| {
235 Error::ValidationError(format!("Failed to render email subject: {}", e))
236 })?;
237 Some(rendered)
238 }
239 None => None,
240 };
241
242 let html_rendered = self.handlebars.render_template(html_template, vars).map_err(|e| {
244 Error::ValidationError(format!("Failed to render HTML template '{}': {}", html_path, e))
245 })?;
246
247 let html_body = if let Some(ref layout) = html_metadata.layout {
249 self.render_layout(LayoutRenderParams {
250 template_dir: &template_dir,
251 layout_name: layout,
252 extension: "html.hbs",
253 lang,
254 body: &html_rendered,
255 title: subject.as_deref(),
256 vars,
257 })?
258 } else {
259 html_rendered
260 };
261
262 let text_rendered = self.handlebars.render_template(text_template, vars).map_err(|e| {
264 Error::ValidationError(format!("Failed to render text template '{}': {}", text_path, e))
265 })?;
266
267 let text_layout = text_metadata.layout.as_ref().or(html_metadata.layout.as_ref());
269 let text_body = if let Some(layout) = text_layout {
270 self.render_layout(LayoutRenderParams {
271 template_dir: &template_dir,
272 layout_name: layout,
273 extension: "txt.hbs",
274 lang,
275 body: &text_rendered,
276 title: subject.as_deref(),
277 vars,
278 })?
279 } else {
280 text_rendered
281 };
282
283 Ok(RenderResult { subject, html_body, text_body })
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_parse_frontmatter_basic() {
293 let content = r#"---
294layout: default
295subject: Test Subject
296---
297Hello {{name}}!"#;
298
299 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
300 assert_eq!(metadata.layout, Some("default".to_string()));
301 assert_eq!(metadata.subject, Some("Test Subject".to_string()));
302 assert_eq!(template, "Hello {{name}}!");
303 }
304
305 #[test]
306 fn test_parse_frontmatter_no_frontmatter() {
307 let content = "Hello {{name}}!";
308
309 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
310 assert!(metadata.layout.is_none());
311 assert!(metadata.subject.is_none());
312 assert_eq!(template, "Hello {{name}}!");
313 }
314
315 #[test]
316 fn test_parse_frontmatter_layout_only() {
317 let content = r#"---
318layout: minimal
319---
320Content here"#;
321
322 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
323 assert_eq!(metadata.layout, Some("minimal".to_string()));
324 assert!(metadata.subject.is_none());
325 assert_eq!(template, "Content here");
326 }
327
328 #[test]
329 fn test_parse_frontmatter_subject_only() {
330 let content = r#"---
331subject: Email Subject Line
332---
333Content here"#;
334
335 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
336 assert!(metadata.layout.is_none());
337 assert_eq!(metadata.subject, Some("Email Subject Line".to_string()));
338 assert_eq!(template, "Content here");
339 }
340
341 #[test]
342 fn test_parse_frontmatter_with_whitespace() {
343 let content = r#"
344---
345layout: default
346subject: Test
347---
348
349Hello!"#;
350
351 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
352 assert_eq!(metadata.layout, Some("default".to_string()));
353 assert_eq!(metadata.subject, Some("Test".to_string()));
354 assert_eq!(template, "Hello!");
356 }
357
358 #[test]
359 fn test_parse_frontmatter_unclosed() {
360 let content = r#"---
361layout: default
362subject: Test
363Hello!"#;
364
365 let (metadata, _template) = TemplateEngine::parse_frontmatter(content);
366 assert!(metadata.layout.is_none());
368 assert!(metadata.subject.is_none());
369 }
370
371 #[test]
372 fn test_template_rendering() {
373 let handlebars = Handlebars::new();
375 let template = "Hello {{name}}, your token is {{token}}";
376 let data = serde_json::json!({
377 "name": "Alice",
378 "token": "abc123"
379 });
380
381 let result = handlebars.render_template(template, &data).unwrap();
382 assert_eq!(result, "Hello Alice, your token is abc123");
383 }
384
385 #[test]
386 fn test_html_escaping() {
387 let handlebars = Handlebars::new();
388 let template = "<p>{{content}}</p>";
389 let data = serde_json::json!({
390 "content": "<script>alert('xss')</script>"
391 });
392
393 let result = handlebars.render_template(template, &data).unwrap();
394 assert!(result.contains("<script>"));
395 assert!(!result.contains("<script>"));
396 }
397
398 #[test]
399 fn test_triple_brace_no_escaping() {
400 let handlebars = Handlebars::new();
401 let template = "<div>{{{body}}}</div>";
402 let data = serde_json::json!({
403 "body": "<p>HTML content</p>"
404 });
405
406 let result = handlebars.render_template(template, &data).unwrap();
407 assert!(result.contains("<p>HTML content</p>"));
408 }
409
410 #[test]
411 fn test_conditional_rendering() {
412 let handlebars = Handlebars::new();
413 let template = "{{#if show}}Shown{{else}}Hidden{{/if}}";
414
415 let data_show = serde_json::json!({"show": true});
416 let result_show = handlebars.render_template(template, &data_show).unwrap();
417 assert_eq!(result_show, "Shown");
418
419 let data_hide = serde_json::json!({"show": false});
420 let result_hide = handlebars.render_template(template, &data_hide).unwrap();
421 assert_eq!(result_hide, "Hidden");
422 }
423
424 #[test]
425 fn test_loop_rendering() {
426 let handlebars = Handlebars::new();
427 let template = "{{#each items}}{{this}},{{/each}}";
428 let data = serde_json::json!({
429 "items": ["apple", "banana", "cherry"]
430 });
431
432 let result = handlebars.render_template(template, &data).unwrap();
433 assert_eq!(result, "apple,banana,cherry,");
434 }
435
436 #[test]
437 fn test_verification_email_variables() {
438 let handlebars = Handlebars::new();
439 let template = r#"
440Welcome {{user_name}}!
441Verify: {{verification_link}}
442Token: {{verification_token}}
443Expires: {{expire_hours}} hours
444"#;
445
446 let data = serde_json::json!({
447 "user_name": "Alice",
448 "verification_link": "https://example.com/verify?token=abc123",
449 "verification_token": "abc123",
450 "expire_hours": 24
451 });
452
453 let result = handlebars.render_template(template, &data).unwrap();
454 assert!(result.contains("Welcome Alice!"));
455 assert!(result.contains("abc123")); assert!(result.contains("Expires: 24 hours"));
457 }
458
459 #[test]
460 fn test_password_reset_email_variables() {
461 let handlebars = Handlebars::new();
462 let template = r#"
463Hello {{user_name}},
464Reset password: {{reset_link}}
465Token: {{reset_token}}
466"#;
467
468 let data = serde_json::json!({
469 "user_name": "Bob",
470 "reset_link": "https://example.com/reset?token=xyz789",
471 "reset_token": "xyz789"
472 });
473
474 let result = handlebars.render_template(template, &data).unwrap();
475 assert!(result.contains("Hello Bob,"));
476 assert!(result.contains("xyz789")); }
478
479 #[test]
480 fn test_missing_variable_in_strict_mode() {
481 let mut handlebars = Handlebars::new();
482 handlebars.set_strict_mode(true);
483
484 let template = "Hello {{name}}, your email is {{email}}";
485 let data = serde_json::json!({"name": "Alice"}); let result = handlebars.render_template(template, &data);
489 assert!(result.is_err());
490 }
491
492 #[test]
493 fn test_multiline_template() {
494 let handlebars = Handlebars::new();
495 let template = r#"
496<!DOCTYPE html>
497<html>
498<body>
499<h1>Hello {{name}}</h1>
500<p>{{message}}</p>
501</body>
502</html>
503"#;
504
505 let data = serde_json::json!({
506 "name": "Charlie",
507 "message": "This is a test email"
508 });
509
510 let result = handlebars.render_template(template, &data).unwrap();
511 assert!(result.contains("<h1>Hello Charlie</h1>"));
512 assert!(result.contains("<p>This is a test email</p>"));
513 }
514}
515
516