The smokesignal.events web application
at main 325 lines 12 kB view raw
1use anyhow::{Result, anyhow}; 2use css_inline::CSSInliner; 3use minify_html_onepass::{Cfg, in_place_str}; 4use minijinja::Environment; 5 6#[cfg(feature = "embed")] 7use rust_embed::Embed; 8 9#[cfg(feature = "embed")] 10#[derive(Embed)] 11#[folder = "templates/email/"] 12struct EmailTemplateAssets; 13 14/// Email template engine that handles loading, rendering, CSS inlining, and minification 15/// 16/// Note: Caching is intentionally NOT implemented for email templates because they contain 17/// dynamic, personalized content. Each email must be rendered fresh to ensure correct data. 18pub struct EmailTemplateEngine { 19 env: Environment<'static>, 20 minify_cfg: Cfg, 21} 22 23impl EmailTemplateEngine { 24 /// Create a new email template engine 25 pub fn new() -> Result<Self> { 26 let mut env = Environment::new(); 27 28 #[cfg(feature = "embed")] 29 { 30 // In production mode, load embedded templates 31 for file_name in EmailTemplateAssets::iter() { 32 if let Some(file) = EmailTemplateAssets::get(&file_name) { 33 let content = std::str::from_utf8(file.data.as_ref()) 34 .map_err(|e| anyhow!("Invalid UTF-8 in template {}: {}", file_name, e))?; 35 36 // Convert to static strings by leaking (acceptable for embedded templates loaded once) 37 let name_static: &'static str = 38 Box::leak(file_name.to_string().into_boxed_str()); 39 let content_static: &'static str = 40 Box::leak(content.to_string().into_boxed_str()); 41 42 env.add_template(name_static, content_static) 43 .map_err(|e| anyhow!("Failed to add template {}: {}", file_name, e))?; 44 } 45 } 46 } 47 48 #[cfg(not(feature = "embed"))] 49 { 50 // In development mode, load templates from filesystem 51 env.set_loader(|name| { 52 let path = format!("templates/email/{}", name); 53 match std::fs::read_to_string(&path) { 54 Ok(content) => Ok(Some(content)), 55 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), 56 Err(e) => Err(minijinja::Error::new( 57 minijinja::ErrorKind::InvalidOperation, 58 format!("Failed to load template {}: {}", name, e), 59 )), 60 } 61 }); 62 } 63 64 // Configure HTML minifier 65 let minify_cfg = Cfg { 66 minify_js: false, // We don't have JS in email templates 67 minify_css: true, 68 }; 69 70 Ok(Self { env, minify_cfg }) 71 } 72 73 /// Render a text email template 74 pub fn render_text(&self, template_name: &str, ctx: minijinja::Value) -> Result<String> { 75 let template_path = format!("{}.txt.jinja", template_name); 76 let template = self 77 .env 78 .get_template(&template_path) 79 .map_err(|e| anyhow!("Failed to get text template {}: {}", template_path, e))?; 80 81 template 82 .render(&ctx) 83 .map_err(|e| anyhow!("Failed to render text template {}: {}", template_path, e)) 84 } 85 86 /// Render an HTML email template with CSS inlining and minification 87 /// 88 /// Note: Caching is intentionally disabled for email templates because they contain 89 /// dynamic, personalized content (event names, times, user-specific data). Caching 90 /// would cause emails to show incorrect information from previous renders. 91 pub fn render_html(&self, template_name: &str, ctx: minijinja::Value) -> Result<String> { 92 let template_path = format!("{}.html.jinja", template_name); 93 94 // Render the template with minijinja 95 let template = self 96 .env 97 .get_template(&template_path) 98 .map_err(|e| anyhow!("Failed to get HTML template {}: {}", template_path, e))?; 99 100 let rendered = template 101 .render(&ctx) 102 .map_err(|e| anyhow!("Failed to render HTML template {}: {}", template_path, e))?; 103 104 // Inline CSS (create inliner on-demand) 105 let css_inliner = CSSInliner::options() 106 .load_remote_stylesheets(false) 107 .keep_at_rules(true) 108 .build(); 109 let mut inlined = css_inliner 110 .inline(&rendered) 111 .map_err(|e| anyhow!("Failed to inline CSS in {}: {}", template_path, e))?; 112 113 // Minify HTML in-place 114 let minified_str = in_place_str(&mut inlined, &self.minify_cfg) 115 .map_err(|e| anyhow!("Failed to minify HTML in {}: {:?}", template_path, e))? 116 .to_string(); 117 118 Ok(minified_str) 119 } 120} 121 122/// Helper functions to create common template contexts 123pub mod contexts { 124 use minijinja::context; 125 126 /// Context for RSVP going notification 127 pub fn rsvp_going( 128 identity: &str, 129 event_name: &str, 130 event_url: &str, 131 unsubscribe_url: &str, 132 ) -> minijinja::Value { 133 context! { 134 identity => identity, 135 event_name => event_name, 136 event_url => event_url, 137 unsubscribe_url => unsubscribe_url, 138 } 139 } 140 141 /// Context for event changed notification 142 pub fn event_changed( 143 event_name: &str, 144 event_url: &str, 145 unsubscribe_url: &str, 146 ) -> minijinja::Value { 147 context! { 148 event_name => event_name, 149 event_url => event_url, 150 unsubscribe_url => unsubscribe_url, 151 } 152 } 153 154 /// Context for email confirmation 155 pub fn confirmation(confirmation_url: &str, unsubscribe_url: Option<&str>) -> minijinja::Value { 156 context! { 157 confirmation_url => confirmation_url, 158 unsubscribe_url => unsubscribe_url, 159 } 160 } 161 162 /// Context for 24-hour event reminder 163 pub fn event_reminder_24h( 164 event_name: &str, 165 event_start_time: &str, 166 event_url: &str, 167 unsubscribe_url: &str, 168 ) -> minijinja::Value { 169 context! { 170 event_name => event_name, 171 event_start_time => event_start_time, 172 event_url => event_url, 173 unsubscribe_url => unsubscribe_url, 174 } 175 } 176 177 /// Context for event summary with full details 178 pub fn event_summary( 179 event_name: &str, 180 event_description: Option<&str>, 181 event_location: Option<&str>, 182 event_start_time: &str, 183 event_end_time: Option<&str>, 184 event_url: &str, 185 unsubscribe_url: &str, 186 ) -> minijinja::Value { 187 context! { 188 event_name => event_name, 189 event_description => event_description, 190 event_location => event_location, 191 event_start_time => event_start_time, 192 event_end_time => event_end_time, 193 event_url => event_url, 194 unsubscribe_url => unsubscribe_url, 195 } 196 } 197 198 /// Context for RSVP acceptance notification 199 pub fn rsvp_accepted( 200 event_name: &str, 201 event_url: &str, 202 unsubscribe_url: &str, 203 ) -> minijinja::Value { 204 context! { 205 event_name => event_name, 206 event_url => event_url, 207 unsubscribe_url => unsubscribe_url, 208 } 209 } 210} 211 212#[cfg(test)] 213mod tests { 214 use super::*; 215 216 #[test] 217 fn test_email_template_engine_creation() { 218 let engine = EmailTemplateEngine::new(); 219 assert!(engine.is_ok()); 220 } 221 222 #[test] 223 fn test_render_text_template() { 224 let engine = EmailTemplateEngine::new().unwrap(); 225 let ctx = contexts::confirmation("https://example.com/confirm/abc123", None); 226 let result = engine.render_text("confirmation", ctx); 227 assert!(result.is_ok()); 228 let text = result.unwrap(); 229 assert!(text.contains("https://example.com/confirm/abc123")); 230 } 231 232 #[test] 233 fn test_render_html_template() { 234 let engine = EmailTemplateEngine::new().unwrap(); 235 let ctx = contexts::confirmation("https://example.com/confirm/abc123", None); 236 let result = engine.render_html("confirmation", ctx); 237 assert!(result.is_ok()); 238 let html = result.unwrap(); 239 assert!(html.contains("https://example.com/confirm/abc123")); 240 // Check that HTML is minified (no unnecessary whitespace at start) 241 assert!(!html.starts_with('\n')); 242 } 243 244 #[test] 245 fn test_render_24h_reminder_text() { 246 let engine = EmailTemplateEngine::new().unwrap(); 247 let ctx = contexts::event_reminder_24h( 248 "Community Meetup", 249 "Tomorrow at 3pm", 250 "https://example.com/events/123", 251 "https://example.com/unsubscribe/token123", 252 ); 253 let result = engine.render_text("event_reminder_24h", ctx); 254 assert!(result.is_ok()); 255 let text = result.unwrap(); 256 assert!(text.contains("Community Meetup")); 257 assert!(text.contains("Tomorrow at 3pm")); 258 assert!(text.contains("https://example.com/events/123")); 259 } 260 261 #[test] 262 fn test_render_24h_reminder_html() { 263 let engine = EmailTemplateEngine::new().unwrap(); 264 let ctx = contexts::event_reminder_24h( 265 "Community Meetup", 266 "Tomorrow at 3pm", 267 "https://example.com/events/123", 268 "https://example.com/unsubscribe/token123", 269 ); 270 let result = engine.render_html("event_reminder_24h", ctx); 271 assert!(result.is_ok()); 272 let html = result.unwrap(); 273 assert!(html.contains("Community Meetup")); 274 assert!(html.contains("Tomorrow at 3pm")); 275 assert!(html.contains("https://example.com/events/123")); 276 assert!(!html.starts_with('\n')); 277 } 278 279 #[test] 280 fn test_render_event_summary_text() { 281 let engine = EmailTemplateEngine::new().unwrap(); 282 let ctx = contexts::event_summary( 283 "Tech Conference", 284 Some("A great tech conference"), 285 Some("Convention Center"), 286 "Jan 15, 2025 at 10am", 287 Some("Jan 15, 2025 at 5pm"), 288 "https://example.com/events/456", 289 "https://example.com/unsubscribe/token456", 290 ); 291 let result = engine.render_text("event_summary", ctx); 292 assert!(result.is_ok()); 293 let text = result.unwrap(); 294 assert!(text.contains("Tech Conference")); 295 assert!(text.contains("A great tech conference")); 296 assert!(text.contains("Convention Center")); 297 assert!(text.contains("Jan 15, 2025 at 10am")); 298 assert!(text.contains("Jan 15, 2025 at 5pm")); 299 assert!(text.contains("https://example.com/events/456")); 300 } 301 302 #[test] 303 fn test_render_event_summary_html() { 304 let engine = EmailTemplateEngine::new().unwrap(); 305 let ctx = contexts::event_summary( 306 "Tech Conference", 307 Some("A great tech conference"), 308 Some("Convention Center"), 309 "Jan 15, 2025 at 10am", 310 Some("Jan 15, 2025 at 5pm"), 311 "https://example.com/events/456", 312 "https://example.com/unsubscribe/token456", 313 ); 314 let result = engine.render_html("event_summary", ctx); 315 assert!(result.is_ok()); 316 let html = result.unwrap(); 317 assert!(html.contains("Tech Conference")); 318 assert!(html.contains("A great tech conference")); 319 assert!(html.contains("Convention Center")); 320 assert!(html.contains("Jan 15, 2025 at 10am")); 321 assert!(html.contains("Jan 15, 2025 at 5pm")); 322 assert!(html.contains("https://example.com/events/456")); 323 assert!(!html.starts_with('\n')); 324 } 325}