Skip to main content

modo/email/
source.rs

1use crate::{Error, Result};
2use std::path::{Path, PathBuf};
3
4/// Trait for loading raw email templates (frontmatter + body).
5///
6/// Implementations must be `Send + Sync` for use in `Arc<dyn TemplateSource>`.
7/// The returned string is the raw template content including the YAML frontmatter
8/// block and Markdown body — parsing is handled downstream by the [`Mailer`](crate::email::Mailer).
9pub trait TemplateSource: Send + Sync {
10    /// Load a template by `name` for the given `locale`.
11    ///
12    /// `default_locale` is the application-wide fallback locale, used when a
13    /// locale-specific file does not exist.
14    ///
15    /// # Errors
16    ///
17    /// Returns an error when no suitable template can be found for the
18    /// given name and locale combination.
19    fn load(&self, name: &str, locale: &str, default_locale: &str) -> Result<String>;
20}
21
22/// Loads templates from the filesystem with locale fallback.
23///
24/// Fallback chain:
25/// 1. `{path}/{locale}/{name}.md`
26/// 2. `{path}/{default_locale}/{name}.md`
27/// 3. `{path}/{name}.md`
28/// 4. Error
29pub struct FileSource {
30    path: PathBuf,
31}
32
33impl FileSource {
34    /// Create a `FileSource` rooted at `templates_path`.
35    pub fn new(templates_path: impl Into<PathBuf>) -> Self {
36        Self {
37            path: templates_path.into(),
38        }
39    }
40
41    fn try_load(&self, file_path: &Path) -> Option<String> {
42        std::fs::read_to_string(file_path).ok()
43    }
44}
45
46impl TemplateSource for FileSource {
47    fn load(&self, name: &str, locale: &str, default_locale: &str) -> Result<String> {
48        let filename = format!("{name}.md");
49
50        // 1. Exact locale
51        let path = self.path.join(locale).join(&filename);
52        if let Some(content) = self.try_load(&path) {
53            return Ok(content);
54        }
55
56        // 2. Default locale (skip if same as exact)
57        if locale != default_locale {
58            let path = self.path.join(default_locale).join(&filename);
59            if let Some(content) = self.try_load(&path) {
60                return Ok(content);
61            }
62        }
63
64        // 3. No-locale fallback
65        let path = self.path.join(&filename);
66        if let Some(content) = self.try_load(&path) {
67            return Ok(content);
68        }
69
70        // 4. Error
71        Err(Error::not_found(format!(
72            "email template '{name}' not found for locale '{locale}'"
73        )))
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    fn setup_templates(dir: &std::path::Path) {
82        // en/welcome.md
83        std::fs::create_dir_all(dir.join("en")).unwrap();
84        std::fs::write(
85            dir.join("en/welcome.md"),
86            "---\nsubject: Welcome EN\n---\nEnglish body",
87        )
88        .unwrap();
89
90        // uk/welcome.md
91        std::fs::create_dir_all(dir.join("uk")).unwrap();
92        std::fs::write(
93            dir.join("uk/welcome.md"),
94            "---\nsubject: Welcome UK\n---\nUkrainian body",
95        )
96        .unwrap();
97
98        // fallback.md (no locale dir)
99        std::fs::write(
100            dir.join("fallback.md"),
101            "---\nsubject: Fallback\n---\nFallback body",
102        )
103        .unwrap();
104    }
105
106    #[test]
107    fn load_exact_locale() {
108        let dir = tempfile::tempdir().unwrap();
109        setup_templates(dir.path());
110        let source = FileSource::new(dir.path());
111
112        let content = source.load("welcome", "uk", "en").unwrap();
113        assert!(content.contains("Ukrainian body"));
114    }
115
116    #[test]
117    fn load_falls_back_to_default_locale() {
118        let dir = tempfile::tempdir().unwrap();
119        setup_templates(dir.path());
120        let source = FileSource::new(dir.path());
121
122        let content = source.load("welcome", "fr", "en").unwrap();
123        assert!(content.contains("English body"));
124    }
125
126    #[test]
127    fn load_falls_back_to_no_locale() {
128        let dir = tempfile::tempdir().unwrap();
129        setup_templates(dir.path());
130        let source = FileSource::new(dir.path());
131
132        let content = source.load("fallback", "fr", "en").unwrap();
133        assert!(content.contains("Fallback body"));
134    }
135
136    #[test]
137    fn load_not_found() {
138        let dir = tempfile::tempdir().unwrap();
139        setup_templates(dir.path());
140        let source = FileSource::new(dir.path());
141
142        let result = source.load("nonexistent", "en", "en");
143        assert!(result.is_err());
144    }
145
146    #[test]
147    fn load_same_locale_as_default_skips_duplicate() {
148        let dir = tempfile::tempdir().unwrap();
149        setup_templates(dir.path());
150        let source = FileSource::new(dir.path());
151
152        // locale == default_locale, should still find it on first try
153        let content = source.load("welcome", "en", "en").unwrap();
154        assert!(content.contains("English body"));
155    }
156}