modo_email/template/
layout.rs1use std::path::Path;
2
3pub(crate) const DEFAULT_LAYOUT: &str = r#"<!DOCTYPE html>
4<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
5<head>
6<meta charset="utf-8">
7<meta name="viewport" content="width=device-width, initial-scale=1">
8<meta http-equiv="X-UA-Compatible" content="IE=edge">
9<title>{{subject}}</title>
10<style>
11 @media (prefers-color-scheme: dark) {
12 body { background-color: #1a1a1a !important; }
13 .email-wrapper { background-color: #2d2d2d !important; }
14 .email-body { color: #e0e0e0 !important; }
15 }
16 @media only screen and (max-width: 620px) {
17 .email-wrapper { width: 100% !important; padding: 16px !important; }
18 }
19</style>
20</head>
21<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
22<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5">
23<tr><td align="center" style="padding:32px 16px">
24 <!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
25 <table role="presentation" class="email-wrapper" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background-color:#ffffff;border-radius:8px;overflow:hidden">
26 {% if logo_url %}
27 <tr><td style="padding:24px 32px 0;text-align:center">
28 <img src="{{logo_url}}" alt="{{product_name | default(value="")}}" style="max-height:48px;width:auto">
29 </td></tr>
30 {% endif %}
31 <tr><td class="email-body" style="padding:32px;color:#1f2937;font-size:16px;line-height:1.6">
32 {{content}}
33 </td></tr>
34 <tr><td style="padding:16px 32px 32px;color:#6b7280;font-size:13px;text-align:center;border-top:1px solid #e5e7eb">
35 {{footer_text | default(value="")}}
36 </td></tr>
37 </table>
38 <!--[if mso]></td></tr></table><![endif]-->
39</td></tr>
40</table>
41</body>
42</html>"#;
43
44pub struct LayoutEngine {
52 env: minijinja::Environment<'static>,
53}
54
55impl LayoutEngine {
56 pub fn try_new(templates_path: &str) -> Result<Self, modo::Error> {
61 let mut env = Self::base_env();
62
63 let layouts_dir = Path::new(templates_path).join("layouts");
64 if layouts_dir.is_dir()
65 && let Ok(entries) = std::fs::read_dir(&layouts_dir)
66 {
67 for entry in entries.flatten() {
68 let path = entry.path();
69 if path.extension().is_some_and(|e| e == "html")
70 && let (Some(stem), Ok(content)) = (
71 path.file_stem().and_then(|s| s.to_str()),
72 std::fs::read_to_string(&path),
73 )
74 {
75 env.add_template_owned(format!("layouts/{stem}.html"), content)
76 .map_err(|e| {
77 modo::Error::internal(format!(
78 "invalid layout template '{stem}.html': {e}"
79 ))
80 })?;
81 }
82 }
83 }
84
85 Ok(Self { env })
86 }
87
88 pub fn new(templates_path: &str) -> Self {
95 Self::try_new(templates_path).expect("all layout templates must be valid")
96 }
97
98 pub fn builtin_only() -> Self {
102 Self {
103 env: Self::base_env(),
104 }
105 }
106
107 pub fn render(
112 &self,
113 layout_name: &str,
114 context: &minijinja::Value,
115 ) -> Result<String, modo::Error> {
116 let template_name = format!("layouts/{layout_name}.html");
117
118 let tmpl = self.env.get_template(&template_name).map_err(|_| {
119 tracing::debug!(layout_name = %layout_name, "email layout not found");
120 modo::Error::internal(format!("Layout not found: {layout_name}"))
121 })?;
122
123 tmpl.render(context).map_err(|e| {
124 tracing::error!(layout_name = %layout_name, error = %e, "email layout render failed");
125 modo::Error::internal(format!("Layout render error: {e}"))
126 })
127 }
128
129 fn base_env() -> minijinja::Environment<'static> {
132 let mut env = minijinja::Environment::new();
133 env.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
134 env.add_template_owned(
135 "layouts/default.html".to_string(),
136 DEFAULT_LAYOUT.to_string(),
137 )
138 .expect("built-in layout is valid");
139 env
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::fs;
147
148 #[test]
149 fn render_with_builtin_layout() {
150 let engine = LayoutEngine::builtin_only();
151 let ctx = minijinja::context! {
152 content => "<p>Hello</p>",
153 subject => "Test",
154 };
155
156 let html = engine.render("default", &ctx).unwrap();
157 assert!(html.contains("<p>Hello</p>"));
158 assert!(html.contains("Test")); assert!(html.contains("max-width")); }
161
162 #[test]
163 fn custom_layout_overrides_builtin() {
164 let dir = tempfile::tempdir().unwrap();
165 let layouts_dir = dir.path().join("layouts");
166 fs::create_dir_all(&layouts_dir).unwrap();
167 fs::write(
168 layouts_dir.join("default.html"),
169 "<html><body>CUSTOM: {{content}}</body></html>",
170 )
171 .unwrap();
172
173 let engine = LayoutEngine::new(dir.path().to_str().unwrap());
174 let ctx = minijinja::context! {
175 content => "<p>Hi</p>",
176 };
177
178 let html = engine.render("default", &ctx).unwrap();
179 assert!(html.contains("CUSTOM: <p>Hi</p>"));
180 }
181
182 #[test]
183 fn missing_layout_errors() {
184 let engine = LayoutEngine::builtin_only();
185 let ctx = minijinja::context! {};
186 let result = engine.render("nonexistent", &ctx);
187 assert!(result.is_err());
188 }
189
190 #[test]
191 fn empty_layout_name() {
192 let engine = LayoutEngine::builtin_only();
193 let ctx = minijinja::context! {};
194 let result = engine.render("", &ctx);
195 assert!(result.is_err());
196 }
197
198 #[test]
199 fn missing_optional_context_vars() {
200 let engine = LayoutEngine::builtin_only();
201 let ctx = minijinja::context! {
202 content => "<p>Hello</p>",
203 subject => "Test",
204 };
205 let html = engine.render("default", &ctx).unwrap();
207 assert!(html.contains("<p>Hello</p>"));
208 assert!(html.contains("Test"));
209 assert!(!html.contains("<img"));
211 }
212
213 #[test]
214 fn context_with_html_in_content() {
215 let engine = LayoutEngine::builtin_only();
216 let ctx = minijinja::context! {
217 content => "<h1>Title</h1><p>Body & more</p>",
218 subject => "Test",
219 };
220 let html = engine.render("default", &ctx).unwrap();
221 assert!(html.contains("<h1>Title</h1>"));
223 assert!(html.contains("<p>Body & more</p>"));
224 }
225
226 #[test]
227 fn invalid_layout_syntax_returns_error() {
228 let dir = tempfile::tempdir().unwrap();
229 let layouts_dir = dir.path().join("layouts");
230 fs::create_dir_all(&layouts_dir).unwrap();
231 fs::write(layouts_dir.join("broken.html"), "{% if unclosed %}").unwrap();
232
233 let result = LayoutEngine::try_new(dir.path().to_str().unwrap());
234 assert!(result.is_err());
235 }
236}