Skip to main content

shared/extensions/
email_templates.rs

1use garde::Validate;
2use serde::{Deserialize, Serialize};
3use sqlx::Row;
4use std::{borrow::Cow, sync::Arc};
5use utoipa::ToSchema;
6
7pub struct EmailTemplate {
8    pub identifier: &'static str,
9    pub available_variables: Vec<&'static str>,
10    pub default_subject: &'static str,
11    pub default_content: &'static str,
12    pub default_enabled: bool,
13}
14
15#[derive(ToSchema, Validate, Serialize, Deserialize)]
16pub struct UpdateEmailTemplate {
17    #[garde(length(chars, min = 1))]
18    #[schema(min_length = 1)]
19    #[serde(default, with = "::serde_with::rust::double_option")]
20    pub content: Option<Option<String>>,
21    #[garde(length(chars, min = 1, max = 255))]
22    #[schema(min_length = 1, max_length = 255)]
23    #[serde(default, with = "::serde_with::rust::double_option")]
24    pub subject: Option<Option<String>>,
25    #[garde(skip)]
26    pub enabled: Option<bool>,
27}
28
29pub struct FetchedEmailTemplate {
30    pub identifier: &'static str,
31    pub available_variables: Vec<&'static str>,
32    pub subject: Cow<'static, str>,
33    pub content: Cow<'static, str>,
34    pub enabled: bool,
35}
36
37impl EmailTemplate {
38    pub async fn get(&self, state: &crate::State) -> Result<FetchedEmailTemplate, anyhow::Error> {
39        let db_content: Option<(bool, String, String)> = state
40            .cache
41            .cached(
42                &format!("email_templates::{}", self.identifier),
43                15,
44                || async {
45                    let Some(row) = sqlx::query("SELECT enabled, subject, content FROM email_templates WHERE identifier = $1")
46                        .bind(self.identifier)
47                        .fetch_optional(state.database.read())
48                        .await? else {
49                            return Ok(None);
50                        };
51
52                    Ok::<_, anyhow::Error>(Some((
53                        row.try_get("enabled")?,
54                        row.try_get("subject")?,
55                        row.try_get("content")?,
56                    )))
57                },
58            )
59            .await?;
60
61        Ok(match db_content {
62            Some((enabled, subject, content)) => FetchedEmailTemplate {
63                identifier: self.identifier,
64                available_variables: self.available_variables.clone(),
65                subject: Cow::Owned(subject),
66                content: Cow::Owned(content),
67                enabled,
68            },
69            None => FetchedEmailTemplate {
70                identifier: self.identifier,
71                available_variables: self.available_variables.clone(),
72                subject: Cow::Borrowed(self.default_subject),
73                content: Cow::Borrowed(self.default_content),
74                enabled: self.default_enabled,
75            },
76        })
77    }
78
79    pub async fn update(
80        &self,
81        state: &crate::State,
82        data: UpdateEmailTemplate,
83    ) -> Result<(), anyhow::Error> {
84        let (subject_set, subject_val) = match data.subject {
85            None => (false, None),
86            Some(inner) => (true, inner),
87        };
88        let (content_set, content_val) = match data.content {
89            None => (false, None),
90            Some(inner) => (true, inner),
91        };
92
93        let insert_subject = subject_val
94            .clone()
95            .unwrap_or_else(|| self.default_subject.to_string());
96        let insert_content = content_val
97            .clone()
98            .unwrap_or_else(|| self.default_content.to_string());
99        let insert_enabled = data.enabled.unwrap_or(self.default_enabled);
100
101        sqlx::query(
102            "INSERT INTO email_templates (identifier, subject, content, enabled)
103            VALUES ($1, $2, $3, $4)
104            ON CONFLICT (identifier) DO UPDATE SET
105                subject = CASE
106                    WHEN $5 THEN COALESCE($6, $7)
107                    ELSE email_templates.subject
108                END,
109                content = CASE
110                    WHEN $8 THEN COALESCE($9, $10)
111                    ELSE email_templates.content
112                END,
113                enabled = COALESCE($11, email_templates.enabled)",
114        )
115        .bind(self.identifier)
116        .bind(&insert_subject)
117        .bind(&insert_content)
118        .bind(insert_enabled)
119        .bind(subject_set)
120        .bind(subject_val.as_deref())
121        .bind(self.default_subject)
122        .bind(content_set)
123        .bind(content_val.as_deref())
124        .bind(self.default_content)
125        .bind(data.enabled)
126        .execute(state.database.write())
127        .await?;
128
129        state
130            .cache
131            .invalidate(&format!("email_templates::{}", self.identifier))
132            .await?;
133
134        Ok(())
135    }
136}
137
138pub struct ExtensionEmailTemplateBuilder {
139    pub templates: Vec<EmailTemplate>,
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 }} - Account Created",
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 }} - Password Reset",
157                    default_content: include_str!("../../mails/password_reset.html"),
158                    default_enabled: true,
159                },
160                EmailTemplate {
161                    identifier: "connection_test",
162                    available_variables: vec![],
163                    default_subject: "{{ settings.app.name }} - Connection Test",
164                    default_content: include_str!("../../mails/connection_test.html"),
165                    default_enabled: true,
166                },
167                EmailTemplate {
168                    identifier: "added_to_server",
169                    available_variables: vec!["server", "server_link"],
170                    default_subject: "{{ settings.app.name }} - Added to Server",
171                    default_content: include_str!("../../mails/added_to_server.html"),
172                    default_enabled: true,
173                },
174                EmailTemplate {
175                    identifier: "removed_from_server",
176                    available_variables: vec!["server"],
177                    default_subject: "{{ settings.app.name }} - Removed from Server",
178                    default_content: include_str!("../../mails/removed_from_server.html"),
179                    default_enabled: true,
180                },
181                EmailTemplate {
182                    identifier: "server_installed",
183                    available_variables: vec!["server", "server_link"],
184                    default_subject: "{{ settings.app.name }} - Server Installed",
185                    default_content: include_str!("../../mails/server_installed.html"),
186                    default_enabled: false,
187                },
188                EmailTemplate {
189                    identifier: "server_restored",
190                    available_variables: vec!["server", "server_link"],
191                    default_subject: "{{ settings.app.name }} - Server Restored",
192                    default_content: include_str!("../../mails/server_restored.html"),
193                    default_enabled: false,
194                },
195            ],
196        }
197    }
198}
199
200impl ExtensionEmailTemplateBuilder {
201    /// 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
202    pub fn add_template(mut self, template: EmailTemplate) -> Self {
203        if self
204            .templates
205            .iter()
206            .all(|t| t.identifier != template.identifier)
207        {
208            self.templates.push(template);
209        }
210
211        self
212    }
213
214    /// Mutate an existing template, useful for changing the default content, should not extend the variables, as the caller will not be
215    /// 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
216    pub fn mutate_template(
217        mut self,
218        identifier: &'static str,
219        mutation: impl FnOnce(&mut EmailTemplate),
220    ) -> Self {
221        if let Some(template) = self
222            .templates
223            .iter_mut()
224            .find(|t| t.identifier == identifier)
225        {
226            mutation(template);
227        }
228
229        self
230    }
231
232    pub(super) fn finish(mut self) -> Vec<Arc<EmailTemplate>> {
233        for template in &mut self.templates {
234            if !template.available_variables.contains(&"settings") {
235                template.available_variables.push("settings");
236            }
237        }
238
239        self.templates.into_iter().map(Arc::new).collect()
240    }
241}
242
243pub struct EmailTemplateManager {
244    pub(super) templates: parking_lot::RwLock<Vec<Arc<EmailTemplate>>>,
245}
246
247impl Default for EmailTemplateManager {
248    fn default() -> Self {
249        Self {
250            templates: parking_lot::RwLock::new(vec![]),
251        }
252    }
253}
254
255impl EmailTemplateManager {
256    pub fn get_templates(&self) -> parking_lot::RwLockReadGuard<'_, Vec<Arc<EmailTemplate>>> {
257        self.templates.read()
258    }
259
260    pub fn get_template(&self, identifier: &str) -> Result<Arc<EmailTemplate>, anyhow::Error> {
261        self.templates
262            .read()
263            .iter()
264            .find(|t| t.identifier == identifier)
265            .cloned()
266            .ok_or_else(|| anyhow::anyhow!("template with identifier '{}' not found", identifier))
267    }
268}