1use crate::{
2 config::TemplateConfig,
3 error::EmailError,
4 templates::{EmailTemplate, RenderedEmail, TemplateContext, TemplateDebugInfo},
5};
6use std::{collections::HashMap, sync::RwLock};
7use tera::{Tera, Context, Value, to_value, Result as TeraResult};
8
9pub struct TemplateEngine {
11 tera: RwLock<Tera>,
12 config: TemplateConfig,
13 templates: RwLock<HashMap<String, EmailTemplate>>,
14}
15
16impl TemplateEngine {
17 pub fn new(config: TemplateConfig) -> Result<Self, EmailError> {
19 let mut tera = Tera::new(&format!("{}/**/*", config.templates_dir))?;
20
21 register_email_filters(&mut tera);
23
24 tera.autoescape_on(vec![".html", ".htm"]);
26
27 Ok(Self {
28 tera: RwLock::new(tera),
29 config,
30 templates: RwLock::new(HashMap::new()),
31 })
32 }
33
34 pub fn render_template(
36 &self,
37 template: &EmailTemplate,
38 context: &TemplateContext,
39 ) -> Result<RenderedEmail, EmailError> {
40 let mut tera_context = Context::new();
42 for (key, value) in context {
43 tera_context.insert(key, value);
44 }
45
46 for (key, value) in &template.metadata {
48 tera_context.insert(key, value);
49 }
50
51 let mut tera = self.tera.write().map_err(|_| EmailError::template("Failed to acquire write lock"))?;
52
53 let subject = if let Some(subject_template) = &template.subject_template {
55 tera.render_str(subject_template, &tera_context)?
56 } else {
57 tera_context.get("subject")
59 .and_then(|v| v.as_str())
60 .unwrap_or("No Subject")
61 .to_string()
62 };
63
64 let html_content = if let Some(html_template) = &template.html_template {
66 Some(tera.render_str(html_template, &tera_context)?)
67 } else {
68 None
69 };
70
71 let text_content = if let Some(text_template) = &template.text_template {
73 Some(tera.render_str(text_template, &tera_context)?)
74 } else {
75 None
76 };
77
78 Ok(RenderedEmail {
79 html_content,
80 text_content,
81 subject,
82 })
83 }
84
85 pub fn get_template_info(&self, template_name: &str) -> Result<TemplateDebugInfo, EmailError> {
87 let template = self.get_template(template_name)?;
88
89 Ok(TemplateDebugInfo {
90 name: template.name.clone(),
91 has_html: template.html_template.is_some(),
92 has_text: template.text_template.is_some(),
93 has_subject: template.subject_template.is_some(),
94 layout: template.layout.clone(),
95 metadata: template.metadata.clone(),
96 html_content: template.html_template.clone(),
97 text_content: template.text_template.clone(),
98 subject_content: template.subject_template.clone(),
99 })
100 }
101
102 pub fn register_template(&self, template: EmailTemplate) -> Result<(), EmailError> {
104 let mut templates = self.templates.write().map_err(|_| EmailError::template("Failed to acquire write lock"))?;
105 templates.insert(template.name.clone(), template);
106 Ok(())
107 }
108
109 pub fn get_template(&self, name: &str) -> Result<EmailTemplate, EmailError> {
111 let templates = self.templates.read().map_err(|_| EmailError::template("Failed to acquire read lock"))?;
112 templates.get(name)
113 .cloned()
114 .ok_or_else(|| EmailError::template(format!("Template '{}' not found", name)))
115 }
116
117 pub fn render_template_by_name(
119 &self,
120 template_name: &str,
121 context: &TemplateContext,
122 ) -> Result<RenderedEmail, EmailError> {
123 let template = self.get_template(template_name)?;
124 self.render_template(&template, context)
125 }
126
127 pub fn validate_template(&self, template_str: &str, template_name: Option<&str>) -> Result<(), EmailError> {
129 let mut temp_tera = Tera::default();
131 let temp_name = "__validation_template__";
132
133 match temp_tera.add_raw_template(temp_name, template_str) {
134 Ok(_) => Ok(()),
135 Err(e) => {
136 let context_info = if let Some(name) = template_name {
137 format!("template '{}': {}", name, e)
138 } else {
139 format!("template: {}", e)
140 };
141 Err(EmailError::template(format!("Invalid {}", context_info)))
142 }
143 }
144 }
145
146 pub fn list_templates(&self) -> Result<Vec<String>, EmailError> {
148 let tera = self.tera.write().map_err(|_| EmailError::template("Failed to acquire write lock"))?;
149 Ok(tera.get_template_names().map(|s| s.to_string()).collect())
150 }
151}
152
153fn register_email_filters(tera: &mut Tera) {
155 tera.register_filter("format_date", format_date_filter);
157 tera.register_filter("format_datetime", format_datetime_filter);
158 tera.register_filter("now", now_filter);
159
160 tera.register_filter("tracking_pixel", tracking_pixel_filter);
162 tera.register_filter("tracking_link", tracking_link_filter);
163
164 tera.register_filter("currency", format_currency_filter);
166 tera.register_filter("phone", format_phone_filter);
167 tera.register_filter("address", format_address_filter);
168
169 tera.register_filter("url_encode", url_encode_filter);
171}
172
173fn format_date_filter(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
175 use chrono::{DateTime, Utc, NaiveDateTime, TimeZone};
176
177 let format_str = args.get("format").and_then(|v| v.as_str()).unwrap_or("%Y-%m-%d");
178
179 let formatted = match value {
180 Value::String(s) => {
181 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
182 dt.format(format_str).to_string()
183 } else if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d") {
184 let dt = Utc.from_utc_datetime(&dt);
185 dt.format(format_str).to_string()
186 } else {
187 s.clone()
188 }
189 }
190 Value::Number(n) => {
191 if let Some(timestamp) = n.as_i64() {
192 let dt = Utc.timestamp_opt(timestamp, 0).single().unwrap_or_else(|| Utc::now());
193 dt.format(format_str).to_string()
194 } else {
195 "Invalid timestamp".to_string()
196 }
197 }
198 _ => "Invalid date".to_string(),
199 };
200
201 Ok(to_value(formatted)?)
202}
203
204fn format_datetime_filter(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
205 use chrono::{DateTime, Utc, TimeZone, NaiveDateTime};
206
207 let format_str = args.get("format").and_then(|v| v.as_str()).unwrap_or("%Y-%m-%d %H:%M:%S");
208
209 let formatted = match value {
210 Value::String(s) => {
211 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
213 dt.format(format_str).to_string()
214 } else if let Ok(dt) = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %z") {
215 dt.format(format_str).to_string()
216 } else if let Ok(dt) = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%z") {
217 dt.format(format_str).to_string()
218 } else if let Ok(dt) = DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S %#z") {
219 dt.format(format_str).to_string()
220 } else if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
221 let dt = Utc.from_utc_datetime(&naive_dt);
222 dt.format(format_str).to_string()
223 } else if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M") {
224 let dt = Utc.from_utc_datetime(&naive_dt);
225 dt.format(format_str).to_string()
226 } else if let Ok(naive_dt) = NaiveDateTime::parse_from_str(s, "%Y/%m/%d %H:%M:%S") {
227 let dt = Utc.from_utc_datetime(&naive_dt);
228 dt.format(format_str).to_string()
229 } else {
230 s.clone()
231 }
232 }
233 Value::Number(n) => {
234 if let Some(timestamp) = n.as_i64() {
235 let dt = Utc.timestamp_opt(timestamp, 0).single().unwrap_or_else(|| Utc::now());
236 dt.format(format_str).to_string()
237 } else {
238 "Invalid timestamp".to_string()
239 }
240 }
241 _ => "Invalid datetime".to_string(),
242 };
243
244 Ok(to_value(formatted)?)
245}
246
247fn now_filter(_value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
248 use chrono::Utc;
249 let format_str = args.get("format").and_then(|v| v.as_str()).unwrap_or("%Y-%m-%d %H:%M:%S");
250 let now = Utc::now();
251 let formatted = now.format(format_str).to_string();
252 Ok(to_value(formatted)?)
253}
254
255fn tracking_pixel_filter(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
256 use chrono::Utc;
257 use uuid::Uuid;
258
259 let email_id = value.as_str().ok_or_else(|| tera::Error::msg("tracking_pixel: email_id must be string"))?;
260 let base_url = args.get("base_url").and_then(|v| v.as_str()).unwrap_or("https://tracking.example.com");
261
262 Uuid::parse_str(email_id).map_err(|_| tera::Error::msg("tracking_pixel: invalid UUID format"))?;
264
265 let timestamp = Utc::now().timestamp();
266 let pixel_url = format!("{}/email/track/open?id={}&t={}", base_url, email_id, timestamp);
267 let pixel_html = format!(
268 r#"<img src="{}" alt="" width="1" height="1" style="display: block; width: 1px; height: 1px;" />"#,
269 pixel_url
270 );
271
272 Ok(to_value(pixel_html)?)
273}
274
275fn tracking_link_filter(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
276 use uuid::Uuid;
277
278 let email_id = value.as_str().ok_or_else(|| tera::Error::msg("tracking_link: email_id must be string"))?;
279 let target_url = args.get("url").and_then(|v| v.as_str()).ok_or_else(|| tera::Error::msg("tracking_link: missing url parameter"))?;
280 let base_url = args.get("base_url").and_then(|v| v.as_str()).unwrap_or("https://tracking.example.com");
281
282 Uuid::parse_str(email_id).map_err(|_| tera::Error::msg("tracking_link: invalid UUID format"))?;
284
285 let encoded_url = urlencoding::encode(target_url);
286 let tracking_url = format!("{}/email/track/click?id={}&url={}", base_url, email_id, encoded_url);
287
288 Ok(to_value(tracking_url)?)
289}
290
291fn format_currency_filter(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
292 let amount = value.as_f64().ok_or_else(|| tera::Error::msg("currency: amount must be number"))?;
293 let currency = args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD");
294
295 let symbol = match currency {
296 "USD" => "$",
297 "EUR" => "€",
298 "GBP" => "£",
299 "JPY" => "¥",
300 _ => currency,
301 };
302
303 let formatted = if currency == "JPY" {
304 format!("{}{:.0}", symbol, amount)
305 } else {
306 format!("{}{:.2}", symbol, amount)
307 };
308
309 Ok(to_value(formatted)?)
310}
311
312fn format_phone_filter(value: &Value, args: &HashMap<String, Value>) -> TeraResult<Value> {
313 let phone_str = value.as_str().ok_or_else(|| tera::Error::msg("phone: must be string"))?;
314 let country = args.get("country").and_then(|v| v.as_str()).unwrap_or("US");
315
316 let digits: String = phone_str.chars().filter(|c| c.is_ascii_digit()).collect();
317
318 let formatted = match country {
319 "US" if digits.len() == 10 => {
320 format!("({}) {}-{}", &digits[0..3], &digits[3..6], &digits[6..10])
321 }
322 "US" if digits.len() == 11 && digits.starts_with('1') => {
323 format!("+1 ({}) {}-{}", &digits[1..4], &digits[4..7], &digits[7..11])
324 }
325 _ => phone_str.to_string(),
326 };
327
328 Ok(to_value(formatted)?)
329}
330
331fn format_address_filter(value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
332 let formatted = match value {
333 Value::Object(obj) => {
334 let street = obj.get("street").and_then(|v| v.as_str()).unwrap_or("");
335 let city = obj.get("city").and_then(|v| v.as_str()).unwrap_or("");
336 let state = obj.get("state").and_then(|v| v.as_str()).unwrap_or("");
337 let zip = obj.get("zip").and_then(|v| v.as_str()).unwrap_or("");
338
339 let mut parts = Vec::new();
340 if !street.is_empty() { parts.push(street.to_string()); }
341
342 let mut city_line = Vec::new();
343 if !city.is_empty() { city_line.push(city.to_string()); }
344 if !state.is_empty() && !zip.is_empty() {
345 city_line.push(format!("{} {}", state, zip));
346 } else if !state.is_empty() {
347 city_line.push(state.to_string());
348 } else if !zip.is_empty() {
349 city_line.push(zip.to_string());
350 }
351
352 if !city_line.is_empty() {
353 parts.push(city_line.join(" "));
354 }
355
356 parts.join("<br/>")
357 }
358 Value::String(s) => s.replace('\n', "<br/>"),
359 _ => "Invalid address format".to_string(),
360 };
361
362 Ok(to_value(formatted)?)
363}
364
365fn url_encode_filter(value: &Value, _args: &HashMap<String, Value>) -> TeraResult<Value> {
366 let text = value.as_str().ok_or_else(|| tera::Error::msg("url_encode: must be string"))?;
367 let encoded = urlencoding::encode(text);
368 Ok(to_value(encoded.to_string())?)
369}
370
371impl From<tera::Error> for EmailError {
372 fn from(err: tera::Error) -> Self {
373 EmailError::template(format!("Template error: {}", err))
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use tempfile::TempDir;
381
382 fn create_test_config(temp_dir: &TempDir) -> TemplateConfig {
383 TemplateConfig {
384 templates_dir: temp_dir.path().join("templates").to_string_lossy().to_string(),
385 layouts_dir: temp_dir.path().join("layouts").to_string_lossy().to_string(),
386 partials_dir: temp_dir.path().join("partials").to_string_lossy().to_string(),
387 enable_cache: false,
388 template_extension: ".html".to_string(),
389 cache_size: None,
390 watch_files: false,
391 }
392 }
393
394 #[test]
395 fn test_template_engine_creation() {
396 let temp_dir = TempDir::new().unwrap();
397 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
398
399 let config = create_test_config(&temp_dir);
400 let engine = TemplateEngine::new(config);
401 assert!(engine.is_ok());
402 }
403
404 #[test]
405 fn test_basic_template_rendering() {
406 let temp_dir = TempDir::new().unwrap();
407 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
408
409 let config = create_test_config(&temp_dir);
410 let engine = TemplateEngine::new(config).unwrap();
411
412 let template = EmailTemplate {
413 name: "test".to_string(),
414 html_template: Some("<h1>Hello {{ name }}</h1>".to_string()),
415 text_template: Some("Hello {{ name }}".to_string()),
416 subject_template: Some("Welcome {{ name }}".to_string()),
417 layout: None,
418 metadata: HashMap::new(),
419 };
420
421 let mut context = TemplateContext::new();
422 context.insert("name".to_string(), serde_json::Value::String("World".to_string()));
423
424 let rendered = engine.render_template(&template, &context).unwrap();
425
426 assert_eq!(rendered.subject, "Welcome World");
427 assert_eq!(rendered.html_content, Some("<h1>Hello World</h1>".to_string()));
428 assert_eq!(rendered.text_content, Some("Hello World".to_string()));
429 }
430
431 #[test]
432 fn test_tera_filters_compatibility() {
433 let temp_dir = TempDir::new().unwrap();
434 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
435
436 let config = create_test_config(&temp_dir);
437 let engine = TemplateEngine::new(config).unwrap();
438
439 let template = EmailTemplate {
441 name: "currency_test".to_string(),
442 html_template: Some("Price: {{ amount | currency(currency=\"USD\") }}".to_string()),
443 text_template: None,
444 subject_template: None,
445 layout: None,
446 metadata: HashMap::new(),
447 };
448
449 let mut context = TemplateContext::new();
450 context.insert("amount".to_string(), serde_json::Value::Number(serde_json::Number::from_f64(99.99).unwrap()));
451
452 let rendered = engine.render_template(&template, &context).unwrap();
453 assert_eq!(rendered.html_content, Some("Price: $99.99".to_string()));
454 }
455
456 #[test]
457 fn test_phone_filter() {
458 let temp_dir = TempDir::new().unwrap();
459 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
460
461 let config = create_test_config(&temp_dir);
462 let engine = TemplateEngine::new(config).unwrap();
463
464 let template = EmailTemplate {
465 name: "phone_test".to_string(),
466 html_template: Some("Phone: {{ phone_number | phone(country=\"US\") }}".to_string()),
467 text_template: None,
468 subject_template: None,
469 layout: None,
470 metadata: HashMap::new(),
471 };
472
473 let mut context = TemplateContext::new();
474 context.insert("phone_number".to_string(), serde_json::Value::String("5551234567".to_string()));
475
476 let rendered = engine.render_template(&template, &context).unwrap();
477 assert_eq!(rendered.html_content, Some("Phone: (555) 123-4567".to_string()));
478 }
479
480 #[test]
481 fn test_address_filter() {
482 let temp_dir = TempDir::new().unwrap();
483 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
484
485 let config = create_test_config(&temp_dir);
486 let engine = TemplateEngine::new(config).unwrap();
487
488 let template = EmailTemplate {
489 name: "address_test".to_string(),
490 html_template: Some("Address: {{ address | address }}".to_string()),
491 text_template: None,
492 subject_template: None,
493 layout: None,
494 metadata: HashMap::new(),
495 };
496
497 let mut context = TemplateContext::new();
498 let address_obj = serde_json::json!({
499 "street": "123 Main St",
500 "city": "Anytown",
501 "state": "CA",
502 "zip": "90210"
503 });
504 context.insert("address".to_string(), address_obj);
505
506 let rendered = engine.render_template(&template, &context).unwrap();
507 assert_eq!(rendered.html_content, Some("Address: 123 Main St<br/>Anytown CA 90210".to_string()));
508 }
509
510 #[test]
511 fn test_url_encode_filter() {
512 let temp_dir = TempDir::new().unwrap();
513 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
514
515 let config = create_test_config(&temp_dir);
516 let engine = TemplateEngine::new(config).unwrap();
517
518 let template = EmailTemplate {
519 name: "url_test".to_string(),
520 html_template: Some("URL: {{ url | url_encode }}".to_string()),
521 text_template: None,
522 subject_template: None,
523 layout: None,
524 metadata: HashMap::new(),
525 };
526
527 let mut context = TemplateContext::new();
528 context.insert("url".to_string(), serde_json::Value::String("hello world & more".to_string()));
529
530 let rendered = engine.render_template(&template, &context).unwrap();
531 assert_eq!(rendered.html_content, Some("URL: hello%20world%20%26%20more".to_string()));
532 }
533
534 #[test]
535 fn test_tracking_pixel_filter() {
536 let temp_dir = TempDir::new().unwrap();
537 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
538
539 let config = create_test_config(&temp_dir);
540 let engine = TemplateEngine::new(config).unwrap();
541
542 let template = EmailTemplate {
543 name: "tracking_test".to_string(),
544 html_template: Some("{{ email_id | tracking_pixel(base_url=\"https://test.com\") }}".to_string()),
545 text_template: None,
546 subject_template: None,
547 layout: None,
548 metadata: HashMap::new(),
549 };
550
551 let mut context = TemplateContext::new();
552 context.insert("email_id".to_string(), serde_json::Value::String("550e8400-e29b-41d4-a716-446655440000".to_string()));
553
554 let rendered = engine.render_template(&template, &context).unwrap();
555 let html_content = rendered.html_content.as_ref().unwrap();
556 assert!(html_content.contains("https://test.com/email/track/open"));
557 assert!(html_content.contains("550e8400-e29b-41d4-a716-446655440000"));
558 }
559
560 #[test]
561 fn test_tracking_link_filter() {
562 let temp_dir = TempDir::new().unwrap();
563 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
564
565 let config = create_test_config(&temp_dir);
566 let engine = TemplateEngine::new(config).unwrap();
567
568 let template = EmailTemplate {
569 name: "link_test".to_string(),
570 html_template: Some("{{ email_id | tracking_link(url=\"https://example.com\", base_url=\"https://track.com\") }}".to_string()),
571 text_template: None,
572 subject_template: None,
573 layout: None,
574 metadata: HashMap::new(),
575 };
576
577 let mut context = TemplateContext::new();
578 context.insert("email_id".to_string(), serde_json::Value::String("550e8400-e29b-41d4-a716-446655440000".to_string()));
579
580 let rendered = engine.render_template(&template, &context).unwrap();
581 let expected = "https://track.com/email/track/click?id=550e8400-e29b-41d4-a716-446655440000&url=https%3A%2F%2Fexample.com";
582 assert_eq!(rendered.html_content, Some(expected.to_string()));
583 }
584
585 #[test]
586 fn test_format_date_filter() {
587 let temp_dir = TempDir::new().unwrap();
588 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
589
590 let config = create_test_config(&temp_dir);
591 let engine = TemplateEngine::new(config).unwrap();
592
593 let template = EmailTemplate {
594 name: "date_test".to_string(),
595 html_template: Some("Date: {{ date_value | format_date(format=\"%B %d, %Y\") }}".to_string()),
596 text_template: None,
597 subject_template: None,
598 layout: None,
599 metadata: HashMap::new(),
600 };
601
602 let mut context = TemplateContext::new();
603 context.insert("date_value".to_string(), serde_json::Value::String("2023-12-25T10:00:00Z".to_string()));
604
605 let rendered = engine.render_template(&template, &context).unwrap();
606 assert_eq!(rendered.html_content, Some("Date: December 25, 2023".to_string()));
607 }
608
609 #[test]
610 fn test_format_datetime_filter_multiple_formats() {
611 let temp_dir = TempDir::new().unwrap();
612 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
613
614 let config = create_test_config(&temp_dir);
615 let engine = TemplateEngine::new(config).unwrap();
616
617 let template = EmailTemplate {
619 name: "datetime_rfc3339".to_string(),
620 html_template: Some("DateTime: {{ datetime_value | format_datetime(format=\"%B %d, %Y %H:%M\") }}".to_string()),
621 text_template: None,
622 subject_template: None,
623 layout: None,
624 metadata: HashMap::new(),
625 };
626
627 let mut context = TemplateContext::new();
628 context.insert("datetime_value".to_string(), serde_json::Value::String("2023-12-25T14:30:00Z".to_string()));
629
630 let rendered = engine.render_template(&template, &context).unwrap();
631 assert_eq!(rendered.html_content, Some("DateTime: December 25, 2023 14:30".to_string()));
632
633 let template2 = EmailTemplate {
635 name: "datetime_legacy".to_string(),
636 html_template: Some("DateTime: {{ datetime_value | format_datetime }}".to_string()),
637 text_template: None,
638 subject_template: None,
639 layout: None,
640 metadata: HashMap::new(),
641 };
642
643 let mut context2 = TemplateContext::new();
644 context2.insert("datetime_value".to_string(), serde_json::Value::String("2023-12-25 14:30:00 +00:00".to_string()));
645
646 let rendered2 = engine.render_template(&template2, &context2).unwrap();
647 assert_eq!(rendered2.html_content, Some("DateTime: 2023-12-25 14:30:00".to_string()));
649
650 let template3 = EmailTemplate {
652 name: "datetime_naive".to_string(),
653 html_template: Some("DateTime: {{ datetime_value | format_datetime(format=\"%B %d, %Y %H:%M\") }}".to_string()),
654 text_template: None,
655 subject_template: None,
656 layout: None,
657 metadata: HashMap::new(),
658 };
659
660 let mut context3 = TemplateContext::new();
661 context3.insert("datetime_value".to_string(), serde_json::Value::String("2023-12-25 14:30:00".to_string()));
662
663 let rendered3 = engine.render_template(&template3, &context3).unwrap();
664 assert_eq!(rendered3.html_content, Some("DateTime: December 25, 2023 14:30".to_string()));
665 }
666
667 #[test]
668 fn test_now_filter() {
669 let temp_dir = TempDir::new().unwrap();
670 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
671
672 let config = create_test_config(&temp_dir);
673 let engine = TemplateEngine::new(config).unwrap();
674
675 let template = EmailTemplate {
676 name: "now_test".to_string(),
677 html_template: Some("Current year: {{ \"\" | now(format=\"%Y\") }}".to_string()),
678 text_template: None,
679 subject_template: None,
680 layout: None,
681 metadata: HashMap::new(),
682 };
683
684 let context = TemplateContext::new();
685 let rendered = engine.render_template(&template, &context).unwrap();
686
687 let current_year = chrono::Utc::now().format("%Y").to_string();
689 assert_eq!(rendered.html_content, Some(format!("Current year: {}", current_year)));
690 }
691
692 #[test]
693 fn test_template_registration_and_retrieval() {
694 let temp_dir = TempDir::new().unwrap();
695 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
696
697 let config = create_test_config(&temp_dir);
698 let engine = TemplateEngine::new(config).unwrap();
699
700 let template = EmailTemplate {
701 name: "registered_template".to_string(),
702 html_template: Some("<p>Hello {{ user }}</p>".to_string()),
703 text_template: Some("Hello {{ user }}".to_string()),
704 subject_template: Some("Welcome {{ user }}".to_string()),
705 layout: None,
706 metadata: HashMap::new(),
707 };
708
709 engine.register_template(template.clone()).unwrap();
711
712 let retrieved = engine.get_template("registered_template").unwrap();
714 assert_eq!(retrieved.name, "registered_template");
715 assert_eq!(retrieved.html_template, template.html_template);
716
717 let mut context = TemplateContext::new();
719 context.insert("user".to_string(), serde_json::Value::String("Alice".to_string()));
720
721 let rendered = engine.render_template_by_name("registered_template", &context).unwrap();
722 assert_eq!(rendered.subject, "Welcome Alice");
723 assert_eq!(rendered.html_content, Some("<p>Hello Alice</p>".to_string()));
724 assert_eq!(rendered.text_content, Some("Hello Alice".to_string()));
725 }
726
727 #[test]
728 fn test_template_validation() {
729 let temp_dir = TempDir::new().unwrap();
730 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
731
732 let config = create_test_config(&temp_dir);
733 let engine = TemplateEngine::new(config).unwrap();
734
735 assert!(engine.validate_template("Hello world!", Some("valid")).is_ok());
737
738 assert!(engine.validate_template("Hello {{ name }}!", Some("valid_with_vars")).is_ok());
740
741 assert!(engine.validate_template("{% if user %}Hello {{ user.name }}{% endif %}", Some("valid_conditional")).is_ok());
743
744 let result = engine.validate_template("Hello {{ invalid_syntax", Some("invalid"));
746 assert!(result.is_err());
747 assert!(result.unwrap_err().to_string().contains("Invalid template 'invalid'"));
748
749 let result = engine.validate_template("{% if unclosed", Some("invalid2"));
751 assert!(result.is_err());
752 }
753
754 #[test]
755 fn test_template_metadata_in_context() {
756 let temp_dir = TempDir::new().unwrap();
757 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
758
759 let config = create_test_config(&temp_dir);
760 let engine = TemplateEngine::new(config).unwrap();
761
762 let mut metadata = HashMap::new();
763 metadata.insert("brand_color".to_string(), "#ff0000".to_string());
764
765 let template = EmailTemplate {
766 name: "metadata_test".to_string(),
767 html_template: Some("<div style=\"color: {{ brand_color }}\">Hello {{ name }}</div>".to_string()),
768 text_template: None,
769 subject_template: None,
770 layout: None,
771 metadata,
772 };
773
774 let mut context = TemplateContext::new();
775 context.insert("name".to_string(), serde_json::Value::String("User".to_string()));
776
777 let rendered = engine.render_template(&template, &context).unwrap();
778 assert_eq!(rendered.html_content, Some("<div style=\"color: #ff0000\">Hello User</div>".to_string()));
779 }
780
781 #[test]
782 fn test_template_debug_info() {
783 let temp_dir = TempDir::new().unwrap();
784 std::fs::create_dir_all(temp_dir.path().join("templates")).unwrap();
785
786 let config = create_test_config(&temp_dir);
787 let engine = TemplateEngine::new(config).unwrap();
788
789 let mut metadata = HashMap::new();
790 metadata.insert("version".to_string(), "1.0".to_string());
791 metadata.insert("author".to_string(), "test".to_string());
792
793 let template = EmailTemplate {
794 name: "debug_test".to_string(),
795 html_template: Some("<h1>Debug Test</h1>".to_string()),
796 text_template: Some("Debug Test".to_string()),
797 subject_template: Some("Debug: {{ type }}".to_string()),
798 layout: Some("base".to_string()),
799 metadata,
800 };
801
802 engine.register_template(template.clone()).unwrap();
804
805 let debug_info = engine.get_template_info("debug_test").unwrap();
807
808 assert_eq!(debug_info.name, "debug_test");
810 assert_eq!(debug_info.has_html, true);
811 assert_eq!(debug_info.has_text, true);
812 assert_eq!(debug_info.has_subject, true);
813 assert_eq!(debug_info.layout, Some("base".to_string()));
814 assert_eq!(debug_info.metadata.get("version"), Some(&"1.0".to_string()));
815 assert_eq!(debug_info.metadata.get("author"), Some(&"test".to_string()));
816 assert_eq!(debug_info.html_content, Some("<h1>Debug Test</h1>".to_string()));
817 assert_eq!(debug_info.text_content, Some("Debug Test".to_string()));
818 assert_eq!(debug_info.subject_content, Some("Debug: {{ type }}".to_string()));
819
820 let minimal_template = EmailTemplate {
822 name: "minimal".to_string(),
823 html_template: Some("<p>Minimal</p>".to_string()),
824 text_template: None,
825 subject_template: None,
826 layout: None,
827 metadata: HashMap::new(),
828 };
829
830 engine.register_template(minimal_template).unwrap();
831 let minimal_debug = engine.get_template_info("minimal").unwrap();
832
833 assert_eq!(minimal_debug.has_html, true);
834 assert_eq!(minimal_debug.has_text, false);
835 assert_eq!(minimal_debug.has_subject, false);
836 assert_eq!(minimal_debug.layout, None);
837 assert!(minimal_debug.metadata.is_empty());
838
839 let result = engine.get_template_info("nonexistent");
841 assert!(result.is_err());
842 assert!(result.unwrap_err().to_string().contains("Template 'nonexistent' not found"));
843 }
844}