Skip to main content

headless_lms_utils/
email_processor.rs

1//! Renders email bodies, stored as the Gutenberg blocks the CMS email editor saves, into the HTML and
2//! plain-text parts of a message.
3
4use std::borrow::Cow;
5use std::collections::{BTreeSet, HashMap, HashSet};
6
7use once_cell::sync::Lazy;
8use regex::{Captures, Regex};
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12use crate::strings::escape_html;
13
14static ALL_TAG_REGEX: Lazy<Regex> =
15    Lazy::new(|| Regex::new(r"<.+?>").expect("invalid all_tags regex"));
16static LINK_REGEX: Lazy<Regex> = Lazy::new(|| {
17    Regex::new(r#"(?s)<a\s[^>]*?href="([^"]*)"[^>]*>(.*?)</a>"#).expect("invalid link regex")
18});
19static WHITESPACE_REGEX: Lazy<Regex> =
20    Lazy::new(|| Regex::new(r"\s+").expect("invalid whitespace regex"));
21static PLACEHOLDER_REGEX: Lazy<Regex> =
22    Lazy::new(|| Regex::new(r"\{\{(\w+)\}\}").expect("invalid placeholder regex"));
23// Gutenberg's link inputs turn `{{LINK}}` into `https://{{LINK}}`. The CMS strips this on save, but
24// templates saved before it did still have it.
25static PREPENDED_PROTOCOL_BEFORE_PLACEHOLDER_REGEX: Lazy<Regex> = Lazy::new(|| {
26    Regex::new(r"^https?://(\{\{\w+\}\})$").expect("invalid prepended_protocol regex")
27});
28static PREPENDED_PROTOCOL_IN_HREF_REGEX: Lazy<Regex> = Lazy::new(|| {
29    Regex::new(r#"(<a\s[^>]*?href=")https?://(\{\{\w+\}\}")"#)
30        .expect("invalid prepended_protocol_in_href regex")
31});
32static LAYOUT_PLACEHOLDER_REGEX: Lazy<Regex> = Lazy::new(|| {
33    Regex::new(r"\{\{(CONTENT|SUBJECT|PREHEADER|LANGUAGE)\}\}")
34        .expect("invalid layout_placeholder regex")
35});
36static IMG_TAG_REGEX: Lazy<Regex> =
37    Lazy::new(|| Regex::new(r"(?is)<img\b[^>]*>").expect("invalid img_tag regex"));
38static SRC_ATTRIBUTE_REGEX: Lazy<Regex> = Lazy::new(|| {
39    Regex::new(r#"(?is)\ssrc\s*=\s*["']?([^"'\s>]*)"#).expect("invalid src_attribute regex")
40});
41static HREFLESS_LINK_REGEX: Lazy<Regex> =
42    Lazy::new(|| Regex::new(r"(?s)<a>(.*?)</a>").expect("invalid hrefless_link regex"));
43
44/// The inline formatting Gutenberg's rich text produces that email clients render. Not `mark`: old
45/// highlights lose their colour styles to the sanitizer, and a bare `mark` is painted yellow.
46const RICH_TEXT_TAGS: [&str; 11] = [
47    "a", "strong", "b", "em", "i", "s", "sub", "sup", "code", "kbd", "br",
48];
49const LINK_SCHEMES: [&str; 3] = ["https", "http", "mailto"];
50const IMAGE_SCHEMES: [&str; 2] = ["https", "http"];
51
52static RICH_TEXT_SANITIZER: Lazy<ammonia::Builder<'static>> =
53    Lazy::new(|| rich_text_sanitizer(&[]));
54/// For the `value` of quotes saved before quotes held inner blocks, which is a run of `<p>`s.
55static QUOTE_VALUE_SANITIZER: Lazy<ammonia::Builder<'static>> =
56    Lazy::new(|| rich_text_sanitizer(&["p"]));
57
58static CSS_INLINER: Lazy<css_inline::CSSInliner<'static>> = Lazy::new(|| {
59    // The style tags stay for the clients that read them: media queries cannot be inlined, and
60    // their `!important` rules still beat the inlined desktop values.
61    css_inline::CSSInliner::options()
62        .keep_style_tags(true)
63        .keep_link_tags(true)
64        .load_remote_stylesheets(false)
65        .build()
66});
67
68/// What the CMS stores as an image's alt text until the author writes one. Must match
69/// `ALT_TEXT_NOT_CHANGED_PLACEHOLDER` in `services/cms/src/services/altTextPlaceholder.ts`.
70const UNCHANGED_ALT_TEXT: &str = "Add alt";
71
72/// The width of the content column of the shells: 640px minus 40px padding on both sides.
73const MAX_IMAGE_WIDTH_PX: f64 = 560.0;
74
75/// The shell used when no `email_layouts` row applies.
76pub const DEFAULT_EMAIL_LAYOUT: &str = include_str!("email_layout_default.html");
77
78/// Colours the renderer writes inline, as `#RRGGBB`. Outlook desktop and clients that strip
79/// `<style>` read only these, so a shell's CSS alone cannot rebrand them.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct EmailTheme<'a> {
82    pub button_background_color: &'a str,
83    pub button_text_color: &'a str,
84}
85
86/// The theme of `DEFAULT_EMAIL_LAYOUT`. Must match the column defaults of `email_layouts`.
87pub const DEFAULT_EMAIL_THEME: EmailTheme<'static> = EmailTheme {
88    button_background_color: "#1F6964",
89    button_text_color: "#FFFFFF",
90};
91
92/// Where the images the renderer links to are served from: `services/main-frontend/public/static/email`.
93/// Sent emails keep linking to these files, so they must never be renamed or removed.
94const EMAIL_ASSET_BASE_URL: &str = "https://courses.mooc.fi/static/email";
95
96const CALLOUT_BACKGROUND_COLOR: &str = "#EDF3F2";
97const CALLOUT_BORDER_COLOR: &str = "#DAE6E5";
98
99const QUOTE_BORDER_COLOR: &str = "#DAE6E5";
100const QUOTE_TEXT_COLOR: &str = "#535A66";
101
102const ONE_TIME_CODE_BACKGROUND_COLOR: &str = "#F7F8F9";
103const ONE_TIME_CODE_BORDER_COLOR: &str = "#DDDEE0";
104const ONE_TIME_CODE_TEXT_COLOR: &str = "#1A2333";
105const ONE_TIME_CODE_FONT_FAMILY: &str =
106    "SFMono-Regular, Menlo, Consolas, 'Liberation Mono', 'Courier New', monospace";
107
108/// Block types an email body may contain; must match `allowedEmailCoreBlocks` and
109/// `blockTypeMapForEmails` in the CMS. Any other type fails deserialization, so a body is never sent
110/// with a block silently left out.
111#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone, Copy)]
112pub enum EmailBlockName {
113    #[serde(rename = "core/paragraph")]
114    Paragraph,
115    #[serde(rename = "core/heading")]
116    Heading,
117    #[serde(rename = "core/image")]
118    Image,
119    #[serde(rename = "core/list")]
120    List,
121    #[serde(rename = "core/list-item")]
122    ListItem,
123    #[serde(rename = "core/table")]
124    Table,
125    #[serde(rename = "core/buttons")]
126    Buttons,
127    #[serde(rename = "core/button")]
128    Button,
129    #[serde(rename = "core/quote")]
130    Quote,
131    #[serde(rename = "core/separator")]
132    Separator,
133    #[serde(rename = "core/spacer")]
134    Spacer,
135    #[serde(rename = "core/code")]
136    Code,
137    #[serde(rename = "moocfi/email-callout")]
138    Callout,
139    #[serde(rename = "moocfi/email-one-time-code")]
140    OneTimeCode,
141}
142
143const DEFAULT_SPACER_HEIGHT_PX: u32 = 32;
144const MAX_SPACER_HEIGHT_PX: f64 = 160.0;
145
146/// One block of an email body. Rich-text attributes (see `EmailBlockName::is_rich_text_attribute`)
147/// are HTML, sanitized when rendered; every other attribute is plain.
148#[derive(Debug, Deserialize, Serialize, PartialEq, Clone)]
149#[serde(rename_all = "camelCase")]
150pub struct EmailGutenbergBlock {
151    pub name: EmailBlockName,
152    #[serde(default)]
153    pub attributes: Map<String, Value>,
154    #[serde(default)]
155    pub inner_blocks: Vec<EmailGutenbergBlock>,
156}
157
158impl EmailBlockName {
159    /// Table sections count as rich text because their cells' `content` is.
160    fn is_rich_text_attribute(self, key: &str) -> bool {
161        match self {
162            Self::Paragraph | Self::Heading | Self::ListItem | Self::Code => key == "content",
163            Self::Image => key == "caption",
164            Self::Table => matches!(key, "caption" | "head" | "body" | "foot"),
165            Self::Button => key == "text",
166            Self::Quote => matches!(key, "value" | "citation"),
167            Self::Callout => key == "title",
168            Self::List | Self::Buttons | Self::Separator | Self::Spacer | Self::OneTimeCode => {
169                false
170            }
171        }
172    }
173}
174
175struct TableCell<'a> {
176    tag: &'static str,
177    content: &'a str,
178    colspan: Option<u32>,
179    rowspan: Option<u32>,
180}
181
182impl EmailGutenbergBlock {
183    fn str_attribute(&self, key: &str) -> &str {
184        self.attributes
185            .get(key)
186            .and_then(Value::as_str)
187            .unwrap_or_default()
188    }
189
190    fn rich_text(&self, key: &str) -> String {
191        sanitize_rich_text(self.str_attribute(key), &RICH_TEXT_SANITIZER)
192    }
193
194    fn rich_text_as_text(&self, key: &str) -> String {
195        html_to_text(&self.rich_text(key))
196    }
197
198    /// Whether the author picked the block style `name`, which Gutenberg records in `className`.
199    fn has_style(&self, name: &str) -> bool {
200        self.str_attribute("className")
201            .split_whitespace()
202            .any(|class| class.strip_prefix("is-style-") == Some(name))
203    }
204
205    /// A length in px; Gutenberg stores it as a string like "100px", old content as a number.
206    fn px_attribute(&self, key: &str) -> Option<f64> {
207        let px = match self.attributes.get(key) {
208            Some(Value::Number(number)) => number.as_f64(),
209            Some(Value::String(text)) => text.trim().trim_end_matches("px").trim().parse().ok(),
210            _ => None,
211        };
212        px.filter(|px| px.is_finite())
213    }
214
215    fn spacer_height_px(&self) -> u32 {
216        self.px_attribute("height")
217            .map(|height| height.clamp(0.0, MAX_SPACER_HEIGHT_PX).round() as u32)
218            .unwrap_or(DEFAULT_SPACER_HEIGHT_PX)
219    }
220
221    /// The width an image is shown at: the author's, but never wider than the content column.
222    /// Without one the image fills the column.
223    fn image_width_px(&self) -> u32 {
224        self.px_attribute("width")
225            .filter(|width| *width >= 1.0)
226            .unwrap_or(MAX_IMAGE_WIDTH_PX)
227            .min(MAX_IMAGE_WIDTH_PX)
228            .round() as u32
229    }
230
231    fn image_alt(&self) -> &str {
232        match self.str_attribute("alt").trim() {
233            UNCHANGED_ALT_TEXT => "",
234            alt => alt,
235        }
236    }
237
238    fn table_rows(&self, section: &str) -> Vec<Vec<TableCell<'_>>> {
239        let Some(Value::Array(rows)) = self.attributes.get(section) else {
240            return vec![];
241        };
242        let span = |cell: &Value, key: &str| {
243            let span = match cell.get(key)? {
244                Value::Number(number) => number.as_u64(),
245                Value::String(text) => text.trim().parse().ok(),
246                _ => None,
247            }?;
248            u32::try_from(span).ok().filter(|span| *span > 1)
249        };
250        rows.iter()
251            .map(|row| {
252                row.get("cells")
253                    .and_then(Value::as_array)
254                    .map(|cells| {
255                        cells
256                            .iter()
257                            .map(|cell| TableCell {
258                                tag: match cell.get("tag").and_then(Value::as_str) {
259                                    Some("th") => "th",
260                                    _ => "td",
261                                },
262                                content: cell
263                                    .get("content")
264                                    .and_then(Value::as_str)
265                                    .unwrap_or_default(),
266                                colspan: span(cell, "colspan"),
267                                rowspan: span(cell, "rowspan"),
268                            })
269                            .collect()
270                    })
271                    .unwrap_or_default()
272            })
273            .collect()
274    }
275
276    /// Its items' numbers if ordered, honouring `start` and `reversed` like a browser does.
277    fn list_item_numbers(&self) -> impl Iterator<Item = i64> + '_ {
278        let count = self.inner_blocks.len() as i64;
279        let is_reversed = self.is_reversed();
280        let start = self
281            .attributes
282            .get("start")
283            .and_then(Value::as_i64)
284            .unwrap_or(if is_reversed { count } else { 1 });
285        (0..count).map(move |index| {
286            if is_reversed {
287                start.saturating_sub(index)
288            } else {
289                start.saturating_add(index)
290            }
291        })
292    }
293
294    fn is_ordered(&self) -> bool {
295        self.attributes
296            .get("ordered")
297            .and_then(Value::as_bool)
298            .unwrap_or(false)
299    }
300
301    fn is_reversed(&self) -> bool {
302        self.attributes
303            .get("reversed")
304            .and_then(Value::as_bool)
305            .unwrap_or(false)
306    }
307}
308
309fn rich_text_sanitizer(extra_tags: &[&'static str]) -> ammonia::Builder<'static> {
310    let mut sanitizer = ammonia::Builder::empty();
311    sanitizer
312        .tags(RICH_TEXT_TAGS.iter().chain(extra_tags).copied().collect())
313        .generic_attributes(HashSet::new())
314        .tag_attributes(HashMap::from([("a", HashSet::from(["href"]))]))
315        .url_schemes(HashSet::from(LINK_SCHEMES))
316        .url_relative(ammonia::UrlRelative::Deny)
317        .link_rel(None);
318    sanitizer
319}
320
321fn sanitize_rich_text(html: &str, sanitizer: &ammonia::Builder) -> String {
322    let sanitized = sanitizer.clean(html).to_string();
323    // A link whose target was rejected loses only its href; unwrap it so it does not look like one.
324    HREFLESS_LINK_REGEX
325        .replace_all(&sanitized, "$1")
326        .into_owned()
327}
328
329/// `url` trimmed, if it is absolute with one of `schemes`.
330fn url_with_scheme<'a>(url: &'a str, schemes: &[&str]) -> Option<&'a str> {
331    let url = url.trim();
332    let (scheme, _) = url.split_once(':')?;
333    schemes
334        .iter()
335        .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
336        .then_some(url)
337}
338
339/// Replaces `{{KEY}}` in every attribute, nested blocks included. Values are HTML-escaped in rich
340/// text and inserted as is elsewhere; a protocol Gutenberg prepended to a link whose whole target is
341/// a placeholder is dropped. Returns the keys found that `replacements` has no value for;
342/// those are left in place.
343pub fn fill_placeholders(
344    blocks: &mut [EmailGutenbergBlock],
345    replacements: &HashMap<String, String>,
346) -> BTreeSet<String> {
347    let mut unfilled = BTreeSet::new();
348    fill_placeholders_in_blocks(blocks, replacements, &mut unfilled);
349    unfilled
350}
351
352fn fill_placeholders_in_blocks(
353    blocks: &mut [EmailGutenbergBlock],
354    replacements: &HashMap<String, String>,
355    unfilled: &mut BTreeSet<String>,
356) {
357    for block in blocks {
358        for (key, value) in block.attributes.iter_mut() {
359            let is_html = block.name.is_rich_text_attribute(key);
360            fill_placeholders_in_value(value, replacements, is_html, unfilled);
361        }
362        fill_placeholders_in_blocks(&mut block.inner_blocks, replacements, unfilled);
363    }
364}
365
366fn fill_placeholders_in_value(
367    value: &mut Value,
368    replacements: &HashMap<String, String>,
369    is_html: bool,
370    unfilled: &mut BTreeSet<String>,
371) {
372    match value {
373        Value::String(text) => {
374            if let Cow::Owned(filled) =
375                fill_placeholders_in_text(text, replacements, is_html, unfilled)
376            {
377                *text = filled;
378            }
379        }
380        Value::Array(values) => values
381            .iter_mut()
382            .for_each(|v| fill_placeholders_in_value(v, replacements, is_html, unfilled)),
383        Value::Object(fields) => fields
384            .values_mut()
385            .for_each(|v| fill_placeholders_in_value(v, replacements, is_html, unfilled)),
386        _ => {}
387    }
388}
389
390fn fill_placeholders_in_text<'a>(
391    text: &'a str,
392    replacements: &HashMap<String, String>,
393    is_html: bool,
394    unfilled: &mut BTreeSet<String>,
395) -> Cow<'a, str> {
396    let unprefixed = if is_html {
397        PREPENDED_PROTOCOL_IN_HREF_REGEX.replace_all(text, "$1$2")
398    } else {
399        PREPENDED_PROTOCOL_BEFORE_PLACEHOLDER_REGEX.replace(text, "$1")
400    };
401    match unprefixed {
402        Cow::Borrowed(text) => replace_placeholders(text, replacements, is_html, unfilled),
403        Cow::Owned(text) => {
404            Cow::Owned(replace_placeholders(&text, replacements, is_html, unfilled).into_owned())
405        }
406    }
407}
408
409fn replace_placeholders<'a>(
410    text: &'a str,
411    replacements: &HashMap<String, String>,
412    is_html: bool,
413    unfilled: &mut BTreeSet<String>,
414) -> Cow<'a, str> {
415    PLACEHOLDER_REGEX.replace_all(text, |caps: &Captures| match replacements.get(&caps[1]) {
416        Some(replacement) if is_html => escape_html(replacement),
417        Some(replacement) => replacement.clone(),
418        None => {
419            unfilled.insert(caps[1].to_string());
420            caps[0].to_string()
421        }
422    })
423}
424
425/// Renders a body into the HTML a shell's `{{CONTENT}}` is replaced with.
426pub fn process_content_to_html(blocks: &[EmailGutenbergBlock], theme: EmailTheme) -> String {
427    blocks
428        .iter()
429        .map(|block| block_to_html(block, theme))
430        .collect()
431}
432
433fn block_to_html(block: &EmailGutenbergBlock, theme: EmailTheme) -> String {
434    match block.name {
435        // Inline, not `h1 + p` in the shell: Gmail and Outlook ignore sibling selectors.
436        EmailBlockName::Paragraph if block.has_style("lead") => format!(
437            r#"<p class="email-lead" style="font-size: 18px; line-height: 28px;">{}</p>"#,
438            block.rich_text("content")
439        ),
440        EmailBlockName::Paragraph => format!("<p>{}</p>", block.rich_text("content")),
441        EmailBlockName::Heading => {
442            let level = block
443                .attributes
444                .get("level")
445                .and_then(Value::as_u64)
446                .filter(|level| (1..=6).contains(level))
447                .unwrap_or(2);
448            format!("<h{level}>{}</h{level}>", block.rich_text("content"))
449        }
450        EmailBlockName::Image => {
451            // The width attribute is for Outlook desktop, which ignores CSS widths on images.
452            let img = match url_with_scheme(block.str_attribute("url"), &IMAGE_SCHEMES) {
453                Some(src) => {
454                    let width = block.image_width_px();
455                    format!(
456                        r#"<img src="{}" alt="{}" width="{width}" style="width: 100%; max-width: {width}px; height: auto;">"#,
457                        escape_html(src),
458                        escape_html(block.image_alt())
459                    )
460                }
461                None => String::new(),
462            };
463            let img = match url_with_scheme(block.str_attribute("href"), &LINK_SCHEMES) {
464                Some(href) if !img.is_empty() => {
465                    format!(r#"<a href="{}">{img}</a>"#, escape_html(href))
466                }
467                _ => img,
468            };
469            match block.rich_text("caption").as_str() {
470                "" => img,
471                caption => format!(
472                    r#"<div class="email-image">{img}<p class="email-image-caption">{caption}</p></div>"#
473                ),
474            }
475        }
476        EmailBlockName::List => {
477            let items = process_content_to_html(&block.inner_blocks, theme);
478            if !block.is_ordered() {
479                return format!("<ul>{items}</ul>");
480            }
481            let start = match block.attributes.get("start").and_then(Value::as_i64) {
482                Some(start) => format!(r#" start="{start}""#),
483                None => String::new(),
484            };
485            let reversed = if block.is_reversed() { " reversed" } else { "" };
486            format!("<ol{start}{reversed}>{items}</ol>")
487        }
488        EmailBlockName::ListItem => format!(
489            "<li>{}{}</li>",
490            block.rich_text("content"),
491            process_content_to_html(&block.inner_blocks, theme)
492        ),
493        EmailBlockName::Table => {
494            let section = |name: &str, tag: &str| {
495                let rows = block.table_rows(name);
496                if rows.is_empty() {
497                    return String::new();
498                }
499                let rows: String = rows
500                    .iter()
501                    .map(|cells| {
502                        let cells: String = cells
503                            .iter()
504                            .map(|cell| {
505                                let mut spans = String::new();
506                                if let Some(colspan) = cell.colspan {
507                                    spans.push_str(&format!(r#" colspan="{colspan}""#));
508                                }
509                                if let Some(rowspan) = cell.rowspan {
510                                    spans.push_str(&format!(r#" rowspan="{rowspan}""#));
511                                }
512                                format!(
513                                    "<{tag}{spans}>{}</{tag}>",
514                                    sanitize_rich_text(cell.content, &RICH_TEXT_SANITIZER),
515                                    tag = cell.tag
516                                )
517                            })
518                            .collect();
519                        format!("<tr>{cells}</tr>")
520                    })
521                    .collect();
522                format!("<{tag}>{rows}</{tag}>")
523            };
524            let caption = match block.rich_text("caption").as_str() {
525                "" => String::new(),
526                caption => format!("<caption>{caption}</caption>"),
527            };
528            format!(
529                "<table>{caption}{}{}{}</table>",
530                section("head", "thead"),
531                section("body", "tbody"),
532                section("foot", "tfoot")
533            )
534        }
535        EmailBlockName::Buttons => format!(
536            r#"<div class="email-buttons">{}</div>"#,
537            process_content_to_html(&block.inner_blocks, theme)
538        ),
539        // Outlook desktop drops padding and background on <a>, so the cell repeats them in bgcolor
540        // and `mso-padding-alt`, which only Outlook reads. The arrow is text because Gmail and
541        // Outlook drop CSS `::after`.
542        EmailBlockName::Button => {
543            let (open_tag, close_tag) =
544                match url_with_scheme(block.str_attribute("url"), &LINK_SCHEMES) {
545                    Some(url) => (format!(r#"a href="{}""#, escape_html(url)), "a"),
546                    None => ("span".to_string(), "span"),
547                };
548            format!(
549                r#"<table role="presentation" class="email-button" cellpadding="0" cellspacing="0" border="0"><tr><td class="email-button-cell" bgcolor="{background}" style="background-color: {background}; border-radius: 6px; mso-padding-alt: 14px 26px;"><{open_tag} class="email-button-link" style="display: inline-block; padding: 14px 26px; font-size: 16px; line-height: 20px; font-weight: 600; color: {text}; text-decoration: none;">{}{}</{close_tag}></td></tr></table>"#,
550                block.rich_text("text"),
551                if block.has_style("arrow") {
552                    "&nbsp;&rarr;"
553                } else {
554                    ""
555                },
556                background = escape_html(theme.button_background_color),
557                text = escape_html(theme.button_text_color),
558            )
559        }
560        // A cell, not <blockquote>: Outlook desktop ignores padding on a blockquote, so its text would
561        // touch the bar.
562        EmailBlockName::Quote => {
563            let citation = match block.rich_text("citation").as_str() {
564                "" => String::new(),
565                citation => format!(
566                    r#"<cite style="display: block; margin: 0; font-size: 14px; font-style: normal;">{citation}</cite>"#
567                ),
568            };
569            let body = if block.inner_blocks.is_empty() {
570                sanitize_rich_text(block.str_attribute("value"), &QUOTE_VALUE_SANITIZER)
571            } else {
572                process_content_to_html(&block.inner_blocks, theme)
573            };
574            format!(
575                r#"<table role="presentation" class="email-quote" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%; margin: 0 0 18px;"><tr><td style="padding: 0 0 0 16px; border: 0; border-left: 4px solid {QUOTE_BORDER_COLOR}; font-size: 16px; line-height: 26px; color: {QUOTE_TEXT_COLOR};">{body}{citation}</td></tr></table>"#
576            )
577        }
578        // Tables, not <hr> or a sized <div>: Outlook desktop ignores their borders, heights and margins.
579        EmailBlockName::Separator => r##"<table role="presentation" class="email-separator" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td height="1" style="height: 1px; font-size: 1px; line-height: 1px; mso-line-height-rule: exactly; border-top: 1px solid #DDDEE0;">&nbsp;</td></tr></table>"##.to_string(),
580        EmailBlockName::Spacer => {
581            let height = block.spacer_height_px();
582            format!(
583                r#"<table role="presentation" class="email-spacer" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td height="{height}" style="height: {height}px; font-size: {height}px; line-height: {height}px; mso-line-height-rule: exactly; padding: 0; border: 0;">&nbsp;</td></tr></table>"#
584            )
585        }
586        EmailBlockName::Code => format!(
587            "<pre class=\"email-code\"><code>{}</code></pre>",
588            block.rich_text("content").replace("<br>", "\n")
589        ),
590        EmailBlockName::OneTimeCode => {
591            let code = block.str_attribute("code").trim();
592            if code.is_empty() {
593                return String::new();
594            }
595            // The extra left padding offsets the letter-spacing trailing the last character.
596            format!(
597                r#"<table role="presentation" class="email-one-time-code" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{ONE_TIME_CODE_BACKGROUND_COLOR}" style="width: 100%; margin: 0 0 18px; background-color: {ONE_TIME_CODE_BACKGROUND_COLOR}; border: 1px solid {ONE_TIME_CODE_BORDER_COLOR}; border-radius: 6px; border-collapse: separate;"><tr><td align="center" style="padding: 16px 16px 16px 20px; border: 0; text-align: center; font-family: {ONE_TIME_CODE_FONT_FAMILY}; font-size: 22px; line-height: 30px; mso-line-height-rule: exactly; font-weight: 600; letter-spacing: 4px; color: {ONE_TIME_CODE_TEXT_COLOR}; overflow-wrap: anywhere; word-break: break-word; user-select: all;">{}</td></tr></table>"#,
598                escape_html(code)
599            )
600        }
601        EmailBlockName::Callout => {
602            let icon_cell = match callout_icon_file(block.str_attribute("icon")) {
603                Some(file) => format!(
604                    r#"<td class="email-callout-icon" width="24" valign="top" style="width: 24px; padding: 20px 0 20px 20px; border: 0;"><img src="{EMAIL_ASSET_BASE_URL}/{file}" width="24" height="24" alt="" style="display: block; width: 24px; height: 24px; margin: 1px 0 0; border: 0; border-radius: 0;"></td>"#
605                ),
606                None => String::new(),
607            };
608            let body_padding = if icon_cell.is_empty() {
609                "20px"
610            } else {
611                "20px 20px 20px 14px"
612            };
613            let title = match block.rich_text("title").as_str() {
614                "" => String::new(),
615                title => format!(
616                    r#"<p class="email-callout-title" style="margin: 0 0 6px;"><strong>{title}</strong></p>"#
617                ),
618            };
619            format!(
620                r#"<table role="presentation" class="email-callout" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{CALLOUT_BACKGROUND_COLOR}" style="width: 100%; background-color: {CALLOUT_BACKGROUND_COLOR}; border: 1px solid {CALLOUT_BORDER_COLOR}; border-radius: 8px; border-collapse: separate;"><tr>{icon_cell}<td class="email-callout-body" valign="top" style="padding: {body_padding}; border: 0;">{title}{}</td></tr></table>"#,
621                process_content_to_html(&block.inner_blocks, theme)
622            )
623        }
624    }
625}
626
627/// The hosted PNG for a callout's `icon` attribute; `None` for "none" and unknown values.
628fn callout_icon_file(icon: &str) -> Option<&'static str> {
629    match icon {
630        "info" => Some("icon-info.png"),
631        "calendar" => Some("icon-calendar.png"),
632        "warning" => Some("icon-warning.png"),
633        "check" => Some("icon-check.png"),
634        _ => None,
635    }
636}
637
638/// Renders a body into the plain-text part of a message. Links keep their targets in the text.
639pub fn process_content_to_plaintext(blocks: &[EmailGutenbergBlock]) -> String {
640    blocks
641        .iter()
642        .map(block_to_plaintext)
643        .filter(|text| !text.is_empty())
644        .collect::<Vec<_>>()
645        .join("\n\n")
646}
647
648fn block_to_plaintext(block: &EmailGutenbergBlock) -> String {
649    match block.name {
650        EmailBlockName::Paragraph | EmailBlockName::Heading => block.rich_text_as_text("content"),
651        EmailBlockName::Image => {
652            let alt = block.image_alt().replace('"', "");
653            let image =
654                url_with_scheme(block.str_attribute("url"), &IMAGE_SCHEMES).map(|url| {
655                    match (
656                        url_with_scheme(block.str_attribute("href"), &LINK_SCHEMES),
657                        alt.as_str(),
658                    ) {
659                        (Some(href), "") => href.to_string(),
660                        (Some(href), alt) => format!("{alt}: {href}"),
661                        (None, "") => format!("<{url}>"),
662                        (None, alt) => format!("\"{alt}\", <{url}>"),
663                    }
664                });
665            let caption = Some(block.rich_text_as_text("caption")).filter(|text| !text.is_empty());
666            image
667                .into_iter()
668                .chain(caption)
669                .collect::<Vec<_>>()
670                .join("\n")
671        }
672        EmailBlockName::List | EmailBlockName::ListItem => list_to_plaintext(block, 0),
673        EmailBlockName::Table => {
674            let mut lines = vec![];
675            let caption = block.rich_text_as_text("caption");
676            if !caption.is_empty() {
677                lines.push(caption);
678            }
679            for section in ["head", "body", "foot"] {
680                for cells in block.table_rows(section) {
681                    let cells: Vec<String> = cells
682                        .iter()
683                        .map(|cell| {
684                            html_to_text(&sanitize_rich_text(cell.content, &RICH_TEXT_SANITIZER))
685                        })
686                        .collect();
687                    lines.push(cells.join(" | "));
688                }
689            }
690            lines.join("\n")
691        }
692        EmailBlockName::Buttons => block
693            .inner_blocks
694            .iter()
695            .map(block_to_plaintext)
696            .collect::<Vec<_>>()
697            .join("\n"),
698        EmailBlockName::Button => {
699            let text = block.rich_text_as_text("text");
700            match url_with_scheme(block.str_attribute("url"), &LINK_SCHEMES) {
701                Some(url) => format!("{text}: {url}"),
702                None => text,
703            }
704        }
705        EmailBlockName::Quote => {
706            let body = if block.inner_blocks.is_empty() {
707                let value =
708                    sanitize_rich_text(block.str_attribute("value"), &QUOTE_VALUE_SANITIZER);
709                html_to_text(&value.replace("</p>", "\n"))
710            } else {
711                process_content_to_plaintext(&block.inner_blocks)
712            };
713            let mut lines: Vec<String> = body
714                .lines()
715                .map(|line| format!("> {line}").trim_end().to_string())
716                .collect();
717            let citation = block.rich_text_as_text("citation");
718            if !citation.is_empty() {
719                lines.push(format!("— {citation}"));
720            }
721            lines.join("\n")
722        }
723        EmailBlockName::Separator => "---".to_string(),
724        EmailBlockName::Spacer => String::new(),
725        EmailBlockName::Code => html_to_preformatted_text(&block.rich_text("content"))
726            .lines()
727            .map(|line| format!("    {line}").trim_end().to_string())
728            .collect::<Vec<_>>()
729            .join("\n"),
730        EmailBlockName::OneTimeCode => block.str_attribute("code").trim().to_string(),
731        EmailBlockName::Callout => [
732            block.rich_text_as_text("title"),
733            process_content_to_plaintext(&block.inner_blocks),
734        ]
735        .into_iter()
736        .filter(|text| !text.is_empty())
737        .collect::<Vec<_>>()
738        .join("\n"),
739    }
740}
741
742fn list_to_plaintext(list: &EmailGutenbergBlock, depth: usize) -> String {
743    let indent = "  ".repeat(depth);
744    let mut numbers = list.list_item_numbers();
745    let is_ordered = list.is_ordered();
746    let mut lines = vec![];
747    for item in &list.inner_blocks {
748        let number = numbers.next();
749        let marker = match number {
750            Some(number) if is_ordered => format!("{number}."),
751            _ => "*".to_string(),
752        };
753        lines.push(format!(
754            "{indent}{marker} {}",
755            item.rich_text_as_text("content")
756        ));
757        for nested in &item.inner_blocks {
758            lines.push(list_to_plaintext(nested, depth + 1));
759        }
760    }
761    lines.join("\n")
762}
763
764/// The inbox preview: the first paragraph as one line of text, or empty when there is none.
765pub fn preheader(blocks: &[EmailGutenbergBlock]) -> String {
766    blocks
767        .iter()
768        .find(|block| block.name == EmailBlockName::Paragraph)
769        .map(|block| {
770            let text = block.rich_text_as_text("content");
771            WHITESPACE_REGEX.replace_all(&text, " ").into_owned()
772        })
773        .unwrap_or_default()
774}
775
776/// Rich text to plain text. A link keeps its target, since the plain-text part has no other way to
777/// show it.
778fn html_to_text(html: &str) -> String {
779    html_to_preformatted_text(html).trim().to_string()
780}
781
782/// Like [`html_to_text`], but keeps the first line's indentation.
783fn html_to_preformatted_text(html: &str) -> String {
784    let with_links = LINK_REGEX.replace_all(html, |caps: &Captures| {
785        let href = &caps[1];
786        let text = ALL_TAG_REGEX.replace_all(&caps[2], "");
787        if text.trim() == href || text.trim().is_empty() {
788            href.to_string()
789        } else {
790            format!("{text} ({href})")
791        }
792    });
793    let with_breaks = with_links.replace("<br>", "\n");
794    let text = ALL_TAG_REGEX.replace_all(&with_breaks, "");
795    crate::strings::decode_html_entities(&text)
796        .trim_start_matches(['\r', '\n'])
797        .trim_end()
798        .to_string()
799}
800
801/// What a shell's placeholders are filled with.
802pub struct EmailLayoutFields<'a> {
803    /// Rendered body HTML, inserted as is.
804    pub content_html: &'a str,
805    pub subject: &'a str,
806    pub preheader: &'a str,
807    /// BCP 47 tag for `lang`; empty when unknown.
808    pub language: &'a str,
809}
810
811/// Wraps a rendered body in an email shell: `{{CONTENT}}` gets the body as is, `{{SUBJECT}}`,
812/// `{{PREHEADER}}` and `{{LANGUAGE}}` their escaped values. One pass, so placeholders inside the body
813/// are never expanded.
814pub fn wrap_in_layout(layout_html: &str, fields: &EmailLayoutFields) -> String {
815    LAYOUT_PLACEHOLDER_REGEX
816        .replace_all(layout_html, |caps: &Captures| match &caps[1] {
817            "CONTENT" => fields.content_html.to_string(),
818            "SUBJECT" => escape_html(fields.subject),
819            "PREHEADER" => escape_html(fields.preheader),
820            _ => escape_html(fields.language),
821        })
822        .into_owned()
823}
824
825/// What a message is rendered from: a body and subject with `{{KEY}}` placeholders, and the layout
826/// it is wrapped in.
827pub struct EmailRenderInput<'a> {
828    pub layout_html: &'a str,
829    pub theme: EmailTheme<'a>,
830    pub subject: &'a str,
831    pub body: Vec<EmailGutenbergBlock>,
832    /// BCP 47 tag for `lang`; empty when unknown.
833    pub language: &'a str,
834    pub replacements: &'a HashMap<String, String>,
835}
836
837/// A message ready to hand to the mail relay.
838#[derive(Debug, Clone, PartialEq, Eq)]
839pub struct RenderedEmail {
840    pub subject: String,
841    pub html: String,
842    pub plain_text: String,
843    /// Placeholders in the subject or body that `replacements` had no value for, left as typed.
844    pub unfilled_placeholders: BTreeSet<String>,
845}
846
847/// Renders a whole message. The one path both sending and previewing go through, so a preview shows
848/// what is sent.
849pub fn render_email(input: EmailRenderInput) -> RenderedEmail {
850    let EmailRenderInput {
851        layout_html,
852        theme,
853        subject,
854        mut body,
855        language,
856        replacements,
857    } = input;
858    let mut unfilled_placeholders = fill_placeholders(&mut body, replacements);
859    // Not escaped: the subject header is not HTML, and `wrap_in_layout` escapes it for the shell.
860    let subject =
861        fill_placeholders_in_text(subject, replacements, false, &mut unfilled_placeholders)
862            .into_owned();
863    let html = wrap_in_layout(
864        layout_html,
865        &EmailLayoutFields {
866            content_html: &process_content_to_html(&body, theme),
867            subject: &subject,
868            preheader: &preheader(&body),
869            language,
870        },
871    );
872    RenderedEmail {
873        html: inline_css(html),
874        plain_text: process_content_to_plaintext(&body),
875        subject,
876        unfilled_placeholders,
877    }
878}
879
880/// Copies the `<style>` rules onto the elements they match, for clients that drop `<style>`.
881fn inline_css(html: String) -> String {
882    match CSS_INLINER.inline(&html) {
883        Ok(inlined) => inlined,
884        Err(err) => {
885            tracing::warn!("Could not inline the CSS of an email, sending it as is: {err}");
886            html
887        }
888    }
889}
890
891/// Problems that make a shell render badly: a missing `{{SUBJECT}}`, `{{PREHEADER}}` or
892/// `{{LANGUAGE}}`, or an image not loaded over https, which clients block or warn about. Empty when
893/// none is found; a heuristic, not validation.
894pub fn layout_warnings(layout_html: &str) -> Vec<String> {
895    let mut warnings: Vec<String> = ["{{SUBJECT}}", "{{PREHEADER}}", "{{LANGUAGE}}"]
896        .into_iter()
897        .filter(|placeholder| !layout_html.contains(placeholder))
898        .map(|placeholder| format!("missing {placeholder}"))
899        .collect();
900    for img in IMG_TAG_REGEX.find_iter(layout_html) {
901        let src = SRC_ATTRIBUTE_REGEX
902            .captures(img.as_str())
903            .map(|caps| caps[1].to_string())
904            .unwrap_or_default();
905        if !src.to_ascii_lowercase().starts_with("https://") {
906            warnings.push(format!("image source is not https: {src:?}"));
907        }
908    }
909    warnings
910}
911
912#[cfg(test)]
913mod email_processor_tests {
914    use pretty_assertions::assert_eq;
915    use serde_json::json;
916
917    use super::*;
918
919    fn blocks(value: Value) -> Vec<EmailGutenbergBlock> {
920        serde_json::from_value(value).unwrap()
921    }
922
923    fn paragraph(content: &str) -> Value {
924        json!({ "name": "core/paragraph", "attributes": { "content": content, "dropCap": false }, "innerBlocks": [] })
925    }
926
927    fn list(ordered: bool, items: &[&str]) -> Value {
928        let items: Vec<Value> = items
929            .iter()
930            .map(|item| json!({ "name": "core/list-item", "attributes": { "content": item }, "innerBlocks": [] }))
931            .collect();
932        json!({ "name": "core/list", "attributes": { "ordered": ordered }, "innerBlocks": items })
933    }
934
935    #[test]
936    fn it_converts_paragraph_correctly_to_plain_text() {
937        let input = blocks(json!([paragraph("testi paragraph.")]));
938        assert_eq!("testi paragraph.", process_content_to_plaintext(&input));
939    }
940
941    #[test]
942    fn it_converts_paragraph_wrapped_in_tags_correctly_to_plain_text() {
943        let input = blocks(json!([paragraph(
944            "<strong><em>testi paragraph.</em></strong>"
945        )]));
946        assert_eq!("testi paragraph.", process_content_to_plaintext(&input));
947    }
948
949    #[test]
950    fn it_converts_heading_correctly_to_plain_text() {
951        let input = blocks(
952            json!([{ "name": "core/heading", "attributes": { "content": "Email heading", "level": 2 } }]),
953        );
954        assert_eq!("Email heading", process_content_to_plaintext(&input));
955    }
956
957    #[test]
958    fn it_converts_image_containing_double_quotes_correctly_to_plain_text() {
959        let input = blocks(
960            json!([{ "name": "core/image", "attributes": { "alt": r#""Alternative title""#, "url": "https://example.com/a.png" } }]),
961        );
962        assert_eq!(
963            "\"Alternative title\", <https://example.com/a.png>",
964            process_content_to_plaintext(&input)
965        );
966    }
967
968    #[test]
969    fn it_converts_unordered_list_containing_other_tags_correctly_to_plain_text() {
970        let input = blocks(json!([list(
971            false,
972            &["<code>1</code>", "<kbd>2</kbd>", "3"]
973        )]));
974        assert_eq!("* 1\n* 2\n* 3", process_content_to_plaintext(&input));
975    }
976
977    #[test]
978    fn it_converts_ordered_list_correctly_to_plain_text() {
979        let input = blocks(json!([list(true, &["first", "second", "third"])]));
980        assert_eq!(
981            "1. first\n2. second\n3. third",
982            process_content_to_plaintext(&input)
983        );
984    }
985
986    #[test]
987    fn it_converts_paragraph_correctly_to_html() {
988        let input = blocks(json!([paragraph("testi paragraph.")]));
989        assert_eq!(
990            "<p>testi paragraph.</p>",
991            process_content_to_html(&input, DEFAULT_EMAIL_THEME)
992        );
993    }
994
995    #[test]
996    fn it_converts_heading_correctly_to_html() {
997        let input = blocks(
998            json!([{ "name": "core/heading", "attributes": { "content": "Email heading", "level": 3 } }]),
999        );
1000        assert_eq!(
1001            "<h3>Email heading</h3>",
1002            process_content_to_html(&input, DEFAULT_EMAIL_THEME)
1003        );
1004    }
1005
1006    #[test]
1007    fn it_converts_image_correctly_to_html() {
1008        let input = blocks(
1009            json!([{ "name": "core/image", "attributes": { "alt": "A \"title\"", "url": "https://example.com/a.png" } }]),
1010        );
1011        assert_eq!(
1012            r#"<img src="https://example.com/a.png" alt="A &quot;title&quot;" width="560" style="width: 100%; max-width: 560px; height: auto;">"#,
1013            process_content_to_html(&input, DEFAULT_EMAIL_THEME)
1014        );
1015    }
1016
1017    #[test]
1018    fn it_converts_lists_correctly_to_html() {
1019        let input = blocks(json!([
1020            list(false, &["<code>1</code>", "2"]),
1021            list(true, &["first", "second"])
1022        ]));
1023        assert_eq!(
1024            "<ul><li><code>1</code></li><li>2</li></ul><ol><li>first</li><li>second</li></ol>",
1025            process_content_to_html(&input, DEFAULT_EMAIL_THEME)
1026        );
1027    }
1028}