Skip to main content

laterite_core/mail/
template.rs

1//! Mail templates, layouts and partials: files a module registers, rows an
2//! admin may edit, and the renderer that turns a template and its variables
3//! into a subject, a text body and an HTML body.
4//!
5//! A file has a header of `key = "value"` lines, then sections split by a
6//! line of `==`: the text body, the HTML body and, for a layout, the CSS.
7//! Bodies are templates (`{{ name }}`, `{% if %}`, `{{ text|markdown }}`);
8//! a template's HTML body is Markdown. Partials are called as
9//! `{{ partial("button", url=link, body="Sign in") }}`; a longer body is
10//! captured first with `{% set body %}...{% endset %}`.
11
12use std::collections::{BTreeMap, HashMap};
13use std::sync::{Arc, RwLock};
14use std::time::Duration;
15
16use minijinja::value::{Kwargs, Value};
17use minijinja::{Environment, State, UndefinedBehavior};
18use sea_query::{Expr, Iden, OnConflict, Order, Query};
19use serde::{Deserialize, Serialize};
20
21use crate::cache::Cache;
22use crate::error::{CoreError, CoreResult};
23use crate::query::{bind_values, build, AnyRowExt};
24use crate::Db;
25
26/// A template file under a code; see [`super::MailRegistry`].
27#[derive(Debug, Clone, Copy)]
28pub struct MailTemplateReg {
29    pub code: &'static str,
30    pub file: &'static str,
31}
32
33/// A layout file under a code.
34#[derive(Debug, Clone, Copy)]
35pub struct MailLayoutReg {
36    pub code: &'static str,
37    pub file: &'static str,
38}
39
40/// A partial file under a code.
41#[derive(Debug, Clone, Copy)]
42pub struct MailPartialReg {
43    pub code: &'static str,
44    pub file: &'static str,
45}
46
47/// The cache namespace every rendered template depends on; an edit bumps
48/// it.
49pub const NAMESPACE: &str = "laterite.mail::templates";
50
51/// A parsed file: its header and its sections.
52#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Sections {
54    pub settings: BTreeMap<String, String>,
55    pub text: String,
56    pub html: String,
57    pub css: String,
58}
59
60/// Parses a sectioned file. With one section it is the HTML body alone;
61/// with two, the header and the HTML body; with three, header, text and
62/// HTML; with four, header, text, HTML and CSS.
63pub fn parse_file(file: &str) -> Sections {
64    let parts: Vec<&str> = file.split("\n==\n").collect();
65    let parts: Vec<String> = parts
66        .into_iter()
67        .map(|p| p.trim_matches('\n').to_string())
68        .collect();
69    let (header, text, html, css) = match parts.len() {
70        0 => (String::new(), String::new(), String::new(), String::new()),
71        1 => (
72            String::new(),
73            String::new(),
74            parts[0].clone(),
75            String::new(),
76        ),
77        2 => (
78            parts[0].clone(),
79            String::new(),
80            parts[1].clone(),
81            String::new(),
82        ),
83        3 => (
84            parts[0].clone(),
85            parts[1].clone(),
86            parts[2].clone(),
87            String::new(),
88        ),
89        _ => (
90            parts[0].clone(),
91            parts[1].clone(),
92            parts[2].clone(),
93            parts[3].clone(),
94        ),
95    };
96    let mut settings = BTreeMap::new();
97    for line in header.lines() {
98        let line = line.trim();
99        if line.is_empty() || line.starts_with('#') {
100            continue;
101        }
102        if let Some((key, value)) = line.split_once('=') {
103            let value = value
104                .trim()
105                .trim_matches('"')
106                .trim_matches('\'')
107                .to_string();
108            settings.insert(key.trim().to_string(), value);
109        }
110    }
111    Sections {
112        settings,
113        text,
114        html,
115        css,
116    }
117}
118
119/// A template row: what a message is rendered from.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Template {
122    pub code: String,
123    pub subject: String,
124    pub description: String,
125    /// The layout's code; `None` renders the body alone.
126    pub layout: Option<String>,
127    pub content_text: String,
128    pub content_html: String,
129    /// Edited in the admin: the file no longer refreshes it.
130    pub is_custom: bool,
131}
132
133/// A layout row: what wraps a template's body.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Layout {
136    pub code: String,
137    pub name: String,
138    pub content_text: String,
139    pub content_html: String,
140    pub content_css: String,
141    pub is_custom: bool,
142}
143
144/// A partial row: a piece a body or a layout calls.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct Partial {
147    pub code: String,
148    pub name: String,
149    pub content_text: String,
150    pub content_html: String,
151    pub is_custom: bool,
152}
153
154/// What rendering produced.
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Rendered {
157    pub subject: String,
158    pub text: String,
159    pub html: Option<String>,
160}
161
162#[derive(Iden)]
163pub(crate) enum MailTemplates {
164    Table,
165    Code,
166    Subject,
167    Description,
168    Layout,
169    ContentText,
170    ContentHtml,
171    IsCustom,
172}
173
174#[derive(Iden)]
175pub(crate) enum MailLayouts {
176    Table,
177    Code,
178    Name,
179    ContentText,
180    ContentHtml,
181    ContentCss,
182    IsCustom,
183}
184
185#[derive(Iden)]
186pub(crate) enum MailPartials {
187    Table,
188    Code,
189    Name,
190    ContentText,
191    ContentHtml,
192    IsCustom,
193}
194
195/// The files as registered, kept so a row resets to its file.
196#[derive(Default)]
197struct Files {
198    templates: HashMap<&'static str, &'static str>,
199    layouts: HashMap<&'static str, &'static str>,
200    partials: HashMap<&'static str, &'static str>,
201}
202
203struct Inner {
204    db: Db,
205    cache: Cache,
206    files: RwLock<Files>,
207    brand_css: RwLock<String>,
208    app_name: RwLock<String>,
209}
210
211/// The templates handle: reads, edits, seeding and rendering.
212#[derive(Clone)]
213pub struct Templates {
214    inner: Arc<Inner>,
215}
216
217const TTL: Duration = Duration::from_secs(3600);
218
219impl Templates {
220    pub fn new(db: Db, cache: Cache) -> Self {
221        cache.allow(NAMESPACE);
222        Self {
223            inner: Arc::new(Inner {
224                db,
225                cache,
226                files: RwLock::new(Files::default()),
227                brand_css: RwLock::new(String::new()),
228                app_name: RwLock::new("Laterite".to_string()),
229            }),
230        }
231    }
232
233    /// The application name layouts show.
234    pub fn set_app_name(&self, name: &str) {
235        *self
236            .inner
237            .app_name
238            .write()
239            .unwrap_or_else(|e| e.into_inner()) = name.to_string();
240    }
241
242    /// The brand stylesheet every layout includes before its own CSS.
243    pub async fn set_brand_css(&self, css: &str) {
244        *self
245            .inner
246            .brand_css
247            .write()
248            .unwrap_or_else(|e| e.into_inner()) = css.to_string();
249        let _ = self.inner.cache.bump(NAMESPACE).await;
250    }
251
252    pub fn brand_css(&self) -> String {
253        self.inner
254            .brand_css
255            .read()
256            .unwrap_or_else(|e| e.into_inner())
257            .clone()
258    }
259
260    /// Seeds the tables from the registered files: a new code is inserted,
261    /// a row the admin has not edited is refreshed, a code no file names
262    /// is removed unless edited.
263    pub async fn seed(
264        &self,
265        templates: &[MailTemplateReg],
266        layouts: &[MailLayoutReg],
267        partials: &[MailPartialReg],
268    ) -> CoreResult<()> {
269        {
270            let mut files = self.inner.files.write().unwrap_or_else(|e| e.into_inner());
271            files.templates = templates.iter().map(|r| (r.code, r.file)).collect();
272            files.layouts = layouts.iter().map(|r| (r.code, r.file)).collect();
273            files.partials = partials.iter().map(|r| (r.code, r.file)).collect();
274        }
275        let db = &self.inner.db;
276        for reg in templates {
277            let row = template_from_file(reg.code, reg.file);
278            if find_template(db, reg.code)
279                .await?
280                .is_none_or(|t| !t.is_custom)
281            {
282                upsert_template(db, &row).await?;
283            }
284        }
285        for reg in layouts {
286            let row = layout_from_file(reg.code, reg.file);
287            if find_layout(db, reg.code)
288                .await?
289                .is_none_or(|l| !l.is_custom)
290            {
291                upsert_layout(db, &row).await?;
292            }
293        }
294        for reg in partials {
295            let row = partial_from_file(reg.code, reg.file);
296            if find_partial(db, reg.code)
297                .await?
298                .is_none_or(|p| !p.is_custom)
299            {
300                upsert_partial(db, &row).await?;
301            }
302        }
303        for t in list_templates(db).await? {
304            if !t.is_custom && !templates.iter().any(|r| r.code == t.code) {
305                delete_template(db, &t.code).await?;
306            }
307        }
308        for l in list_layouts(db).await? {
309            if !l.is_custom && !layouts.iter().any(|r| r.code == l.code) {
310                delete_layout(db, &l.code).await?;
311            }
312        }
313        for p in list_partials(db).await? {
314            if !p.is_custom && !partials.iter().any(|r| r.code == p.code) {
315                delete_partial(db, &p.code).await?;
316            }
317        }
318        let _ = self.inner.cache.bump(NAMESPACE).await;
319        Ok(())
320    }
321
322    pub async fn templates(&self) -> CoreResult<Vec<Template>> {
323        list_templates(&self.inner.db).await
324    }
325
326    pub async fn layouts(&self) -> CoreResult<Vec<Layout>> {
327        list_layouts(&self.inner.db).await
328    }
329
330    pub async fn partials(&self) -> CoreResult<Vec<Partial>> {
331        list_partials(&self.inner.db).await
332    }
333
334    pub async fn template(&self, code: &str) -> CoreResult<Option<Template>> {
335        find_template(&self.inner.db, code).await
336    }
337
338    pub async fn layout(&self, code: &str) -> CoreResult<Option<Layout>> {
339        find_layout(&self.inner.db, code).await
340    }
341
342    pub async fn partial(&self, code: &str) -> CoreResult<Option<Partial>> {
343        find_partial(&self.inner.db, code).await
344    }
345
346    /// Writes a template as edited; it is custom from now and its file no
347    /// longer refreshes it.
348    pub async fn save_template(&self, template: &Template) -> CoreResult<()> {
349        let mut row = template.clone();
350        row.is_custom = true;
351        upsert_template(&self.inner.db, &row).await?;
352        self.bump().await;
353        Ok(())
354    }
355
356    pub async fn save_layout(&self, layout: &Layout) -> CoreResult<()> {
357        let mut row = layout.clone();
358        row.is_custom = true;
359        upsert_layout(&self.inner.db, &row).await?;
360        self.bump().await;
361        Ok(())
362    }
363
364    pub async fn save_partial(&self, partial: &Partial) -> CoreResult<()> {
365        let mut row = partial.clone();
366        row.is_custom = true;
367        upsert_partial(&self.inner.db, &row).await?;
368        self.bump().await;
369        Ok(())
370    }
371
372    /// Puts a template back to its file; `false` when no file registers
373    /// the code (a custom one the admin made), in which case nothing moves.
374    pub async fn reset_template(&self, code: &str) -> CoreResult<bool> {
375        let file = self.file_of(|f| f.templates.get(code).copied());
376        let Some(file) = file else {
377            return Ok(false);
378        };
379        upsert_template(&self.inner.db, &template_from_file(code, file)).await?;
380        self.bump().await;
381        Ok(true)
382    }
383
384    pub async fn reset_layout(&self, code: &str) -> CoreResult<bool> {
385        let file = self.file_of(|f| f.layouts.get(code).copied());
386        let Some(file) = file else {
387            return Ok(false);
388        };
389        upsert_layout(&self.inner.db, &layout_from_file(code, file)).await?;
390        self.bump().await;
391        Ok(true)
392    }
393
394    pub async fn reset_partial(&self, code: &str) -> CoreResult<bool> {
395        let file = self.file_of(|f| f.partials.get(code).copied());
396        let Some(file) = file else {
397            return Ok(false);
398        };
399        upsert_partial(&self.inner.db, &partial_from_file(code, file)).await?;
400        self.bump().await;
401        Ok(true)
402    }
403
404    /// Removes a template no file registers; one a file registers comes
405    /// back at the next boot, so it is reset instead.
406    pub async fn delete_template(&self, code: &str) -> CoreResult<bool> {
407        if self.file_of(|f| f.templates.get(code).copied()).is_some() {
408            return self.reset_template(code).await;
409        }
410        delete_template(&self.inner.db, code).await?;
411        self.bump().await;
412        Ok(true)
413    }
414
415    pub async fn delete_layout(&self, code: &str) -> CoreResult<bool> {
416        if self.file_of(|f| f.layouts.get(code).copied()).is_some() {
417            return self.reset_layout(code).await;
418        }
419        delete_layout(&self.inner.db, code).await?;
420        self.bump().await;
421        Ok(true)
422    }
423
424    pub async fn delete_partial(&self, code: &str) -> CoreResult<bool> {
425        if self.file_of(|f| f.partials.get(code).copied()).is_some() {
426            return self.reset_partial(code).await;
427        }
428        delete_partial(&self.inner.db, code).await?;
429        self.bump().await;
430        Ok(true)
431    }
432
433    /// Whether a file registers `code` as a template.
434    pub fn has_template_file(&self, code: &str) -> bool {
435        self.file_of(|f| f.templates.get(code).copied()).is_some()
436    }
437
438    pub fn has_layout_file(&self, code: &str) -> bool {
439        self.file_of(|f| f.layouts.get(code).copied()).is_some()
440    }
441
442    pub fn has_partial_file(&self, code: &str) -> bool {
443        self.file_of(|f| f.partials.get(code).copied()).is_some()
444    }
445
446    fn file_of(&self, pick: impl FnOnce(&Files) -> Option<&'static str>) -> Option<&'static str> {
447        let files = self.inner.files.read().unwrap_or_else(|e| e.into_inner());
448        pick(&files)
449    }
450
451    async fn bump(&self) {
452        let _ = self.inner.cache.bump(NAMESPACE).await;
453    }
454
455    /// Renders the template `code` with `vars`: the subject, the text body
456    /// and the HTML body wrapped in the layout with the brand stylesheet
457    /// inlined. An unregistered code is `NotFound`; an undefined variable
458    /// renders empty.
459    pub async fn render(&self, code: &str, vars: &serde_json::Value) -> CoreResult<Rendered> {
460        let bundle = self.bundle(code).await?;
461        self.render_bundle(&bundle, vars)
462    }
463
464    /// Renders `template` as given (an unsaved edit) with `vars`.
465    pub async fn render_draft(
466        &self,
467        template: &Template,
468        vars: &serde_json::Value,
469    ) -> CoreResult<Rendered> {
470        let mut bundle = self.pieces().await?;
471        bundle.template = template.clone();
472        self.render_bundle(&bundle, vars)
473    }
474
475    async fn pieces(&self) -> CoreResult<Bundle> {
476        let key = "laterite.mail::pieces";
477        let pieces: Pieces = self
478            .inner
479            .cache
480            .remember(key, TTL, &[NAMESPACE], || async {
481                Ok(Pieces {
482                    layouts: list_layouts(&self.inner.db).await?,
483                    partials: list_partials(&self.inner.db).await?,
484                })
485            })
486            .await?;
487        Ok(Bundle {
488            template: Template {
489                code: String::new(),
490                subject: String::new(),
491                description: String::new(),
492                layout: None,
493                content_text: String::new(),
494                content_html: String::new(),
495                is_custom: false,
496            },
497            layouts: pieces.layouts,
498            partials: pieces.partials,
499        })
500    }
501
502    async fn bundle(&self, code: &str) -> CoreResult<Bundle> {
503        let key = format!("laterite.mail::template:{code}");
504        let template: Option<Template> = self
505            .inner
506            .cache
507            .remember(&key, TTL, &[NAMESPACE], || {
508                find_template(&self.inner.db, code)
509            })
510            .await?;
511        let template =
512            template.ok_or_else(|| CoreError::NotFound(format!("mail template `{code}`")))?;
513        let mut bundle = self.pieces().await?;
514        bundle.template = template;
515        Ok(bundle)
516    }
517
518    fn render_bundle(&self, bundle: &Bundle, vars: &serde_json::Value) -> CoreResult<Rendered> {
519        let app_name = self
520            .inner
521            .app_name
522            .read()
523            .unwrap_or_else(|e| e.into_inner())
524            .clone();
525        let brand_css = self.brand_css();
526        let layout = bundle
527            .template
528            .layout
529            .as_deref()
530            .and_then(|code| bundle.layouts.iter().find(|l| l.code == code));
531        let mut base = minijinja::context! { app_name => app_name.clone() };
532        if let serde_json::Value::Object(_) = vars {
533            base = minijinja::context! { ..Value::from(minijinja::value::Serde(vars)), ..base };
534        }
535
536        // HTML.
537        let mut html_env = environment(&bundle.partials, false);
538        html_env.add_template_owned("subject".to_string(), bundle.template.subject.clone())?;
539        html_env.add_template_owned("body".to_string(), bundle.template.content_html.clone())?;
540        if let Some(layout) = layout {
541            html_env.add_template_owned("layout".to_string(), layout.content_html.clone())?;
542            html_env.add_template_owned("css".to_string(), layout.content_css.clone())?;
543        }
544        let subject = html_env.get_template("subject")?.render(&base)?;
545        let body_source = html_env.get_template("body")?.render(&base)?;
546        let body_html = crate::markdown::render(&body_source);
547        let html = match layout {
548            Some(_) => {
549                let brand = brand_values(&brand_css);
550                let css = html_env
551                    .get_template("css")?
552                    .render(minijinja::context! { brand => brand, ..base.clone() })?;
553                let page = html_env
554                    .get_template("layout")?
555                    .render(minijinja::context! {
556                        content => Value::from_safe_string(body_html),
557                        css => Value::from_safe_string(css.clone()),
558                        brand_css => Value::from_safe_string(brand_css.clone()),
559                        subject => subject.clone(),
560                        ..base.clone()
561                    })?;
562                let inliner = css_inline::CSSInliner::options()
563                    .load_remote_stylesheets(false)
564                    .extra_css(Some(std::borrow::Cow::Owned(format!("{brand_css}\n{css}"))))
565                    .build();
566                inliner.inline(&page).map_err(|e| {
567                    CoreError::Internal(format!("mail css could not be inlined: {e}"))
568                })?
569            }
570            None => body_html,
571        };
572
573        // Text.
574        let mut text_env = environment(&bundle.partials, true);
575        text_env.add_template_owned("body".to_string(), bundle.template.content_text.clone())?;
576        if let Some(layout) = layout {
577            text_env.add_template_owned("layout".to_string(), layout.content_text.clone())?;
578        }
579        let body_text = if bundle.template.content_text.trim().is_empty() {
580            text_of(&body_source)
581        } else {
582            text_env.get_template("body")?.render(&base)?
583        };
584        let text = match layout.filter(|l| !l.content_text.trim().is_empty()) {
585            Some(_) => text_env
586                .get_template("layout")?
587                .render(minijinja::context! {
588                    content => body_text,
589                    subject => subject.clone(),
590                    ..base
591                })?,
592            None => body_text,
593        };
594        Ok(Rendered {
595            subject: subject.trim().to_string(),
596            text: tidy_text(&text),
597            html: Some(html),
598        })
599    }
600}
601
602#[derive(Clone, Serialize, Deserialize)]
603struct Pieces {
604    layouts: Vec<Layout>,
605    partials: Vec<Partial>,
606}
607
608struct Bundle {
609    template: Template,
610    layouts: Vec<Layout>,
611    partials: Vec<Partial>,
612}
613
614impl From<minijinja::Error> for CoreError {
615    fn from(e: minijinja::Error) -> Self {
616        CoreError::Internal(format!("mail template: {e}"))
617    }
618}
619
620/// An environment with the partials loaded, the `partial()` function and
621/// the `markdown` filter; undefined variables render empty.
622fn environment(partials: &[Partial], text: bool) -> Environment<'static> {
623    let mut env = Environment::new();
624    env.set_undefined_behavior(UndefinedBehavior::Lenient);
625    for partial in partials {
626        let source = if text {
627            partial.content_text.clone()
628        } else {
629            partial.content_html.clone()
630        };
631        let _ = env.add_template_owned(format!("partial:{}", partial.code), source);
632    }
633    env.add_filter("markdown", |value: String| -> Value {
634        Value::from_safe_string(crate::markdown::render(&value))
635    });
636    env.add_function(
637        "partial",
638        |state: &State, name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
639            let template = state.env().get_template(&format!("partial:{name}"))?;
640            let mut ctx: BTreeMap<String, Value> = BTreeMap::new();
641            for key in kwargs.args() {
642                ctx.insert(key.to_string(), kwargs.get(key)?);
643            }
644            // The caller's own variables stay visible to the partial.
645            let parent = state.lookup("app_name");
646            if let Some(app_name) = parent {
647                ctx.entry("app_name".to_string()).or_insert(app_name);
648            }
649            kwargs.assert_all_used()?;
650            let out = template.render(Value::from_object(ctx))?;
651            Ok(Value::from_safe_string(out))
652        },
653    );
654    env
655}
656
657/// The brand stylesheet's custom properties as a map, for a layout's CSS to
658/// read as `{{ brand.button_bg }}`.
659fn brand_values(css: &str) -> BTreeMap<String, String> {
660    let mut out = BTreeMap::new();
661    for part in css.split(';') {
662        let Some(at) = part.find("--mail-") else {
663            continue;
664        };
665        let rest = &part[at + "--mail-".len()..];
666        {
667            if let Some((key, value)) = rest.split_once(':') {
668                out.insert(
669                    key.trim().replace('-', "_"),
670                    value.trim().trim_end_matches('}').trim().to_string(),
671                );
672            }
673        }
674    }
675    out
676}
677
678/// A plain-text reading of a Markdown or HTML body: tags gone, entities
679/// resolved, blank lines kept.
680fn text_of(source: &str) -> String {
681    let mut out = String::with_capacity(source.len());
682    let mut in_tag = false;
683    for ch in source.chars() {
684        match ch {
685            '<' => in_tag = true,
686            '>' => in_tag = false,
687            _ if !in_tag => out.push(ch),
688            _ => {}
689        }
690    }
691    out.replace("&amp;", "&")
692        .replace("&lt;", "<")
693        .replace("&gt;", ">")
694        .replace("&quot;", "\"")
695        .replace("&#39;", "'")
696}
697
698fn tidy_text(text: &str) -> String {
699    let mut out = String::with_capacity(text.len());
700    let mut blank = 0;
701    for line in text.lines() {
702        let line = line.trim_end();
703        if line.trim().is_empty() {
704            blank += 1;
705            if blank > 1 {
706                continue;
707            }
708        } else {
709            blank = 0;
710        }
711        out.push_str(line);
712        out.push('\n');
713    }
714    out.trim().to_string()
715}
716
717fn template_from_file(code: &str, file: &str) -> Template {
718    let s = parse_file(file);
719    Template {
720        code: code.to_string(),
721        subject: s.settings.get("subject").cloned().unwrap_or_default(),
722        description: s.settings.get("description").cloned().unwrap_or_default(),
723        layout: s
724            .settings
725            .get("layout")
726            .cloned()
727            .filter(|l| !l.is_empty())
728            .or_else(|| Some("default".to_string())),
729        content_text: s.text,
730        content_html: s.html,
731        is_custom: false,
732    }
733}
734
735fn layout_from_file(code: &str, file: &str) -> Layout {
736    let s = parse_file(file);
737    Layout {
738        code: code.to_string(),
739        name: s
740            .settings
741            .get("name")
742            .cloned()
743            .unwrap_or_else(|| code.to_string()),
744        content_text: s.text,
745        content_html: s.html,
746        content_css: s.css,
747        is_custom: false,
748    }
749}
750
751fn partial_from_file(code: &str, file: &str) -> Partial {
752    let s = parse_file(file);
753    Partial {
754        code: code.to_string(),
755        name: s
756            .settings
757            .get("name")
758            .cloned()
759            .unwrap_or_else(|| code.to_string()),
760        content_text: s.text,
761        content_html: s.html,
762        is_custom: false,
763    }
764}
765
766// ---- rows
767
768fn template_of(r: &sqlx::any::AnyRow) -> CoreResult<Template> {
769    Ok(Template {
770        code: r.get_text("code")?,
771        subject: r.get_text("subject")?,
772        description: r.get_text("description")?,
773        layout: r.get_text_opt("layout")?.filter(|l| !l.is_empty()),
774        content_text: r.get_text("content_text")?,
775        content_html: r.get_text("content_html")?,
776        is_custom: r.get_bool("is_custom")?,
777    })
778}
779
780async fn list_templates(db: &Db) -> CoreResult<Vec<Template>> {
781    let (sql, values) = build(
782        db.backend,
783        Query::select()
784            .columns([
785                MailTemplates::Code,
786                MailTemplates::Subject,
787                MailTemplates::Description,
788                MailTemplates::Layout,
789                MailTemplates::ContentText,
790                MailTemplates::ContentHtml,
791                MailTemplates::IsCustom,
792            ])
793            .from(MailTemplates::Table)
794            .order_by(MailTemplates::Code, Order::Asc)
795            .to_owned(),
796    );
797    let rows = bind_values(sqlx::query(&sql), values)
798        .fetch_all(&db.pool)
799        .await?;
800    rows.iter().map(template_of).collect()
801}
802
803async fn find_template(db: &Db, code: &str) -> CoreResult<Option<Template>> {
804    let (sql, values) = build(
805        db.backend,
806        Query::select()
807            .columns([
808                MailTemplates::Code,
809                MailTemplates::Subject,
810                MailTemplates::Description,
811                MailTemplates::Layout,
812                MailTemplates::ContentText,
813                MailTemplates::ContentHtml,
814                MailTemplates::IsCustom,
815            ])
816            .from(MailTemplates::Table)
817            .and_where(Expr::col(MailTemplates::Code).eq(code))
818            .to_owned(),
819    );
820    let row = bind_values(sqlx::query(&sql), values)
821        .fetch_optional(&db.pool)
822        .await?;
823    row.as_ref().map(template_of).transpose()
824}
825
826async fn upsert_template(db: &Db, t: &Template) -> CoreResult<()> {
827    let (sql, values) = build(
828        db.backend,
829        Query::insert()
830            .into_table(MailTemplates::Table)
831            .columns([
832                MailTemplates::Code,
833                MailTemplates::Subject,
834                MailTemplates::Description,
835                MailTemplates::Layout,
836                MailTemplates::ContentText,
837                MailTemplates::ContentHtml,
838                MailTemplates::IsCustom,
839            ])
840            .values_panic([
841                t.code.as_str().into(),
842                t.subject.as_str().into(),
843                t.description.as_str().into(),
844                t.layout.clone().into(),
845                t.content_text.as_str().into(),
846                t.content_html.as_str().into(),
847                t.is_custom.into(),
848            ])
849            .on_conflict(
850                OnConflict::column(MailTemplates::Code)
851                    .update_columns([
852                        MailTemplates::Subject,
853                        MailTemplates::Description,
854                        MailTemplates::Layout,
855                        MailTemplates::ContentText,
856                        MailTemplates::ContentHtml,
857                        MailTemplates::IsCustom,
858                    ])
859                    .to_owned(),
860            )
861            .to_owned(),
862    );
863    bind_values(sqlx::query(&sql), values)
864        .execute(&db.pool)
865        .await?;
866    Ok(())
867}
868
869async fn delete_template(db: &Db, code: &str) -> CoreResult<()> {
870    let (sql, values) = build(
871        db.backend,
872        Query::delete()
873            .from_table(MailTemplates::Table)
874            .and_where(Expr::col(MailTemplates::Code).eq(code))
875            .to_owned(),
876    );
877    bind_values(sqlx::query(&sql), values)
878        .execute(&db.pool)
879        .await?;
880    Ok(())
881}
882
883fn layout_of(r: &sqlx::any::AnyRow) -> CoreResult<Layout> {
884    Ok(Layout {
885        code: r.get_text("code")?,
886        name: r.get_text("name")?,
887        content_text: r.get_text("content_text")?,
888        content_html: r.get_text("content_html")?,
889        content_css: r.get_text("content_css")?,
890        is_custom: r.get_bool("is_custom")?,
891    })
892}
893
894async fn list_layouts(db: &Db) -> CoreResult<Vec<Layout>> {
895    let (sql, values) = build(
896        db.backend,
897        Query::select()
898            .columns([
899                MailLayouts::Code,
900                MailLayouts::Name,
901                MailLayouts::ContentText,
902                MailLayouts::ContentHtml,
903                MailLayouts::ContentCss,
904                MailLayouts::IsCustom,
905            ])
906            .from(MailLayouts::Table)
907            .order_by(MailLayouts::Code, Order::Asc)
908            .to_owned(),
909    );
910    let rows = bind_values(sqlx::query(&sql), values)
911        .fetch_all(&db.pool)
912        .await?;
913    rows.iter().map(layout_of).collect()
914}
915
916async fn find_layout(db: &Db, code: &str) -> CoreResult<Option<Layout>> {
917    Ok(list_layouts(db).await?.into_iter().find(|l| l.code == code))
918}
919
920async fn upsert_layout(db: &Db, l: &Layout) -> CoreResult<()> {
921    let (sql, values) = build(
922        db.backend,
923        Query::insert()
924            .into_table(MailLayouts::Table)
925            .columns([
926                MailLayouts::Code,
927                MailLayouts::Name,
928                MailLayouts::ContentText,
929                MailLayouts::ContentHtml,
930                MailLayouts::ContentCss,
931                MailLayouts::IsCustom,
932            ])
933            .values_panic([
934                l.code.as_str().into(),
935                l.name.as_str().into(),
936                l.content_text.as_str().into(),
937                l.content_html.as_str().into(),
938                l.content_css.as_str().into(),
939                l.is_custom.into(),
940            ])
941            .on_conflict(
942                OnConflict::column(MailLayouts::Code)
943                    .update_columns([
944                        MailLayouts::Name,
945                        MailLayouts::ContentText,
946                        MailLayouts::ContentHtml,
947                        MailLayouts::ContentCss,
948                        MailLayouts::IsCustom,
949                    ])
950                    .to_owned(),
951            )
952            .to_owned(),
953    );
954    bind_values(sqlx::query(&sql), values)
955        .execute(&db.pool)
956        .await?;
957    Ok(())
958}
959
960async fn delete_layout(db: &Db, code: &str) -> CoreResult<()> {
961    let (sql, values) = build(
962        db.backend,
963        Query::delete()
964            .from_table(MailLayouts::Table)
965            .and_where(Expr::col(MailLayouts::Code).eq(code))
966            .to_owned(),
967    );
968    bind_values(sqlx::query(&sql), values)
969        .execute(&db.pool)
970        .await?;
971    Ok(())
972}
973
974fn partial_of(r: &sqlx::any::AnyRow) -> CoreResult<Partial> {
975    Ok(Partial {
976        code: r.get_text("code")?,
977        name: r.get_text("name")?,
978        content_text: r.get_text("content_text")?,
979        content_html: r.get_text("content_html")?,
980        is_custom: r.get_bool("is_custom")?,
981    })
982}
983
984async fn list_partials(db: &Db) -> CoreResult<Vec<Partial>> {
985    let (sql, values) = build(
986        db.backend,
987        Query::select()
988            .columns([
989                MailPartials::Code,
990                MailPartials::Name,
991                MailPartials::ContentText,
992                MailPartials::ContentHtml,
993                MailPartials::IsCustom,
994            ])
995            .from(MailPartials::Table)
996            .order_by(MailPartials::Code, Order::Asc)
997            .to_owned(),
998    );
999    let rows = bind_values(sqlx::query(&sql), values)
1000        .fetch_all(&db.pool)
1001        .await?;
1002    rows.iter().map(partial_of).collect()
1003}
1004
1005async fn find_partial(db: &Db, code: &str) -> CoreResult<Option<Partial>> {
1006    Ok(list_partials(db)
1007        .await?
1008        .into_iter()
1009        .find(|p| p.code == code))
1010}
1011
1012async fn upsert_partial(db: &Db, p: &Partial) -> CoreResult<()> {
1013    let (sql, values) = build(
1014        db.backend,
1015        Query::insert()
1016            .into_table(MailPartials::Table)
1017            .columns([
1018                MailPartials::Code,
1019                MailPartials::Name,
1020                MailPartials::ContentText,
1021                MailPartials::ContentHtml,
1022                MailPartials::IsCustom,
1023            ])
1024            .values_panic([
1025                p.code.as_str().into(),
1026                p.name.as_str().into(),
1027                p.content_text.as_str().into(),
1028                p.content_html.as_str().into(),
1029                p.is_custom.into(),
1030            ])
1031            .on_conflict(
1032                OnConflict::column(MailPartials::Code)
1033                    .update_columns([
1034                        MailPartials::Name,
1035                        MailPartials::ContentText,
1036                        MailPartials::ContentHtml,
1037                        MailPartials::IsCustom,
1038                    ])
1039                    .to_owned(),
1040            )
1041            .to_owned(),
1042    );
1043    bind_values(sqlx::query(&sql), values)
1044        .execute(&db.pool)
1045        .await?;
1046    Ok(())
1047}
1048
1049async fn delete_partial(db: &Db, code: &str) -> CoreResult<()> {
1050    let (sql, values) = build(
1051        db.backend,
1052        Query::delete()
1053            .from_table(MailPartials::Table)
1054            .and_where(Expr::col(MailPartials::Code).eq(code))
1055            .to_owned(),
1056    );
1057    bind_values(sqlx::query(&sql), values)
1058        .execute(&db.pool)
1059        .await?;
1060    Ok(())
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065    use super::*;
1066
1067    #[test]
1068    fn a_file_splits_into_header_and_sections() {
1069        let s = parse_file("subject = \"Hi {{ name }}\"\nlayout = plain\n==\nplain {{ name }}\n==\n# Hi {{ name }}\n");
1070        assert_eq!(s.settings["subject"], "Hi {{ name }}");
1071        assert_eq!(s.settings["layout"], "plain");
1072        assert_eq!(s.text, "plain {{ name }}");
1073        assert_eq!(s.html, "# Hi {{ name }}");
1074        let two = parse_file("name = \"Button\"\n==\n<a>{{ body }}</a>");
1075        assert_eq!(two.settings["name"], "Button");
1076        assert_eq!(two.html, "<a>{{ body }}</a>");
1077        assert!(two.text.is_empty());
1078        let four = parse_file(
1079            "name = L\n==\n{{ content }}\n==\n<html>{{ content }}</html>\n==\nbody { color: red }",
1080        );
1081        assert_eq!(four.css, "body { color: red }");
1082    }
1083
1084    #[test]
1085    fn brand_properties_become_a_map_and_text_loses_its_tags() {
1086        let map = brand_values(":root { --mail-button-bg: #c0392b; --mail-text-color: #222; }");
1087        assert_eq!(map["button_bg"], "#c0392b");
1088        assert_eq!(map["text_color"], "#222");
1089        assert_eq!(text_of("<p>Hi &amp; bye</p>"), "Hi & bye");
1090        assert_eq!(tidy_text("a\n\n\n\nb  \n"), "a\n\nb");
1091    }
1092}