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