Skip to main content

cloudillo_email/
template.rs

1//! Email template rendering with Handlebars
2//!
3//! Loads HTML and plain text templates from filesystem and renders them
4//! with variable substitution. Supports:
5//! - YAML frontmatter for metadata (subject, layout)
6//! - Layout templates for consistent email structure
7//! - Language-specific template variants
8
9use 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/// Metadata extracted from template frontmatter
17#[derive(Debug, Default, Deserialize)]
18pub struct TemplateMetadata {
19	/// Layout template name (e.g., "default" -> layouts/default.html.hbs)
20	#[serde(default)]
21	pub layout: Option<String>,
22	/// Email subject line
23	#[serde(default)]
24	pub subject: Option<String>,
25}
26
27/// Result of template rendering
28#[derive(Debug)]
29pub struct RenderResult {
30	/// Subject extracted from template frontmatter
31	pub subject: Option<String>,
32	/// Rendered HTML body
33	pub html_body: String,
34	/// Rendered plain text body
35	pub text_body: String,
36}
37
38/// Parameters for rendering a layout template
39struct 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
49/// Template engine for email rendering
50pub struct TemplateEngine {
51	handlebars: Handlebars<'static>,
52	settings_service: Arc<SettingsService>,
53}
54
55impl TemplateEngine {
56	/// Create new template engine
57	pub fn new(settings_service: Arc<SettingsService>) -> ClResult<Self> {
58		let mut handlebars = Handlebars::new();
59
60		// Enable strict mode to catch undefined variables
61		handlebars.set_strict_mode(true);
62
63		Ok(Self { handlebars, settings_service })
64	}
65
66	/// Parse YAML frontmatter from template content
67	///
68	/// Frontmatter is delimited by `---` at the start of the file:
69	/// ```text
70	/// ---
71	/// layout: default
72	/// subject: Email Subject
73	/// ---
74	/// Template content here...
75	/// ```
76	///
77	/// Returns (metadata, content_without_frontmatter)
78	fn parse_frontmatter(content: &str) -> (TemplateMetadata, &str) {
79		let content = content.trim_start();
80
81		// Check if content starts with frontmatter delimiter
82		if !content.starts_with("---") {
83			return (TemplateMetadata::default(), content);
84		}
85
86		// Find the closing delimiter
87		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..]; // Skip "\n---"
91
92			// Parse YAML frontmatter
93			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			// No closing delimiter found
102			(TemplateMetadata::default(), content)
103		}
104	}
105
106	/// Try to load a template file, returning None if it doesn't exist
107	fn try_load_template(path: &str) -> Option<String> {
108		std::fs::read_to_string(path).ok()
109	}
110
111	/// Resolve template path with language fallback
112	///
113	/// For template "verification" with lang "hu":
114	/// 1. Try: verification.hu.html.hbs
115	/// 2. Fallback: verification.html.hbs
116	fn resolve_template_path(
117		template_dir: &str,
118		template_name: &str,
119		extension: &str,
120		lang: Option<&str>,
121	) -> ClResult<(String, String)> {
122		// Try language-specific template first
123		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		// Fallback to default template
132		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	/// Load and render a layout template with the given body content
146	fn render_layout(&self, params: &LayoutRenderParams<'_>) -> ClResult<String> {
147		let layouts_dir = format!("{}/layouts", params.template_dir);
148
149		// Try language-specific layout first
150		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		// Fallback to default layout
159		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		// Merge layout variables with provided vars
173		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	/// Render email template with variables and optional language
190	///
191	/// Returns RenderResult containing subject (if defined in frontmatter),
192	/// HTML body, and plain text body.
193	///
194	/// Template resolution order for lang="hu":
195	/// 1. verification.hu.html.hbs (language-specific)
196	/// 2. verification.html.hbs (fallback)
197	///
198	/// Layout resolution (if layout specified in frontmatter):
199	/// 1. layouts/default.hu.html.hbs (language-specific)
200	/// 2. layouts/default.html.hbs (fallback)
201	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		// Get template directory from settings
209		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		// Load HTML template with language fallback
216		let (html_path, html_content) =
217			Self::resolve_template_path(&template_dir, template_name, "html.hbs", lang)?;
218
219		// Parse frontmatter from HTML template
220		let (html_metadata, html_template) = Self::parse_frontmatter(&html_content);
221
222		// Load text template with language fallback
223		let (text_path, text_content) =
224			Self::resolve_template_path(&template_dir, template_name, "txt.hbs", lang)?;
225
226		// Parse frontmatter from text template
227		let (text_metadata, text_template) = Self::parse_frontmatter(&text_content);
228
229		// Render subject FIRST (before layouts) so it can be used as title
230		// Use subject from HTML metadata (primary) or text metadata (fallback)
231		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		// Render HTML content
242		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		// Apply layout if specified (use rendered subject as title)
247		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		// Render text content
262		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		// Apply layout if specified (use text metadata layout, fallback to html metadata)
267		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		// Leading newlines after frontmatter are trimmed
354		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		// Should return original content since frontmatter is not properly closed
366		assert!(metadata.layout.is_none());
367		assert!(metadata.subject.is_none());
368	}
369
370	#[test]
371	fn test_template_rendering() {
372		// Test basic Handlebars rendering
373		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("&lt;script&gt;"));
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")); // Token should be in result
455		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")); // Token should be in result
476	}
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"}); // missing 'email'
485
486		// Should fail in strict mode because 'email' is missing
487		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// vim: ts=4