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 SettingValue::String(template_dir) = template_dir else {
212 return Err(Error::ConfigError("Invalid template_dir setting".into()));
213 };
214
215 let (html_path, html_content) =
217 Self::resolve_template_path(&template_dir, template_name, "html.hbs", lang)?;
218
219 let (html_metadata, html_template) = Self::parse_frontmatter(&html_content);
221
222 let (text_path, text_content) =
224 Self::resolve_template_path(&template_dir, template_name, "txt.hbs", lang)?;
225
226 let (text_metadata, text_template) = Self::parse_frontmatter(&text_content);
228
229 let subject = match html_metadata.subject.as_ref().or(text_metadata.subject.as_ref()) {
232 Some(subj) => {
233 let rendered = self.handlebars.render_template(subj, vars).map_err(|e| {
234 Error::ValidationError(format!("Failed to render email subject: {}", e))
235 })?;
236 Some(rendered)
237 }
238 None => None,
239 };
240
241 let html_rendered = self.handlebars.render_template(html_template, vars).map_err(|e| {
243 Error::ValidationError(format!("Failed to render HTML template '{}': {}", html_path, e))
244 })?;
245
246 let html_body = if let Some(ref layout) = html_metadata.layout {
248 self.render_layout(&LayoutRenderParams {
249 template_dir: &template_dir,
250 layout_name: layout,
251 extension: "html.hbs",
252 lang,
253 body: &html_rendered,
254 title: subject.as_deref(),
255 vars,
256 })?
257 } else {
258 html_rendered
259 };
260
261 let text_rendered = self.handlebars.render_template(text_template, vars).map_err(|e| {
263 Error::ValidationError(format!("Failed to render text template '{}': {}", text_path, e))
264 })?;
265
266 let text_layout = text_metadata.layout.as_ref().or(html_metadata.layout.as_ref());
268 let text_body = if let Some(layout) = text_layout {
269 self.render_layout(&LayoutRenderParams {
270 template_dir: &template_dir,
271 layout_name: layout,
272 extension: "txt.hbs",
273 lang,
274 body: &text_rendered,
275 title: subject.as_deref(),
276 vars,
277 })?
278 } else {
279 text_rendered
280 };
281
282 Ok(RenderResult { subject, html_body, text_body })
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_parse_frontmatter_basic() {
292 let content = r"---
293layout: default
294subject: Test Subject
295---
296Hello {{name}}!";
297
298 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
299 assert_eq!(metadata.layout, Some("default".to_string()));
300 assert_eq!(metadata.subject, Some("Test Subject".to_string()));
301 assert_eq!(template, "Hello {{name}}!");
302 }
303
304 #[test]
305 fn test_parse_frontmatter_no_frontmatter() {
306 let content = "Hello {{name}}!";
307
308 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
309 assert!(metadata.layout.is_none());
310 assert!(metadata.subject.is_none());
311 assert_eq!(template, "Hello {{name}}!");
312 }
313
314 #[test]
315 fn test_parse_frontmatter_layout_only() {
316 let content = r"---
317layout: minimal
318---
319Content here";
320
321 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
322 assert_eq!(metadata.layout, Some("minimal".to_string()));
323 assert!(metadata.subject.is_none());
324 assert_eq!(template, "Content here");
325 }
326
327 #[test]
328 fn test_parse_frontmatter_subject_only() {
329 let content = r"---
330subject: Email Subject Line
331---
332Content here";
333
334 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
335 assert!(metadata.layout.is_none());
336 assert_eq!(metadata.subject, Some("Email Subject Line".to_string()));
337 assert_eq!(template, "Content here");
338 }
339
340 #[test]
341 fn test_parse_frontmatter_with_whitespace() {
342 let content = r"
343---
344layout: default
345subject: Test
346---
347
348Hello!";
349
350 let (metadata, template) = TemplateEngine::parse_frontmatter(content);
351 assert_eq!(metadata.layout, Some("default".to_string()));
352 assert_eq!(metadata.subject, Some("Test".to_string()));
353 assert_eq!(template, "Hello!");
355 }
356
357 #[test]
358 fn test_parse_frontmatter_unclosed() {
359 let content = r"---
360layout: default
361subject: Test
362Hello!";
363
364 let (metadata, _template) = TemplateEngine::parse_frontmatter(content);
365 assert!(metadata.layout.is_none());
367 assert!(metadata.subject.is_none());
368 }
369
370 #[test]
371 fn test_template_rendering() {
372 let handlebars = Handlebars::new();
374 let template = "Hello {{name}}, your token is {{token}}";
375 let data = serde_json::json!({
376 "name": "Alice",
377 "token": "abc123"
378 });
379
380 let result = handlebars.render_template(template, &data).unwrap();
381 assert_eq!(result, "Hello Alice, your token is abc123");
382 }
383
384 #[test]
385 fn test_html_escaping() {
386 let handlebars = Handlebars::new();
387 let template = "<p>{{content}}</p>";
388 let data = serde_json::json!({
389 "content": "<script>alert('xss')</script>"
390 });
391
392 let result = handlebars.render_template(template, &data).unwrap();
393 assert!(result.contains("<script>"));
394 assert!(!result.contains("<script>"));
395 }
396
397 #[test]
398 fn test_triple_brace_no_escaping() {
399 let handlebars = Handlebars::new();
400 let template = "<div>{{{body}}}</div>";
401 let data = serde_json::json!({
402 "body": "<p>HTML content</p>"
403 });
404
405 let result = handlebars.render_template(template, &data).unwrap();
406 assert!(result.contains("<p>HTML content</p>"));
407 }
408
409 #[test]
410 fn test_conditional_rendering() {
411 let handlebars = Handlebars::new();
412 let template = "{{#if show}}Shown{{else}}Hidden{{/if}}";
413
414 let data_show = serde_json::json!({"show": true});
415 let result_show = handlebars.render_template(template, &data_show).unwrap();
416 assert_eq!(result_show, "Shown");
417
418 let data_hide = serde_json::json!({"show": false});
419 let result_hide = handlebars.render_template(template, &data_hide).unwrap();
420 assert_eq!(result_hide, "Hidden");
421 }
422
423 #[test]
424 fn test_loop_rendering() {
425 let handlebars = Handlebars::new();
426 let template = "{{#each items}}{{this}},{{/each}}";
427 let data = serde_json::json!({
428 "items": ["apple", "banana", "cherry"]
429 });
430
431 let result = handlebars.render_template(template, &data).unwrap();
432 assert_eq!(result, "apple,banana,cherry,");
433 }
434
435 #[test]
436 fn test_verification_email_variables() {
437 let handlebars = Handlebars::new();
438 let template = r"
439Welcome {{user_name}}!
440Verify: {{verification_link}}
441Token: {{verification_token}}
442Expires: {{expire_hours}} hours
443";
444
445 let data = serde_json::json!({
446 "user_name": "Alice",
447 "verification_link": "https://example.com/verify?token=abc123",
448 "verification_token": "abc123",
449 "expire_hours": 24
450 });
451
452 let result = handlebars.render_template(template, &data).unwrap();
453 assert!(result.contains("Welcome Alice!"));
454 assert!(result.contains("abc123")); assert!(result.contains("Expires: 24 hours"));
456 }
457
458 #[test]
459 fn test_password_reset_email_variables() {
460 let handlebars = Handlebars::new();
461 let template = r"
462Hello {{user_name}},
463Reset password: {{reset_link}}
464Token: {{reset_token}}
465";
466
467 let data = serde_json::json!({
468 "user_name": "Bob",
469 "reset_link": "https://example.com/reset?token=xyz789",
470 "reset_token": "xyz789"
471 });
472
473 let result = handlebars.render_template(template, &data).unwrap();
474 assert!(result.contains("Hello Bob,"));
475 assert!(result.contains("xyz789")); }
477
478 #[test]
479 fn test_missing_variable_in_strict_mode() {
480 let mut handlebars = Handlebars::new();
481 handlebars.set_strict_mode(true);
482
483 let template = "Hello {{name}}, your email is {{email}}";
484 let data = serde_json::json!({"name": "Alice"}); let result = handlebars.render_template(template, &data);
488 assert!(result.is_err());
489 }
490
491 #[test]
492 fn test_multiline_template() {
493 let handlebars = Handlebars::new();
494 let template = r"
495<!DOCTYPE html>
496<html>
497<body>
498<h1>Hello {{name}}</h1>
499<p>{{message}}</p>
500</body>
501</html>
502";
503
504 let data = serde_json::json!({
505 "name": "Charlie",
506 "message": "This is a test email"
507 });
508
509 let result = handlebars.render_template(template, &data).unwrap();
510 assert!(result.contains("<h1>Hello Charlie</h1>"));
511 assert!(result.contains("<p>This is a test email</p>"));
512 }
513}
514
515