1use 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"));
23static 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
44const 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(&[]));
54static 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 css_inline::CSSInliner::options()
62 .keep_style_tags(true)
63 .keep_link_tags(true)
64 .load_remote_stylesheets(false)
65 .build()
66});
67
68const UNCHANGED_ALT_TEXT: &str = "Add alt";
71
72const MAX_IMAGE_WIDTH_PX: f64 = 560.0;
74
75pub const DEFAULT_EMAIL_LAYOUT: &str = include_str!("email_layout_default.html");
77
78#[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
86pub const DEFAULT_EMAIL_THEME: EmailTheme<'static> = EmailTheme {
88 button_background_color: "#1F6964",
89 button_text_color: "#FFFFFF",
90};
91
92const 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#[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#[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 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 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 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 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 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 HREFLESS_LINK_REGEX
325 .replace_all(&sanitized, "$1")
326 .into_owned()
327}
328
329fn 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
339pub 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
425pub 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 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 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 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 " →"
553 } else {
554 ""
555 },
556 background = escape_html(theme.button_background_color),
557 text = escape_html(theme.button_text_color),
558 )
559 }
560 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"), "E_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 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;"> </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;"> </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 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
627fn 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
638pub 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"), "E_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
764pub 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
776fn html_to_text(html: &str) -> String {
779 html_to_preformatted_text(html).trim().to_string()
780}
781
782fn 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
801pub struct EmailLayoutFields<'a> {
803 pub content_html: &'a str,
805 pub subject: &'a str,
806 pub preheader: &'a str,
807 pub language: &'a str,
809}
810
811pub 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
825pub 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 pub language: &'a str,
834 pub replacements: &'a HashMap<String, String>,
835}
836
837#[derive(Debug, Clone, PartialEq, Eq)]
839pub struct RenderedEmail {
840 pub subject: String,
841 pub html: String,
842 pub plain_text: String,
843 pub unfilled_placeholders: BTreeSet<String>,
845}
846
847pub 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 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
880fn 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
891pub 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 "title"" 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}