Skip to main content

modo_email/template/
filesystem.rs

1use super::{EmailTemplate, TemplateProvider};
2use std::path::PathBuf;
3
4/// Loads email templates from the filesystem with locale-based fallback.
5///
6/// Templates are stored as `.md` files with YAML frontmatter. Localized
7/// variants live in subdirectories named after the locale (e.g. `de/welcome.md`).
8/// When a localized file is not found, the provider falls back to the root
9/// template (`welcome.md`).
10///
11/// Path traversal attempts (names or locales containing `..`, `/`, or `\`)
12/// are rejected and return an error.
13pub struct FilesystemProvider {
14    base_dir: PathBuf,
15}
16
17impl FilesystemProvider {
18    /// Create a provider rooted at `base_dir`.
19    ///
20    /// The directory does not need to exist at construction time; errors are
21    /// returned when a template is requested and the file cannot be found.
22    pub fn new(base_dir: impl Into<PathBuf>) -> Self {
23        Self {
24            base_dir: base_dir.into(),
25        }
26    }
27
28    fn resolve_path(&self, name: &str, locale: &str) -> Option<PathBuf> {
29        // Reject path traversal attempts
30        if name.contains("..")
31            || name.contains('/')
32            || name.contains('\\')
33            || locale.contains("..")
34            || locale.contains('/')
35            || locale.contains('\\')
36        {
37            return None;
38        }
39
40        if !locale.is_empty() {
41            let localized = self.base_dir.join(locale).join(format!("{name}.md"));
42            if localized.is_file() {
43                return Some(localized);
44            }
45        }
46
47        let root = self.base_dir.join(format!("{name}.md"));
48        if root.is_file() {
49            return Some(root);
50        }
51
52        None
53    }
54}
55
56impl TemplateProvider for FilesystemProvider {
57    fn get(&self, name: &str, locale: &str) -> Result<EmailTemplate, modo::Error> {
58        let path = self.resolve_path(name, locale).ok_or_else(|| {
59            tracing::debug!(
60                template_name = %name,
61                locale = %locale,
62                "email template not found on filesystem"
63            );
64            modo::Error::internal(format!("Email template not found: {name}"))
65        })?;
66
67        tracing::debug!(
68            template_name = %name,
69            locale = %locale,
70            path = %path.display(),
71            "loading email template from filesystem"
72        );
73
74        let raw = std::fs::read_to_string(&path).map_err(|e| {
75            modo::Error::internal(format!("Failed to read template {}: {e}", path.display()))
76        })?;
77
78        EmailTemplate::parse(&raw)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::template::TemplateProvider;
86    use std::fs;
87
88    #[test]
89    fn load_template_no_locale() {
90        let dir = tempfile::tempdir().unwrap();
91        let path = dir.path();
92        fs::write(
93            path.join("welcome.md"),
94            "---\nsubject: \"Hi\"\n---\n\nHello!",
95        )
96        .unwrap();
97
98        let provider = FilesystemProvider::new(path.to_str().unwrap());
99        let tpl = provider.get("welcome", "").unwrap();
100        assert_eq!(tpl.subject, "Hi");
101    }
102
103    #[test]
104    fn load_template_with_locale() {
105        let dir = tempfile::tempdir().unwrap();
106        let path = dir.path();
107        fs::create_dir_all(path.join("de")).unwrap();
108        fs::write(
109            path.join("de/welcome.md"),
110            "---\nsubject: \"Hallo\"\n---\n\nHallo!",
111        )
112        .unwrap();
113        fs::write(
114            path.join("welcome.md"),
115            "---\nsubject: \"Hi\"\n---\n\nHello!",
116        )
117        .unwrap();
118
119        let provider = FilesystemProvider::new(path.to_str().unwrap());
120        let tpl = provider.get("welcome", "de").unwrap();
121        assert_eq!(tpl.subject, "Hallo");
122    }
123
124    #[test]
125    fn locale_fallback_to_root() {
126        let dir = tempfile::tempdir().unwrap();
127        let path = dir.path();
128        fs::write(
129            path.join("welcome.md"),
130            "---\nsubject: \"Hi\"\n---\n\nHello!",
131        )
132        .unwrap();
133
134        let provider = FilesystemProvider::new(path.to_str().unwrap());
135        let tpl = provider.get("welcome", "fr").unwrap();
136        assert_eq!(tpl.subject, "Hi");
137    }
138
139    #[test]
140    fn template_not_found() {
141        let dir = tempfile::tempdir().unwrap();
142        let provider = FilesystemProvider::new(dir.path().to_str().unwrap());
143        let result = provider.get("missing", "");
144        assert!(result.is_err());
145    }
146
147    #[test]
148    fn path_traversal_in_name_rejected() {
149        let dir = tempfile::tempdir().unwrap();
150        let provider = FilesystemProvider::new(dir.path().to_str().unwrap());
151        let result = provider.get("../secret", "");
152        assert!(result.is_err());
153    }
154
155    #[test]
156    fn path_traversal_in_locale_rejected() {
157        let dir = tempfile::tempdir().unwrap();
158        let provider = FilesystemProvider::new(dir.path().to_str().unwrap());
159        let result = provider.get("welcome", "../../etc");
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn backslash_traversal_rejected() {
165        let dir = tempfile::tempdir().unwrap();
166        let provider = FilesystemProvider::new(dir.path().to_str().unwrap());
167        let result = provider.get("..\\secret", "");
168        assert!(result.is_err());
169    }
170
171    #[test]
172    fn forward_slash_in_name_rejected() {
173        let dir = tempfile::tempdir().unwrap();
174        let provider = FilesystemProvider::new(dir.path().to_str().unwrap());
175        let result = provider.get("sub/template", "");
176        assert!(result.is_err());
177    }
178
179    #[test]
180    fn empty_locale_uses_root() {
181        let dir = tempfile::tempdir().unwrap();
182        let path = dir.path();
183        fs::write(
184            path.join("welcome.md"),
185            "---\nsubject: \"Root\"\n---\n\nRoot body",
186        )
187        .unwrap();
188
189        let provider = FilesystemProvider::new(path.to_str().unwrap());
190        let tpl = provider.get("welcome", "").unwrap();
191        assert_eq!(tpl.subject, "Root");
192    }
193
194    #[test]
195    fn name_with_md_extension() {
196        let dir = tempfile::tempdir().unwrap();
197        let provider = FilesystemProvider::new(dir.path().to_str().unwrap());
198        // "welcome.md" → tries "welcome.md.md" → not found
199        let result = provider.get("welcome.md", "");
200        assert!(result.is_err());
201    }
202}