The smokesignal.events web application
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}