use anyhow::{Result, anyhow}; use css_inline::CSSInliner; use minify_html_onepass::{Cfg, in_place_str}; use minijinja::Environment; #[cfg(feature = "embed")] use rust_embed::Embed; #[cfg(feature = "embed")] #[derive(Embed)] #[folder = "templates/email/"] struct EmailTemplateAssets; /// Email template engine that handles loading, rendering, CSS inlining, and minification /// /// Note: Caching is intentionally NOT implemented for email templates because they contain /// dynamic, personalized content. Each email must be rendered fresh to ensure correct data. pub struct EmailTemplateEngine { env: Environment<'static>, minify_cfg: Cfg, } impl EmailTemplateEngine { /// Create a new email template engine pub fn new() -> Result { let mut env = Environment::new(); #[cfg(feature = "embed")] { // In production mode, load embedded templates for file_name in EmailTemplateAssets::iter() { if let Some(file) = EmailTemplateAssets::get(&file_name) { let content = std::str::from_utf8(file.data.as_ref()) .map_err(|e| anyhow!("Invalid UTF-8 in template {}: {}", file_name, e))?; // Convert to static strings by leaking (acceptable for embedded templates loaded once) let name_static: &'static str = Box::leak(file_name.to_string().into_boxed_str()); let content_static: &'static str = Box::leak(content.to_string().into_boxed_str()); env.add_template(name_static, content_static) .map_err(|e| anyhow!("Failed to add template {}: {}", file_name, e))?; } } } #[cfg(not(feature = "embed"))] { // In development mode, load templates from filesystem env.set_loader(|name| { let path = format!("templates/email/{}", name); match std::fs::read_to_string(&path) { Ok(content) => Ok(Some(content)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(minijinja::Error::new( minijinja::ErrorKind::InvalidOperation, format!("Failed to load template {}: {}", name, e), )), } }); } // Configure HTML minifier let minify_cfg = Cfg { minify_js: false, // We don't have JS in email templates minify_css: true, }; Ok(Self { env, minify_cfg }) } /// Render a text email template pub fn render_text(&self, template_name: &str, ctx: minijinja::Value) -> Result { let template_path = format!("{}.txt.jinja", template_name); let template = self .env .get_template(&template_path) .map_err(|e| anyhow!("Failed to get text template {}: {}", template_path, e))?; template .render(&ctx) .map_err(|e| anyhow!("Failed to render text template {}: {}", template_path, e)) } /// Render an HTML email template with CSS inlining and minification /// /// Note: Caching is intentionally disabled for email templates because they contain /// dynamic, personalized content (event names, times, user-specific data). Caching /// would cause emails to show incorrect information from previous renders. pub fn render_html(&self, template_name: &str, ctx: minijinja::Value) -> Result { let template_path = format!("{}.html.jinja", template_name); // Render the template with minijinja let template = self .env .get_template(&template_path) .map_err(|e| anyhow!("Failed to get HTML template {}: {}", template_path, e))?; let rendered = template .render(&ctx) .map_err(|e| anyhow!("Failed to render HTML template {}: {}", template_path, e))?; // Inline CSS (create inliner on-demand) let css_inliner = CSSInliner::options() .load_remote_stylesheets(false) .keep_at_rules(true) .build(); let mut inlined = css_inliner .inline(&rendered) .map_err(|e| anyhow!("Failed to inline CSS in {}: {}", template_path, e))?; // Minify HTML in-place let minified_str = in_place_str(&mut inlined, &self.minify_cfg) .map_err(|e| anyhow!("Failed to minify HTML in {}: {:?}", template_path, e))? .to_string(); Ok(minified_str) } } /// Helper functions to create common template contexts pub mod contexts { use minijinja::context; /// Context for RSVP going notification pub fn rsvp_going( identity: &str, event_name: &str, event_url: &str, unsubscribe_url: &str, ) -> minijinja::Value { context! { identity => identity, event_name => event_name, event_url => event_url, unsubscribe_url => unsubscribe_url, } } /// Context for event changed notification pub fn event_changed( event_name: &str, event_url: &str, unsubscribe_url: &str, ) -> minijinja::Value { context! { event_name => event_name, event_url => event_url, unsubscribe_url => unsubscribe_url, } } /// Context for email confirmation pub fn confirmation(confirmation_url: &str, unsubscribe_url: Option<&str>) -> minijinja::Value { context! { confirmation_url => confirmation_url, unsubscribe_url => unsubscribe_url, } } /// Context for 24-hour event reminder pub fn event_reminder_24h( event_name: &str, event_start_time: &str, event_url: &str, unsubscribe_url: &str, ) -> minijinja::Value { context! { event_name => event_name, event_start_time => event_start_time, event_url => event_url, unsubscribe_url => unsubscribe_url, } } /// Context for event summary with full details pub fn event_summary( event_name: &str, event_description: Option<&str>, event_location: Option<&str>, event_start_time: &str, event_end_time: Option<&str>, event_url: &str, unsubscribe_url: &str, ) -> minijinja::Value { context! { event_name => event_name, event_description => event_description, event_location => event_location, event_start_time => event_start_time, event_end_time => event_end_time, event_url => event_url, unsubscribe_url => unsubscribe_url, } } /// Context for RSVP acceptance notification pub fn rsvp_accepted( event_name: &str, event_url: &str, unsubscribe_url: &str, ) -> minijinja::Value { context! { event_name => event_name, event_url => event_url, unsubscribe_url => unsubscribe_url, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_email_template_engine_creation() { let engine = EmailTemplateEngine::new(); assert!(engine.is_ok()); } #[test] fn test_render_text_template() { let engine = EmailTemplateEngine::new().unwrap(); let ctx = contexts::confirmation("https://example.com/confirm/abc123", None); let result = engine.render_text("confirmation", ctx); assert!(result.is_ok()); let text = result.unwrap(); assert!(text.contains("https://example.com/confirm/abc123")); } #[test] fn test_render_html_template() { let engine = EmailTemplateEngine::new().unwrap(); let ctx = contexts::confirmation("https://example.com/confirm/abc123", None); let result = engine.render_html("confirmation", ctx); assert!(result.is_ok()); let html = result.unwrap(); assert!(html.contains("https://example.com/confirm/abc123")); // Check that HTML is minified (no unnecessary whitespace at start) assert!(!html.starts_with('\n')); } #[test] fn test_render_24h_reminder_text() { let engine = EmailTemplateEngine::new().unwrap(); let ctx = contexts::event_reminder_24h( "Community Meetup", "Tomorrow at 3pm", "https://example.com/events/123", "https://example.com/unsubscribe/token123", ); let result = engine.render_text("event_reminder_24h", ctx); assert!(result.is_ok()); let text = result.unwrap(); assert!(text.contains("Community Meetup")); assert!(text.contains("Tomorrow at 3pm")); assert!(text.contains("https://example.com/events/123")); } #[test] fn test_render_24h_reminder_html() { let engine = EmailTemplateEngine::new().unwrap(); let ctx = contexts::event_reminder_24h( "Community Meetup", "Tomorrow at 3pm", "https://example.com/events/123", "https://example.com/unsubscribe/token123", ); let result = engine.render_html("event_reminder_24h", ctx); assert!(result.is_ok()); let html = result.unwrap(); assert!(html.contains("Community Meetup")); assert!(html.contains("Tomorrow at 3pm")); assert!(html.contains("https://example.com/events/123")); assert!(!html.starts_with('\n')); } #[test] fn test_render_event_summary_text() { let engine = EmailTemplateEngine::new().unwrap(); let ctx = contexts::event_summary( "Tech Conference", Some("A great tech conference"), Some("Convention Center"), "Jan 15, 2025 at 10am", Some("Jan 15, 2025 at 5pm"), "https://example.com/events/456", "https://example.com/unsubscribe/token456", ); let result = engine.render_text("event_summary", ctx); assert!(result.is_ok()); let text = result.unwrap(); assert!(text.contains("Tech Conference")); assert!(text.contains("A great tech conference")); assert!(text.contains("Convention Center")); assert!(text.contains("Jan 15, 2025 at 10am")); assert!(text.contains("Jan 15, 2025 at 5pm")); assert!(text.contains("https://example.com/events/456")); } #[test] fn test_render_event_summary_html() { let engine = EmailTemplateEngine::new().unwrap(); let ctx = contexts::event_summary( "Tech Conference", Some("A great tech conference"), Some("Convention Center"), "Jan 15, 2025 at 10am", Some("Jan 15, 2025 at 5pm"), "https://example.com/events/456", "https://example.com/unsubscribe/token456", ); let result = engine.render_html("event_summary", ctx); assert!(result.is_ok()); let html = result.unwrap(); assert!(html.contains("Tech Conference")); assert!(html.contains("A great tech conference")); assert!(html.contains("Convention Center")); assert!(html.contains("Jan 15, 2025 at 10am")); assert!(html.contains("Jan 15, 2025 at 5pm")); assert!(html.contains("https://example.com/events/456")); assert!(!html.starts_with('\n')); } }