Skip to main content

modo_email/template/
layout.rs

1use 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
44/// Renders HTML layout templates using [MiniJinja](https://docs.rs/minijinja).
45///
46/// The engine always includes a built-in `"default"` layout. Additional layouts
47/// are loaded from `{templates_path}/layouts/*.html` at construction time and
48/// override the built-in if they share the same name.
49///
50/// Auto-escaping is disabled because the `content` variable is already rendered HTML.
51pub struct LayoutEngine {
52    env: minijinja::Environment<'static>,
53}
54
55impl LayoutEngine {
56    /// Create a `LayoutEngine` that loads custom `.html` layouts from
57    /// `{templates_path}/layouts/` in addition to the built-in `"default"` layout.
58    ///
59    /// Returns an error if any layout file contains invalid template syntax.
60    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    /// Create a `LayoutEngine` that loads custom `.html` layouts from
89    /// `{templates_path}/layouts/` in addition to the built-in `"default"` layout.
90    ///
91    /// # Panics
92    /// Panics if any layout file contains invalid template syntax.
93    /// Use [`try_new`](Self::try_new) for a fallible alternative.
94    pub fn new(templates_path: &str) -> Self {
95        Self::try_new(templates_path).expect("all layout templates must be valid")
96    }
97
98    /// Create a `LayoutEngine` with only the built-in `"default"` layout.
99    ///
100    /// Useful in tests or when no custom layouts are needed.
101    pub fn builtin_only() -> Self {
102        Self {
103            env: Self::base_env(),
104        }
105    }
106
107    /// Render the named layout with the provided MiniJinja context.
108    ///
109    /// `layout_name` is looked up as `layouts/{layout_name}.html`. Returns an
110    /// error if the layout does not exist or if rendering fails.
111    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    /// Creates a base environment with the built-in default layout and
130    /// auto-escaping disabled (email content is pre-rendered HTML).
131    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")); // subject in <title>
159        assert!(html.contains("max-width")); // responsive wrapper
160    }
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        // No logo_url or footer_text — should render without error
206        let html = engine.render("default", &ctx).unwrap();
207        assert!(html.contains("<p>Hello</p>"));
208        assert!(html.contains("Test"));
209        // logo_url block should be skipped ({% if logo_url %} is falsy)
210        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 &amp; more</p>",
218            subject => "Test",
219        };
220        let html = engine.render("default", &ctx).unwrap();
221        // Auto-escape is disabled, so HTML should be rendered verbatim
222        assert!(html.contains("<h1>Title</h1>"));
223        assert!(html.contains("<p>Body &amp; 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}