Skip to main content

cloudillo_email/
template.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Email template rendering with Handlebars
5//!
6//! Loads HTML and plain text templates from filesystem and renders them
7//! with variable substitution. Supports:
8//! - YAML frontmatter for metadata (subject, layout)
9//! - Layout templates for consistent email structure
10//! - Language-specific template variants
11
12use 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/// Metadata extracted from template frontmatter
20#[derive(Debug, Default, Deserialize)]
21pub struct TemplateMetadata {
22	/// Layout template name (e.g., "default" -> layouts/default.html.hbs)
23	#[serde(default)]
24	pub layout: Option<String>,
25	/// Email subject line
26	#[serde(default)]
27	pub subject: Option<String>,
28}
29
30/// Result of template rendering
31#[derive(Debug)]
32pub struct RenderResult {
33	/// Subject extracted from template frontmatter
34	pub subject: Option<String>,
35	/// Rendered HTML body
36	pub html_body: String,
37	/// Rendered plain text body
38	pub text_body: String,
39}
40
41/// Parameters for rendering a layout template
42struct 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
52/// Template engine for email rendering
53pub struct TemplateEngine {
54	handlebars: Handlebars<'static>,
55	settings_service: Arc<SettingsService>,
56}
57
58impl TemplateEngine {
59	/// Create new template engine
60	pub fn new(settings_service: Arc<SettingsService>) -> ClResult<Self> {
61		let mut handlebars = Handlebars::new();
62
63		// Enable strict mode to catch undefined variables
64		handlebars.set_strict_mode(true);
65
66		Ok(Self { handlebars, settings_service })
67	}
68
69	/// Parse YAML frontmatter from template content
70	///
71	/// Frontmatter is delimited by `---` at the start of the file:
72	/// ```text
73	/// ---
74	/// layout: default
75	/// subject: Email Subject
76	/// ---
77	/// Template content here...
78	/// ```
79	///
80	/// Returns (metadata, content_without_frontmatter)
81	fn parse_frontmatter(content: &str) -> (TemplateMetadata, &str) {
82		let content = content.trim_start();
83
84		// Check if content starts with frontmatter delimiter
85		if !content.starts_with("---") {
86			return (TemplateMetadata::default(), content);
87		}
88
89		// Find the closing delimiter
90		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..]; // Skip "\n---"
94
95			// Parse YAML frontmatter
96			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			// No closing delimiter found
105			(TemplateMetadata::default(), content)
106		}
107	}
108
109	/// Try to load a template file, returning None if it doesn't exist
110	fn try_load_template(path: &str) -> Option<String> {
111		std::fs::read_to_string(path).ok()
112	}
113
114	/// Resolve template path with language fallback
115	///
116	/// For template "verification" with lang "hu":
117	/// 1. Try: verification.hu.html.hbs
118	/// 2. Fallback: verification.html.hbs
119	fn resolve_template_path(
120		template_dir: &str,
121		template_name: &str,
122		extension: &str,
123		lang: Option<&str>,
124	) -> ClResult<(String, String)> {
125		// Try language-specific template first
126		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		// Fallback to default template
135		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	/// Load and render a layout template with the given body content
149	fn render_layout(&self, params: &LayoutRenderParams<'_>) -> ClResult<String> {
150		let layouts_dir = format!("{}/layouts", params.template_dir);
151
152		// Try language-specific layout first
153		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		// Fallback to default layout
162		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		// Merge layout variables with provided vars
176		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	/// Render email template with variables and optional language
193	///
194	/// Returns RenderResult containing subject (if defined in frontmatter),
195	/// HTML body, and plain text body.
196	///
197	/// Template resolution order for lang="hu":
198	/// 1. verification.hu.html.hbs (language-specific)
199	/// 2. verification.html.hbs (fallback)
200	///
201	/// Layout resolution (if layout specified in frontmatter):
202	/// 1. layouts/default.hu.html.hbs (language-specific)
203	/// 2. layouts/default.html.hbs (fallback)
204	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		// Get template directory from settings
212		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		// Load HTML template with language fallback
219		let (html_path, html_content) =
220			Self::resolve_template_path(&template_dir, template_name, "html.hbs", lang)?;
221
222		// Parse frontmatter from HTML template
223		let (html_metadata, html_template) = Self::parse_frontmatter(&html_content);
224
225		// Load text template with language fallback
226		let (text_path, text_content) =
227			Self::resolve_template_path(&template_dir, template_name, "txt.hbs", lang)?;
228
229		// Parse frontmatter from text template
230		let (text_metadata, text_template) = Self::parse_frontmatter(&text_content);
231
232		// Render subject FIRST (before layouts) so it can be used as title
233		// Use subject from HTML metadata (primary) or text metadata (fallback)
234		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		// Render HTML content
245		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		// Apply layout if specified (use rendered subject as title)
250		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		// Render text content
265		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		// Apply layout if specified (use text metadata layout, fallback to html metadata)
270		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		// Leading newlines after frontmatter are trimmed
357		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		// Should return original content since frontmatter is not properly closed
369		assert!(metadata.layout.is_none());
370		assert!(metadata.subject.is_none());
371	}
372
373	#[test]
374	fn test_template_rendering() {
375		// Test basic Handlebars rendering
376		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("&lt;script&gt;"));
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")); // Token should be in result
458		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")); // Token should be in result
479	}
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"}); // missing 'email'
488
489		// Should fail in strict mode because 'email' is missing
490		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// vim: ts=4