Skip to main content

headless_lms_models/
email_layouts.rs

1//! The HTML shells outgoing emails are wrapped in. See `email_processor::wrap_in_layout`.
2
3use headless_lms_utils::email_processor::{self, DEFAULT_EMAIL_THEME, EmailTheme};
4
5use crate::prelude::*;
6
7/// A configured shell, as opposed to the default bundled in the code.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct EmailLayout {
10    pub id: Uuid,
11    pub updated_at: DateTime<Utc>,
12    /// `None` for the fallback shell.
13    pub language: Option<String>,
14    pub html: String,
15    /// `#RRGGBB`.
16    pub button_background_color: String,
17    /// `#RRGGBB`.
18    pub button_text_color: String,
19}
20
21/// Every live shell, at most one per language. Pick one for an email with `find_for_language`.
22pub async fn get_all_live(conn: &mut PgConnection) -> ModelResult<Vec<EmailLayout>> {
23    let res = sqlx::query_as!(
24        EmailLayout,
25        r#"
26SELECT id,
27  updated_at,
28  language,
29  html,
30  button_background_color,
31  button_text_color
32FROM email_layouts
33WHERE deleted_at IS NULL
34        "#
35    )
36    .fetch_all(conn)
37    .await?;
38    Ok(res)
39}
40
41/// The shell for an email in `language` (a BCP 47 tag such as "fi" or "fi-FI"): the exact match,
42/// else the match on its primary subtag, else the language-less shell. Case-insensitive. `None`
43/// means the bundled default applies.
44pub fn find_for_language<'a>(
45    layouts: &'a [EmailLayout],
46    language: Option<&str>,
47) -> Option<&'a EmailLayout> {
48    let with_language = |wanted: &str| {
49        layouts.iter().find(|layout| {
50            layout
51                .language
52                .as_deref()
53                .is_some_and(|language| language.eq_ignore_ascii_case(wanted))
54        })
55    };
56    let language = language
57        .map(str::trim)
58        .filter(|language| !language.is_empty());
59    language
60        .and_then(|language| {
61            with_language(language).or_else(|| {
62                let (primary, _) = language.split_once(['-', '_'])?;
63                with_language(primary)
64            })
65        })
66        .or_else(|| layouts.iter().find(|layout| layout.language.is_none()))
67}
68
69/// The shell an email is rendered with and the colours its buttons get: a live layout, or the
70/// bundled default.
71#[derive(Debug, Clone, Copy)]
72pub struct ResolvedLayout<'a> {
73    pub html: &'a str,
74    pub theme: EmailTheme<'a>,
75}
76
77impl<'a> ResolvedLayout<'a> {
78    /// The live layout for `language` (see [`find_for_language`]), else the bundled default.
79    pub fn for_language(layouts: &'a [EmailLayout], language: Option<&str>) -> Self {
80        match find_for_language(layouts, language) {
81            Some(layout) => Self {
82                html: &layout.html,
83                theme: EmailTheme {
84                    button_background_color: &layout.button_background_color,
85                    button_text_color: &layout.button_text_color,
86                },
87            },
88            None => Self {
89                html: email_processor::DEFAULT_EMAIL_LAYOUT,
90                theme: DEFAULT_EMAIL_THEME,
91            },
92        }
93    }
94}