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 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 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 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 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 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 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}