Skip to main content

gatekpr_email/templates/
loader.rs

1//! Template file loading and management
2//!
3//! Loads MJML template files from the filesystem or embedded resources.
4
5use crate::error::{EmailError, Result};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use tokio::sync::RwLock;
10use tracing::{debug, info};
11
12/// Template loader for managing MJML template files
13///
14/// Supports loading templates from:
15/// - Filesystem (for development)
16/// - Embedded resources (for production)
17///
18/// Templates are cached for efficiency.
19pub struct TemplateLoader {
20    /// Template directory path
21    template_dir: PathBuf,
22    /// Cached templates (name -> content)
23    cache: Arc<RwLock<HashMap<String, String>>>,
24    /// Whether to use embedded templates
25    use_embedded: bool,
26}
27
28impl TemplateLoader {
29    /// Create a new template loader
30    ///
31    /// # Arguments
32    ///
33    /// * `template_dir` - Directory containing MJML templates
34    pub fn new(template_dir: impl AsRef<Path>) -> Self {
35        Self {
36            template_dir: template_dir.as_ref().to_path_buf(),
37            cache: Arc::new(RwLock::new(HashMap::new())),
38            use_embedded: false,
39        }
40    }
41
42    /// Create a loader using embedded templates
43    pub fn embedded() -> Self {
44        Self {
45            template_dir: PathBuf::new(),
46            cache: Arc::new(RwLock::new(HashMap::new())),
47            use_embedded: true,
48        }
49    }
50
51    /// Load a template by name
52    ///
53    /// # Arguments
54    ///
55    /// * `name` - Template name (without .mjml extension)
56    ///
57    /// # Returns
58    ///
59    /// The template content as a string.
60    pub async fn load(&self, name: &str) -> Result<String> {
61        // Check cache first
62        {
63            let cache = self.cache.read().await;
64            if let Some(content) = cache.get(name) {
65                debug!(template = %name, "Using cached template");
66                return Ok(content.clone());
67            }
68        }
69
70        // Load template
71        let content = if self.use_embedded {
72            self.load_embedded(name)?
73        } else {
74            self.load_from_file(name).await?
75        };
76
77        // Cache the template
78        {
79            let mut cache = self.cache.write().await;
80            cache.insert(name.to_string(), content.clone());
81        }
82
83        info!(template = %name, "Template loaded and cached");
84        Ok(content)
85    }
86
87    /// Load template from filesystem
88    async fn load_from_file(&self, name: &str) -> Result<String> {
89        let path = self.template_dir.join(format!("{}.mjml", name));
90
91        tokio::fs::read_to_string(&path)
92            .await
93            .map_err(|e| EmailError::TemplateNotFound(format!("{}: {}", path.display(), e)))
94    }
95
96    /// Load embedded template
97    fn load_embedded(&self, name: &str) -> Result<String> {
98        // Embedded templates for production use
99        match name {
100            "welcome" => Ok(include_str!("../../templates/welcome.mjml").to_string()),
101            "verify-email" => Ok(include_str!("../../templates/verify-email.mjml").to_string()),
102            "password-reset" => Ok(include_str!("../../templates/password-reset.mjml").to_string()),
103            "password-changed" => {
104                Ok(include_str!("../../templates/password-changed.mjml").to_string())
105            }
106            "api-key-generated" => {
107                Ok(include_str!("../../templates/api-key-generated.mjml").to_string())
108            }
109            "new-login" => Ok(include_str!("../../templates/new-login.mjml").to_string()),
110            "device-auth" => Ok(include_str!("../../templates/device-auth.mjml").to_string()),
111            "validation-complete" => {
112                Ok(include_str!("../../templates/validation-complete.mjml").to_string())
113            }
114            _ => Err(EmailError::TemplateNotFound(format!(
115                "Unknown embedded template: {}",
116                name
117            ))),
118        }
119    }
120
121    /// Clear the template cache
122    pub async fn clear_cache(&self) {
123        let mut cache = self.cache.write().await;
124        cache.clear();
125        info!("Template cache cleared");
126    }
127
128    /// Preload all templates into cache
129    pub async fn preload_all(&self) -> Result<()> {
130        let templates = [
131            "welcome",
132            "verify-email",
133            "password-reset",
134            "password-changed",
135            "api-key-generated",
136            "new-login",
137            "device-auth",
138            "validation-complete",
139        ];
140
141        for name in templates {
142            self.load(name).await?;
143        }
144
145        info!(count = templates.len(), "All templates preloaded");
146        Ok(())
147    }
148}
149
150impl Default for TemplateLoader {
151    fn default() -> Self {
152        Self::embedded()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[tokio::test]
161    async fn test_cache_operations() {
162        let loader = TemplateLoader::new("/tmp/nonexistent");
163
164        // Cache should be empty initially
165        {
166            let cache = loader.cache.read().await;
167            assert!(cache.is_empty());
168        }
169
170        // Clear should work on empty cache
171        loader.clear_cache().await;
172    }
173}