Skip to main content

shared/extensions/email_templates/
mod.rs

1use garde::Validate;
2use serde::{Deserialize, Serialize};
3use sqlx::Row;
4use std::{borrow::Cow, collections::BTreeMap, sync::Arc};
5use utoipa::ToSchema;
6
7mod variables;
8pub use variables::*;
9
10#[derive(Clone)]
11pub struct EmailTemplate {
12    pub identifier: &'static str,
13    pub available_variables: Vec<&'static str>,
14    pub default_subject: &'static str,
15    pub default_content: &'static str,
16    pub default_enabled: bool,
17}
18
19#[derive(ToSchema, Validate, Serialize, Deserialize)]
20pub struct UpdateEmailTemplate {
21    #[garde(length(chars, min = 1))]
22    #[schema(min_length = 1)]
23    #[serde(default, with = "::serde_with::rust::double_option")]
24    pub content: Option<Option<String>>,
25    #[garde(length(chars, min = 1, max = 255))]
26    #[schema(min_length = 1, max_length = 255)]
27    #[serde(default, with = "::serde_with::rust::double_option")]
28    pub subject: Option<Option<String>>,
29    #[garde(skip)]
30    pub enabled: Option<bool>,
31}
32
33pub struct FetchedEmailTemplate {
34    pub identifier: &'static str,
35    pub available_variables: Vec<&'static str>,
36    pub subject: Cow<'static, str>,
37    pub content: Cow<'static, str>,
38    pub enabled: bool,
39}
40
41impl EmailTemplate {
42    pub async fn get(&self, state: &crate::State) -> Result<FetchedEmailTemplate, anyhow::Error> {
43        let db_content: Option<(bool, Option<String>, Option<String>)> = state
44            .cache
45            .cached(
46                &format!("email_templates::{}", self.identifier),
47                15,
48                || async {
49                    let Some(row) = sqlx::query("SELECT enabled, subject, content FROM email_templates WHERE identifier = $1")
50                        .bind(self.identifier)
51                        .fetch_optional(state.database.read())
52                        .await? else {
53                            return Ok(None);
54                        };
55
56                    Ok::<_, anyhow::Error>(Some((
57                        row.try_get("enabled")?,
58                        row.try_get("subject")?,
59                        row.try_get("content")?,
60                    )))
61                },
62            )
63            .await?;
64
65        Ok(match db_content {
66            Some((enabled, subject, content)) => FetchedEmailTemplate {
67                identifier: self.identifier,
68                available_variables: self.available_variables.clone(),
69                subject: subject
70                    .map(Cow::Owned)
71                    .unwrap_or(Cow::Borrowed(self.default_subject)),
72                content: content
73                    .map(Cow::Owned)
74                    .unwrap_or(Cow::Borrowed(self.default_content)),
75                enabled,
76            },
77            None => FetchedEmailTemplate {
78                identifier: self.identifier,
79                available_variables: self.available_variables.clone(),
80                subject: Cow::Borrowed(self.default_subject),
81                content: Cow::Borrowed(self.default_content),
82                enabled: self.default_enabled,
83            },
84        })
85    }
86
87    pub async fn update(
88        &self,
89        state: &crate::State,
90        data: UpdateEmailTemplate,
91    ) -> Result<(), anyhow::Error> {
92        let (subject_set, subject_val) = match data.subject {
93            None => (false, None),
94            Some(inner) => (true, inner),
95        };
96        let (content_set, content_val) = match data.content {
97            None => (false, None),
98            Some(inner) => (true, inner),
99        };
100
101        let insert_enabled = data.enabled.unwrap_or(self.default_enabled);
102
103        sqlx::query(
104            "INSERT INTO email_templates (identifier, subject, content, enabled)
105            VALUES ($1, $2, $3, $4)
106            ON CONFLICT (identifier) DO UPDATE SET
107                subject = CASE
108                    WHEN $5 THEN $2
109                    ELSE email_templates.subject
110                END,
111                content = CASE
112                    WHEN $6 THEN $3
113                    ELSE email_templates.content
114                END,
115                enabled = COALESCE($7, email_templates.enabled)",
116        )
117        .bind(self.identifier)
118        .bind(subject_val.as_deref())
119        .bind(content_val.as_deref())
120        .bind(insert_enabled)
121        .bind(subject_set)
122        .bind(content_set)
123        .bind(data.enabled)
124        .execute(state.database.write())
125        .await?;
126
127        state
128            .cache
129            .invalidate(&format!("email_templates::{}", self.identifier))
130            .await?;
131
132        Ok(())
133    }
134}
135
136#[derive(Clone)]
137pub struct ExtensionEmailTemplateBuilder {
138    pub templates: Vec<EmailTemplate>,
139    pub variables: Vec<EmailVariable>,
140}
141
142impl Default for ExtensionEmailTemplateBuilder {
143    fn default() -> Self {
144        Self {
145            templates: vec![
146                EmailTemplate {
147                    identifier: "account_created",
148                    available_variables: vec!["user", "reset_link"],
149                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
150                    default_content: include_str!("../../../mails/account_created.html"),
151                    default_enabled: true,
152                },
153                EmailTemplate {
154                    identifier: "password_reset",
155                    available_variables: vec!["user", "reset_link"],
156                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
157                    default_content: include_str!("../../../mails/password_reset.html"),
158                    default_enabled: true,
159                },
160                EmailTemplate {
161                    identifier: "email_verification",
162                    available_variables: vec!["user", "email", "verification_link"],
163                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
164                    default_content: include_str!("../../../mails/email_verification.html"),
165                    default_enabled: true,
166                },
167                EmailTemplate {
168                    identifier: "two_factor_code",
169                    available_variables: vec!["user", "code"],
170                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
171                    default_content: include_str!("../../../mails/two_factor_code.html"),
172                    default_enabled: true,
173                },
174                EmailTemplate {
175                    identifier: "session_created",
176                    available_variables: vec!["user", "ip", "user_agent", "sessions_link"],
177                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
178                    default_content: include_str!("../../../mails/session_created.html"),
179                    default_enabled: false,
180                },
181                EmailTemplate {
182                    identifier: "connection_test",
183                    available_variables: vec![],
184                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
185                    default_content: include_str!("../../../mails/connection_test.html"),
186                    default_enabled: true,
187                },
188                EmailTemplate {
189                    identifier: "added_to_server",
190                    available_variables: vec!["user", "server", "server_link"],
191                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
192                    default_content: include_str!("../../../mails/added_to_server.html"),
193                    default_enabled: true,
194                },
195                EmailTemplate {
196                    identifier: "removed_from_server",
197                    available_variables: vec!["server"],
198                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
199                    default_content: include_str!("../../../mails/removed_from_server.html"),
200                    default_enabled: true,
201                },
202                EmailTemplate {
203                    identifier: "server_installed",
204                    available_variables: vec!["user", "server", "server_link"],
205                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
206                    default_content: include_str!("../../../mails/server_installed.html"),
207                    default_enabled: false,
208                },
209                EmailTemplate {
210                    identifier: "server_restored",
211                    available_variables: vec!["user", "server", "server_link"],
212                    default_subject: "{{ settings.app.name }} - {{ vars.subject }}",
213                    default_content: include_str!("../../../mails/server_restored.html"),
214                    default_enabled: false,
215                },
216            ],
217            variables: core_variables(),
218        }
219    }
220}
221
222impl ExtensionEmailTemplateBuilder {
223    /// Add a new email template to the system, this will not override any existing templates, if you want to override an existing template, use `mutate_template` instead
224    pub fn add_template(mut self, template: EmailTemplate) -> Self {
225        if self
226            .templates
227            .iter()
228            .all(|t| t.identifier != template.identifier)
229        {
230            self.templates.push(template);
231        }
232
233        self
234    }
235
236    /// Mutate an existing template, useful for changing the default content, should not extend the variables, as the caller will not be
237    /// aware of the new variables and thus will not be able to use them, if you need to add variables, consider adding a new template instead
238    pub fn mutate_template(
239        mut self,
240        identifier: &'static str,
241        mutation: impl FnOnce(&mut EmailTemplate),
242    ) -> Self {
243        if let Some(template) = self
244            .templates
245            .iter_mut()
246            .find(|t| t.identifier == identifier)
247        {
248            mutation(template);
249        }
250
251        self
252    }
253
254    /// Declare a system variable with its English default, available to the given template as `{{ vars.<name> }}`
255    /// (or to every template when `template_identifier` is `None`). Existing variables are left untouched.
256    pub fn add_template_variable(
257        mut self,
258        template_identifier: Option<&'static str>,
259        name: &'static str,
260        default_value: &'static str,
261    ) -> Self {
262        if let Err(err) = validate_variable_name(&name.into(), &()) {
263            tracing::error!("ignoring email variable '{}': {}", name, err);
264            return self;
265        }
266
267        if self
268            .variables
269            .iter()
270            .all(|v| !v.matches(template_identifier, name))
271        {
272            self.variables.push(EmailVariable {
273                template_identifier: template_identifier.map(Into::into),
274                name: name.into(),
275                default_value: default_value.to_string(),
276                default_translations: BTreeMap::new(),
277            });
278        }
279
280        self
281    }
282
283    /// Declare variables and their translations from an embedded directory of `<language>.json` files,
284    /// the same layout the panel uses for its own templates: `en.json` holds
285    /// `{ "<template identifier>": { "<name>": "<fragment>" } }`, every other file the translations.
286    pub fn add_template_variables(mut self, directory: &include_dir::Dir<'_>) -> Self {
287        load_variables(directory, &mut self.variables);
288
289        self
290    }
291
292    /// Ship a translated default for a variable declared with `add_template_variable`.
293    pub fn add_template_variable_translation(
294        mut self,
295        template_identifier: Option<&'static str>,
296        name: &'static str,
297        language: &'static str,
298        value: &'static str,
299    ) -> Self {
300        if let Some(variable) = self
301            .variables
302            .iter_mut()
303            .find(|v| v.matches(template_identifier, name))
304        {
305            variable
306                .default_translations
307                .insert(language.into(), value.to_string());
308        }
309
310        self
311    }
312
313    /// Mutate an existing variable, useful for changing the default wording of a core template without replacing its layout.
314    pub fn mutate_template_variable(
315        mut self,
316        template_identifier: Option<&'static str>,
317        name: &'static str,
318        mutation: impl FnOnce(&mut EmailVariable),
319    ) -> Self {
320        if let Some(variable) = self
321            .variables
322            .iter_mut()
323            .find(|v| v.matches(template_identifier, name))
324        {
325            mutation(variable);
326        }
327
328        self
329    }
330
331    pub(super) fn finish(mut self) -> (Vec<Arc<EmailTemplate>>, Vec<Arc<EmailVariable>>) {
332        for template in &mut self.templates {
333            for global in ["settings", "language"] {
334                if !template.available_variables.contains(&global) {
335                    template.available_variables.push(global);
336                }
337            }
338        }
339
340        (
341            self.templates.into_iter().map(Arc::new).collect(),
342            self.variables.into_iter().map(Arc::new).collect(),
343        )
344    }
345}
346
347pub struct EmailTemplateManager {
348    pub(super) templates: parking_lot::RwLock<Vec<Arc<EmailTemplate>>>,
349    pub(super) variables: parking_lot::RwLock<Vec<Arc<EmailVariable>>>,
350}
351
352impl Default for EmailTemplateManager {
353    fn default() -> Self {
354        Self {
355            templates: parking_lot::RwLock::new(vec![]),
356            variables: parking_lot::RwLock::new(vec![]),
357        }
358    }
359}
360
361impl EmailTemplateManager {
362    pub fn get_templates(&self) -> parking_lot::RwLockReadGuard<'_, Vec<Arc<EmailTemplate>>> {
363        self.templates.read()
364    }
365
366    pub fn get_template(&self, identifier: &str) -> Result<Arc<EmailTemplate>, anyhow::Error> {
367        self.templates
368            .read()
369            .iter()
370            .find(|t| t.identifier == identifier)
371            .cloned()
372            .ok_or_else(|| anyhow::anyhow!("template with identifier '{}' not found", identifier))
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    fn fixture_context() -> minijinja::Value {
381        minijinja::context! {
382            user => minijinja::value::Serde(serde_json::json!({ "username": "tester", "email": "tester@example.com" })),
383            server => minijinja::value::Serde(serde_json::json!({ "name": "Example Server" })),
384            email => "tester@example.com",
385            reset_link => "https://panel.example.com/auth/reset-password?token=x",
386            verification_link => "https://panel.example.com/auth/verify-email?token=x",
387            sessions_link => "https://panel.example.com/account/sessions",
388            server_link => "https://panel.example.com/server/00000001",
389            code => "123456",
390            ip => "127.0.0.1",
391            user_agent => "test-agent",
392        }
393    }
394
395    fn strict_environment(
396        variables: &BTreeMap<compact_str::CompactString, String>,
397    ) -> minijinja::Environment<'static> {
398        let mut environment = minijinja::Environment::new();
399        environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
400        environment.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
401        environment.add_global(
402            "settings",
403            minijinja::Value::from(minijinja::value::Serde(serde_json::json!({
404                "app": { "name": "Panel & Co", "url": "https://panel.example.com" }
405            }))),
406        );
407        environment.add_global("language", "en");
408        environment.add_global("subject", "Subject");
409        environment.add_global(
410            "vars",
411            minijinja::Value::from(minijinja::value::Serde(variables)),
412        );
413
414        environment
415    }
416
417    #[test]
418    fn stock_templates_render_against_english_defaults() {
419        let builder = ExtensionEmailTemplateBuilder::default();
420        let context = fixture_context();
421
422        for template in &builder.templates {
423            let variables: BTreeMap<compact_str::CompactString, String> = builder
424                .variables
425                .iter()
426                .filter(|v| v.template_identifier.as_deref() == Some(template.identifier))
427                .map(|v| (v.name.clone(), v.default_value.clone()))
428                .collect();
429            assert!(
430                variables.contains_key("subject"),
431                "{} has no subject variable",
432                template.identifier
433            );
434
435            let environment = strict_environment(&variables);
436            for (name, value) in &variables {
437                environment
438                    .render_str(value, &context)
439                    .unwrap_or_else(|err| {
440                        panic!("{}.{name} failed to render: {err}", template.identifier)
441                    });
442            }
443            environment
444                .render_str(template.default_subject, &context)
445                .unwrap_or_else(|err| {
446                    panic!("{} subject failed to render: {err}", template.identifier)
447                });
448            environment
449                .render_str(template.default_content, &context)
450                .unwrap_or_else(|err| {
451                    panic!("{} content failed to render: {err}", template.identifier)
452                });
453        }
454    }
455}