Skip to main content

shared/extensions/email_templates/
variables.rs

1use super::EmailTemplateManager;
2use crate::prelude::*;
3use garde::Validate;
4use indexmap::IndexMap;
5use serde::{Deserialize, Serialize};
6use sqlx::Row;
7use std::collections::BTreeMap;
8use utoipa::ToSchema;
9
10const VARIABLES_DIR: include_dir::Dir<'_> =
11    include_dir::include_dir!("$CARGO_MANIFEST_DIR/mails/variables");
12
13pub fn validate_variable_name(
14    name: &compact_str::CompactString,
15    _context: &(),
16) -> Result<(), garde::Error> {
17    let mut chars = name.chars();
18
19    let valid = matches!(chars.next(), Some('a'..='z'))
20        && chars.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_'))
21        && name.len() <= 64;
22
23    if !valid {
24        return Err(garde::Error::new(
25            "variable name must start with a lowercase letter and only contain lowercase letters, digits and underscores (max 64 characters)",
26        ));
27    }
28
29    Ok(())
30}
31
32pub fn validate_variable_translations(
33    translations: &BTreeMap<compact_str::CompactString, String>,
34    _context: &(),
35) -> Result<(), garde::Error> {
36    for (language, value) in translations {
37        crate::utils::validate_language(language, &())?;
38
39        if value.is_empty() || value.len() > 8192 {
40            return Err(garde::Error::new(format!(
41                "translation for language '{language}' must be between 1 and 8192 characters"
42            )));
43        }
44    }
45
46    Ok(())
47}
48
49pub struct EmailVariable {
50    pub template_identifier: Option<compact_str::CompactString>,
51    pub name: compact_str::CompactString,
52    pub default_value: String,
53    pub default_translations: BTreeMap<compact_str::CompactString, String>,
54}
55
56impl EmailVariable {
57    pub(super) fn matches(&self, template_identifier: Option<&str>, name: &str) -> bool {
58        self.template_identifier.as_deref() == template_identifier && self.name == name
59    }
60}
61
62#[derive(ToSchema, Validate, Deserialize)]
63pub struct CreateEmailVariable {
64    #[garde(custom(validate_variable_name))]
65    #[schema(pattern = "^[a-z][a-z0-9_]{0,63}$", max_length = 64)]
66    pub name: compact_str::CompactString,
67    #[garde(length(chars, min = 1, max = 8192))]
68    #[schema(min_length = 1, max_length = 8192)]
69    pub value: String,
70    #[garde(custom(validate_variable_translations))]
71    #[serde(default)]
72    pub value_translations: BTreeMap<compact_str::CompactString, String>,
73}
74
75#[derive(ToSchema, Validate, Deserialize)]
76pub struct UpdateEmailVariable {
77    #[garde(length(chars, min = 1, max = 8192))]
78    #[schema(min_length = 1, max_length = 8192)]
79    #[serde(default, with = "::serde_with::rust::double_option")]
80    pub value: Option<Option<String>>,
81    #[garde(inner(custom(validate_variable_translations)))]
82    pub value_translations: Option<BTreeMap<compact_str::CompactString, String>>,
83}
84
85#[derive(Serialize, Deserialize)]
86struct EmailVariableRow {
87    name: compact_str::CompactString,
88    value: Option<String>,
89    value_translations: BTreeMap<compact_str::CompactString, String>,
90}
91
92pub struct FetchedEmailVariable {
93    pub template_identifier: Option<compact_str::CompactString>,
94    pub name: compact_str::CompactString,
95    pub default_value: Option<String>,
96    pub default_translations: BTreeMap<compact_str::CompactString, String>,
97    pub value: Option<String>,
98    pub value_translations: BTreeMap<compact_str::CompactString, String>,
99}
100
101#[schema_extension_derive::extendible]
102#[init_args(FetchedEmailVariable, crate::State)]
103#[hook_args(crate::State)]
104#[derive(ToSchema, Serialize)]
105#[schema(title = "EmailVariable")]
106pub struct AdminApiEmailVariable {
107    pub template_identifier: Option<compact_str::CompactString>,
108    pub name: compact_str::CompactString,
109    pub system: bool,
110    pub default_value: Option<String>,
111    pub default_translations: BTreeMap<compact_str::CompactString, String>,
112    pub value: Option<String>,
113    pub value_translations: BTreeMap<compact_str::CompactString, String>,
114}
115
116impl FetchedEmailVariable {
117    pub fn is_system(&self) -> bool {
118        self.default_value.is_some()
119    }
120
121    pub fn resolve(&self, language: &str) -> &str {
122        self.value_translations
123            .get(language)
124            .map(String::as_str)
125            .or(self.value.as_deref())
126            .or_else(|| self.default_translations.get(language).map(String::as_str))
127            .or(self.default_value.as_deref())
128            .unwrap_or_default()
129    }
130}
131
132#[async_trait::async_trait]
133impl IntoAdminApiObject for FetchedEmailVariable {
134    type AdminApiObject = AdminApiEmailVariable;
135    type ExtraArgs<'a> = ();
136
137    async fn into_admin_api_object<'a>(
138        self,
139        state: &crate::State,
140        _args: Self::ExtraArgs<'a>,
141    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
142        let api_object = AdminApiEmailVariable::init_hooks(&self, state).await?;
143        let system = self.is_system();
144
145        let api_object = finish_extendible!(
146            AdminApiEmailVariable {
147                template_identifier: self.template_identifier,
148                name: self.name,
149                system,
150                default_value: self.default_value,
151                default_translations: self.default_translations,
152                value: self.value,
153                value_translations: self.value_translations,
154            },
155            api_object,
156            state
157        )?;
158
159        Ok(api_object)
160    }
161}
162
163type VariablesFile =
164    IndexMap<compact_str::CompactString, IndexMap<compact_str::CompactString, String>>;
165
166pub(super) fn load_variables(directory: &include_dir::Dir<'_>, variables: &mut Vec<EmailVariable>) {
167    let mut files: Vec<_> = directory.files().collect();
168    files.sort_by_key(|file| file.path().file_stem() != Some(std::ffi::OsStr::new("en")));
169
170    for file in files {
171        let Some(language) = file.path().file_stem().and_then(|s| s.to_str()) else {
172            continue;
173        };
174
175        let entries: VariablesFile = match serde_json::from_slice(file.contents()) {
176            Ok(entries) => entries,
177            Err(err) => {
178                tracing::error!(
179                    "failed to parse email variables file '{}': {:#?}",
180                    file.path().display(),
181                    err
182                );
183                continue;
184            }
185        };
186
187        for (template_identifier, entries) in entries {
188            for (name, value) in entries {
189                if let Some(variable) = variables
190                    .iter_mut()
191                    .find(|v| v.matches(Some(&template_identifier), &name))
192                {
193                    if language != "en" {
194                        variable.default_translations.insert(language.into(), value);
195                    }
196                } else if language == "en" {
197                    if let Err(err) = validate_variable_name(&name, &()) {
198                        tracing::error!(
199                            "ignoring email variable '{}.{}': {}",
200                            template_identifier,
201                            name,
202                            err
203                        );
204                        continue;
205                    }
206
207                    variables.push(EmailVariable {
208                        template_identifier: Some(template_identifier.clone()),
209                        name,
210                        default_value: value,
211                        default_translations: BTreeMap::new(),
212                    });
213                } else {
214                    tracing::warn!(
215                        "email variable translation '{}.{}' in '{}' has no English definition",
216                        template_identifier,
217                        name,
218                        file.path().display()
219                    );
220                }
221            }
222        }
223    }
224}
225
226pub(super) fn core_variables() -> Vec<EmailVariable> {
227    let mut variables = Vec::new();
228    load_variables(&VARIABLES_DIR, &mut variables);
229    variables
230}
231
232impl EmailTemplateManager {
233    fn variables_cache_key(template_identifier: Option<&str>) -> String {
234        format!(
235            "email_variables::{}",
236            template_identifier.unwrap_or_default()
237        )
238    }
239
240    pub async fn get_variables(
241        &self,
242        state: &crate::State,
243        template_identifier: Option<&str>,
244    ) -> Result<Vec<FetchedEmailVariable>, anyhow::Error> {
245        let rows: Vec<EmailVariableRow> = state
246            .cache
247            .cached(
248                &Self::variables_cache_key(template_identifier),
249                15,
250                || async {
251                    let rows = sqlx::query(
252                        "SELECT name, value, value_translations FROM email_variables
253                        WHERE template_identifier = $1
254                        ORDER BY name",
255                    )
256                    .bind(template_identifier.unwrap_or_default())
257                    .fetch_all(state.database.read())
258                    .await?;
259
260                    rows.into_iter()
261                        .map(|row| {
262                            Ok(EmailVariableRow {
263                                name: row.try_get("name")?,
264                                value: row.try_get("value")?,
265                                value_translations: serde_json::from_value(
266                                    row.try_get("value_translations")?,
267                                )?,
268                            })
269                        })
270                        .collect::<Result<Vec<_>, anyhow::Error>>()
271                },
272            )
273            .await?;
274
275        let mut rows: BTreeMap<compact_str::CompactString, EmailVariableRow> = rows
276            .into_iter()
277            .map(|row| (row.name.clone(), row))
278            .collect();
279
280        let mut variables: Vec<FetchedEmailVariable> = self
281            .variables
282            .read()
283            .iter()
284            .filter(|v| v.template_identifier.as_deref() == template_identifier)
285            .map(|definition| {
286                let (value, value_translations) = rows
287                    .remove(&definition.name)
288                    .map(|row| (row.value, row.value_translations))
289                    .unwrap_or_default();
290
291                FetchedEmailVariable {
292                    template_identifier: definition.template_identifier.clone(),
293                    name: definition.name.clone(),
294                    default_value: Some(definition.default_value.clone()),
295                    default_translations: definition.default_translations.clone(),
296                    value,
297                    value_translations,
298                }
299            })
300            .collect();
301
302        for (name, row) in rows {
303            variables.push(FetchedEmailVariable {
304                template_identifier: template_identifier.map(Into::into),
305                name,
306                default_value: None,
307                default_translations: BTreeMap::new(),
308                value: row.value,
309                value_translations: row.value_translations,
310            });
311        }
312
313        Ok(variables)
314    }
315
316    pub async fn resolve_variables(
317        &self,
318        state: &crate::State,
319        template_identifier: &str,
320        language: &str,
321    ) -> Result<BTreeMap<compact_str::CompactString, String>, anyhow::Error> {
322        let mut resolved = BTreeMap::new();
323
324        for variable in self
325            .get_variables(state, None)
326            .await?
327            .into_iter()
328            .chain(self.get_variables(state, Some(template_identifier)).await?)
329        {
330            let value = variable.resolve(language).to_string();
331            resolved.insert(variable.name, value);
332        }
333
334        Ok(resolved)
335    }
336
337    pub async fn create_variable(
338        &self,
339        state: &crate::State,
340        template_identifier: Option<&str>,
341        data: CreateEmailVariable,
342    ) -> Result<(), crate::database::DatabaseError> {
343        sqlx::query(
344            "INSERT INTO email_variables (template_identifier, name, value, value_translations)
345            VALUES ($1, $2, $3, $4)",
346        )
347        .bind(template_identifier.unwrap_or_default())
348        .bind(&data.name)
349        .bind(&data.value)
350        .bind(serde_json::to_value(&data.value_translations)?)
351        .execute(state.database.write())
352        .await?;
353
354        state
355            .cache
356            .invalidate(&Self::variables_cache_key(template_identifier))
357            .await?;
358
359        Ok(())
360    }
361
362    pub async fn update_variable(
363        &self,
364        state: &crate::State,
365        variable: &FetchedEmailVariable,
366        data: UpdateEmailVariable,
367    ) -> Result<(), anyhow::Error> {
368        let (value_set, value) = match data.value {
369            None => (false, None),
370            Some(inner) => (true, inner),
371        };
372        let value_translations = data
373            .value_translations
374            .as_ref()
375            .map(serde_json::to_value)
376            .transpose()?;
377
378        sqlx::query(
379            "INSERT INTO email_variables (template_identifier, name, value, value_translations)
380            VALUES ($1, $2, $3, COALESCE($5, '{}'::jsonb))
381            ON CONFLICT (template_identifier, name) DO UPDATE SET
382                value = CASE
383                    WHEN $4 THEN $3
384                    ELSE email_variables.value
385                END,
386                value_translations = COALESCE($5, email_variables.value_translations)",
387        )
388        .bind(variable.template_identifier.as_deref().unwrap_or_default())
389        .bind(&variable.name)
390        .bind(value.as_deref())
391        .bind(value_set)
392        .bind(value_translations)
393        .execute(state.database.write())
394        .await?;
395
396        if variable.is_system() {
397            sqlx::query(
398                "DELETE FROM email_variables
399                WHERE template_identifier = $1 AND name = $2
400                AND value IS NULL AND value_translations = '{}'::jsonb",
401            )
402            .bind(variable.template_identifier.as_deref().unwrap_or_default())
403            .bind(&variable.name)
404            .execute(state.database.write())
405            .await?;
406        }
407
408        state
409            .cache
410            .invalidate(&Self::variables_cache_key(
411                variable.template_identifier.as_deref(),
412            ))
413            .await?;
414
415        Ok(())
416    }
417
418    pub async fn delete_variable(
419        &self,
420        state: &crate::State,
421        variable: &FetchedEmailVariable,
422    ) -> Result<(), anyhow::Error> {
423        sqlx::query(
424            "DELETE FROM email_variables
425            WHERE template_identifier = $1 AND name = $2",
426        )
427        .bind(variable.template_identifier.as_deref().unwrap_or_default())
428        .bind(&variable.name)
429        .execute(state.database.write())
430        .await?;
431
432        state
433            .cache
434            .invalidate(&Self::variables_cache_key(
435                variable.template_identifier.as_deref(),
436            ))
437            .await?;
438
439        Ok(())
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::{super::ExtensionEmailTemplateBuilder, *};
446
447    fn placeholders(value: &str) -> Vec<&str> {
448        let mut found = Vec::new();
449        let mut rest = value;
450
451        while let Some(start) = rest.find("{{") {
452            let Some(len) = rest[start..].find("}}") else {
453                break;
454            };
455            found.push(rest[start..start + len + 2].trim());
456            rest = &rest[start + len + 2..];
457        }
458
459        found.sort_unstable();
460        found
461    }
462
463    fn variables_file(name: &str) -> VariablesFile {
464        serde_json::from_slice(
465            VARIABLES_DIR
466                .get_file(name)
467                .unwrap_or_else(|| panic!("{name} missing"))
468                .contents(),
469        )
470        .unwrap_or_else(|err| panic!("{name} is invalid: {err}"))
471    }
472
473    #[test]
474    fn translated_defaults_match_english_keys_and_placeholders() {
475        let base = variables_file("en.json");
476
477        for file in VARIABLES_DIR.files() {
478            let name = file.path().file_name().unwrap().to_str().unwrap();
479            if name == "en.json" {
480                continue;
481            }
482
483            let translations = variables_file(name);
484            for (template_identifier, entries) in &translations {
485                for (variable, value) in entries {
486                    let base_value = base
487                        .get(template_identifier)
488                        .and_then(|e| e.get(variable))
489                        .unwrap_or_else(|| {
490                            panic!("{name}: unknown variable '{template_identifier}.{variable}'")
491                        });
492                    assert_eq!(
493                        placeholders(value),
494                        placeholders(base_value),
495                        "{name}: placeholder mismatch in '{template_identifier}.{variable}'"
496                    );
497                    assert!(
498                        !value.is_empty(),
499                        "{name}: '{template_identifier}.{variable}' is empty"
500                    );
501                }
502            }
503        }
504    }
505
506    #[test]
507    fn directory_loading_matches_core_and_skips_unknown_translations() {
508        let builder = ExtensionEmailTemplateBuilder {
509            templates: vec![],
510            variables: vec![],
511        }
512        .add_template_variables(&VARIABLES_DIR);
513        let core = ExtensionEmailTemplateBuilder::default();
514
515        assert_eq!(builder.variables.len(), core.variables.len());
516        assert!(
517            builder
518                .variables
519                .iter()
520                .all(|v| v.default_translations.contains_key("de"))
521        );
522
523        let again = builder.add_template_variables(&VARIABLES_DIR);
524        assert_eq!(again.variables.len(), core.variables.len());
525    }
526
527    #[test]
528    fn extension_directory_adds_languages_to_its_own_and_core_variables() {
529        const EN: &str = r#"{ "dev.example.welcome": { "subject": "Welcome", "Bad Name": "x" } }"#;
530        const DE: &str = r#"{ "dev.example.welcome": { "subject": "Willkommen" }, "password_reset": { "subject": "Passwort neu" } }"#;
531        const FR: &str = r#"{ "dev.example.welcome": { "subject": "Bienvenue", "unknown": "?" } }"#;
532        const EXTENSION_DIR: include_dir::Dir<'_> = include_dir::Dir::new(
533            "mails/variables",
534            &[
535                include_dir::DirEntry::File(include_dir::File::new(
536                    "mails/variables/fr.json",
537                    FR.as_bytes(),
538                )),
539                include_dir::DirEntry::File(include_dir::File::new(
540                    "mails/variables/en.json",
541                    EN.as_bytes(),
542                )),
543                include_dir::DirEntry::File(include_dir::File::new(
544                    "mails/variables/de.json",
545                    DE.as_bytes(),
546                )),
547            ],
548        );
549
550        let builder =
551            ExtensionEmailTemplateBuilder::default().add_template_variables(&EXTENSION_DIR);
552
553        let welcome = builder
554            .variables
555            .iter()
556            .find(|v| v.matches(Some("dev.example.welcome"), "subject"))
557            .expect("extension variable registered");
558        assert_eq!(welcome.default_value, "Welcome");
559        assert_eq!(
560            welcome.default_translations.get("de").map(String::as_str),
561            Some("Willkommen")
562        );
563        assert_eq!(
564            welcome.default_translations.get("fr").map(String::as_str),
565            Some("Bienvenue")
566        );
567
568        assert!(builder.variables.iter().all(|v| v.name != "Bad Name"));
569        assert!(builder.variables.iter().all(|v| v.name != "unknown"));
570
571        let core_subject = builder
572            .variables
573            .iter()
574            .find(|v| v.matches(Some("password_reset"), "subject"))
575            .unwrap();
576        assert_eq!(
577            core_subject
578                .default_translations
579                .get("de")
580                .map(String::as_str),
581            Some("Passwort neu")
582        );
583    }
584
585    #[test]
586    fn variable_names_are_validated() {
587        for valid in ["subject", "ip_label", "a1", "footer_2"] {
588            assert!(
589                validate_variable_name(&valid.into(), &()).is_ok(),
590                "{valid}"
591            );
592        }
593        for invalid in [
594            "",
595            "Subject",
596            "1abc",
597            "with-dash",
598            "with space",
599            "_leading",
600            "über",
601        ] {
602            assert!(
603                validate_variable_name(&invalid.into(), &()).is_err(),
604                "{invalid}"
605            );
606        }
607    }
608
609    #[test]
610    fn resolution_prefers_operator_values_over_shipped_translations() {
611        let variable = FetchedEmailVariable {
612            template_identifier: None,
613            name: "greeting".into(),
614            default_value: Some("Hello".into()),
615            default_translations: BTreeMap::from([("de".into(), "Hallo".into())]),
616            value: None,
617            value_translations: BTreeMap::new(),
618        };
619        assert_eq!(variable.resolve("de"), "Hallo");
620        assert_eq!(variable.resolve("fr"), "Hello");
621
622        let customised = FetchedEmailVariable {
623            value: Some("Hi there".into()),
624            ..variable
625        };
626        assert_eq!(customised.resolve("de"), "Hi there");
627
628        let translated = FetchedEmailVariable {
629            value_translations: BTreeMap::from([("de".into(), "Servus".into())]),
630            ..customised
631        };
632        assert_eq!(translated.resolve("de"), "Servus");
633        assert_eq!(translated.resolve("en"), "Hi there");
634    }
635}