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 template_dir = match template_dir {
212			SettingValue::String(dir) => dir,
213			_ => return Err(Error::ConfigError("Invalid template_dir setting".into())),
214		};
215
216		// Load HTML template with language fallback
217		let (html_path, html_content) =
218			Self::resolve_template_path(&template_dir, template_name, "html.hbs", lang)?;
219
220		// Parse frontmatter from HTML template
221		let (html_metadata, html_template) = Self::parse_frontmatter(&html_content);
222
223		// Load text template with language fallback
224		let (text_path, text_content) =
225			Self::resolve_template_path(&template_dir, template_name, "txt.hbs", lang)?;
226
227		// Parse frontmatter from text template
228		let (text_metadata, text_template) = Self::parse_frontmatter(&text_content);
229
230		// Render subject FIRST (before layouts) so it can be used as title
231		// Use subject from HTML metadata (primary) or text metadata (fallback)
232		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		// Render HTML content
243		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		// Apply layout if specified (use rendered subject as title)
248		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		// Render text content
263		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		// Apply layout if specified (use text metadata layout, fallback to html metadata)
268		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		// Leading newlines after frontmatter are trimmed
355		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		// Should return original content since frontmatter is not properly closed
367		assert!(metadata.layout.is_none());
368		assert!(metadata.subject.is_none());
369	}
370
371	#[test]
372	fn test_template_rendering() {
373		// Test basic Handlebars rendering
374		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("&lt;script&gt;"));
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")); // Token should be in result
456		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")); // Token should be in result
477	}
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"}); // missing 'email'
486
487		// Should fail in strict mode because 'email' is missing
488		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// vim: ts=4