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}