elif_email/templates/
engine.rs

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
9/// Email template engine using Tera
10pub struct TemplateEngine {
11    tera: RwLock<Tera>,
12    config: TemplateConfig,
13    templates: RwLock<HashMap<String, EmailTemplate>>,
14}
15
16impl TemplateEngine {
17    /// Create new template engine
18    pub fn new(config: TemplateConfig) -> Result<Self, EmailError> {
19        let mut tera = Tera::new(&format!("{}/**/*", config.templates_dir))?;
20        
21        // Register custom filters
22        register_email_filters(&mut tera);
23        
24        // Enable auto-escaping for HTML but not for text
25        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    /// Render template with context
35    pub fn render_template(
36        &self,
37        template: &EmailTemplate,
38        context: &TemplateContext,
39    ) -> Result<RenderedEmail, EmailError> {
40        // Create tera context from template context
41        let mut tera_context = Context::new();
42        for (key, value) in context {
43            tera_context.insert(key, value);
44        }
45
46        // Add template metadata to context
47        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        // Render subject
54        let subject = if let Some(subject_template) = &template.subject_template {
55            tera.render_str(subject_template, &tera_context)?
56        } else {
57            // Fallback to context value or default
58            tera_context.get("subject")
59                .and_then(|v| v.as_str())
60                .unwrap_or("No Subject")
61                .to_string()
62        };
63
64        // Render HTML content
65        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        // Render text content
72        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    /// Get template info for debugging
86    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    /// Register a template
103    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    /// Get template by name
110    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    /// Render template by name with context
118    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    /// Validate template syntax
128    pub fn validate_template(&self, template_str: &str, template_name: Option<&str>) -> Result<(), EmailError> {
129        // Create a temporary Tera instance to test template parsing without affecting the main engine
130        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    /// List available templates
147    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
153/// Register custom email filters for Tera
154fn register_email_filters(tera: &mut Tera) {
155    // Date formatting filters
156    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    // Email tracking filters
161    tera.register_filter("tracking_pixel", tracking_pixel_filter);
162    tera.register_filter("tracking_link", tracking_link_filter);
163    
164    // Formatting filters
165    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    // String filters
170    tera.register_filter("url_encode", url_encode_filter);
171}
172
173// Tera filter implementations
174fn 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            // Try various common datetime formats
212            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    // Validate UUID format
263    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    // Validate UUID format
283    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        // Test currency filter
440        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        // Test RFC 3339 format
618        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        // Test legacy %Y-%m-%d %H:%M:%S format (with timezone) - This should fall back to string unchanged
634        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        // The timezone format should be parsed and converted to UTC, then formatted with default format
648        assert_eq!(rendered2.html_content, Some("DateTime: 2023-12-25 14:30:00".to_string()));
649
650        // Test naive datetime format %Y-%m-%d %H:%M:%S (without timezone)
651        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        // Should contain the current year
688        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        // Register template
710        engine.register_template(template.clone()).unwrap();
711
712        // Retrieve and verify
713        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        // Render by name
718        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        // Valid template with no variables should validate
736        assert!(engine.validate_template("Hello world!", Some("valid")).is_ok());
737
738        // Valid template with variables should validate (syntax check only)
739        assert!(engine.validate_template("Hello {{ name }}!", Some("valid_with_vars")).is_ok());
740
741        // Valid template with conditionals should validate
742        assert!(engine.validate_template("{% if user %}Hello {{ user.name }}{% endif %}", Some("valid_conditional")).is_ok());
743
744        // Invalid template syntax should fail
745        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        // Invalid template with bad syntax should fail  
750        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        // Register template
803        engine.register_template(template.clone()).unwrap();
804
805        // Get debug info
806        let debug_info = engine.get_template_info("debug_test").unwrap();
807
808        // Verify accurate debug information
809        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        // Test with template that has no text content
821        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        // Test error case for non-existent template
840        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}