modo_email/template/
filesystem.rs1use super::{EmailTemplate, TemplateProvider};
2use std::path::PathBuf;
3
4pub struct FilesystemProvider {
14 base_dir: PathBuf,
15}
16
17impl FilesystemProvider {
18 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 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 let result = provider.get("welcome.md", "");
200 assert!(result.is_err());
201 }
202}