//! Email-safe block types + table-based renderer.
//!
//! Email clients (especially Outlook's Word renderer + Gmail's
//! mobile webview) ignore most modern CSS. This renderer
//! emits the table-based, inline-CSS markup that survives the
//! lowest common denominator: 600px-wide centered table, role
//! presentation hints for screen readers, web-safe font stack.
//!
//! The renderer is intentionally a pure fn — same blocks +
//! same vars always produce the same HTML — so tests + the
//! frontend's preview pane (which ports the same logic) stay
//! aligned.
//!
//! Variable substitution: `{{name}}` style, looked up in a
//! `HashMap<String, String>`. Unknown placeholders pass
//! through unchanged so a partial render still ships.
use std::collections::HashMap;
use nexo_sanitize::{sanitize_color, sanitize_url};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TextAlign {
Left,
Center,
Right,
}
impl Default for TextAlign {
fn default() -> Self {
Self::Left
}
}
impl TextAlign {
fn as_str(&self) -> &'static str {
match self {
Self::Left => "left",
Self::Center => "center",
Self::Right => "right",
}
}
}
/// How an `Image` block delivers its bytes to the recipient.
///
/// - `Url` — `<img src="https://.../t/asset/.../{sha}.png">`,
/// recipient's mail client fetches over HTTPS. Smaller email
/// payload + works as a secondary engagement signal (every
/// image fetch hits our public route on the same host as the
/// tracking pixel).
/// - `Cid` — bytes ride along inline as a `multipart/related`
/// part the HTML body references via `<img src="cid:{sha}">`.
/// Email is heavier (image bytes baked in, ~33% base64
/// overhead on the wire) but renders even when the
/// recipient's client blocks external images (Outlook
/// default, Gmail "ask before showing").
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ImageEmbed {
#[default]
Url,
Cid,
}
/// One block in an email template. Persisted as JSON inside
/// `marketing_email_templates.blocks_json`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EmailBlock {
Heading {
/// Plain-text body. Used when `text_html` is `None`.
/// Always HTML-escaped during render.
text: String,
/// Optional rich-text body emitted by the TipTap
/// editor. When present, supersedes `text` and is
/// rendered VERBATIM after passing through the
/// email-safe sanitizer (whitelist tags + attrs).
/// Stored on the wire as the post-sanitization HTML
/// so a reload doesn't re-sanitize.
#[serde(default, skip_serializing_if = "Option::is_none")]
text_html: Option<String>,
/// 1, 2, 3 — clamped server-side. Anything else falls
/// back to 2.
#[serde(default = "default_heading_level")]
level: u8,
#[serde(default)]
color: Option<String>,
#[serde(default)]
align: TextAlign,
},
Paragraph {
text: String,
/// See `Heading.text_html`. Same semantics, applies
/// inside the `<p>` element.
#[serde(default, skip_serializing_if = "Option::is_none")]
text_html: Option<String>,
#[serde(default)]
color: Option<String>,
#[serde(default)]
align: TextAlign,
/// Px size, clamped 10..=32 server-side.
#[serde(default = "default_paragraph_size")]
font_size: u8,
},
Button {
text: String,
url: String,
#[serde(default = "default_button_bg")]
bg_color: String,
#[serde(default = "default_button_text")]
text_color: String,
#[serde(default = "default_align_center")]
align: TextAlign,
},
Image {
url: String,
#[serde(default)]
alt: String,
/// `None` → 100% (use full container width).
#[serde(default)]
width: Option<u32>,
#[serde(default = "default_align_center")]
align: TextAlign,
/// Optional click-through. When set, image becomes a
/// link.
#[serde(default)]
link_url: Option<String>,
/// Where the recipient's mail client fetches the
/// bytes from. `Url` (default) hits our public asset
/// route; `Cid` ships the bytes inline as a
/// `multipart/related` part the HTML references via
/// `<img src="cid:{sha}">`. URL gives engagement
/// signals + smaller emails; CID gives guaranteed
/// rendering even when the recipient's client
/// blocks external images. Defaults to `Url` so
/// pre-existing templates behave unchanged.
#[serde(default)]
embed: ImageEmbed,
},
Divider {
#[serde(default = "default_divider_color")]
color: String,
},
Spacer {
#[serde(default = "default_spacer_height")]
height_px: u32,
},
/// Legacy fixed 50/50 two-column block. Kept for back-
/// compat with templates saved before the generalised
/// Row/Column model landed; new authoring goes through
/// `Row` instead. The render path treats it identically
/// to a 2-column Row of 50% each.
TwoColumn {
#[serde(default)]
left: Vec<EmailBlock>,
#[serde(default)]
right: Vec<EmailBlock>,
},
/// Generalised Elementor-style row container. Holds 1-N
/// columns whose `width_pct` values sum to 100. Each
/// column holds element blocks (heading, paragraph, …);
/// nested rows are not rendered (the type permits them
/// but the UI won't author them).
Row {
#[serde(default)]
columns: Vec<Column>,
/// Operator-set background colour for the row's
/// outer TD (covers all columns). `None` ⇒ inherits
/// the email card's white background. Hex `#rrggbb`.
#[serde(default, skip_serializing_if = "Option::is_none")]
background: Option<String>,
/// Optional background image URL for the row. Combines
/// with `background` (colour shows through transparent
/// areas + acts as fallback when the client blocks
/// remote images). Must be `http://` or `https://`;
/// other schemes (`javascript:`, `data:`, `file:`) are
/// rejected by `sanitize_url` at render time.
#[serde(default, skip_serializing_if = "Option::is_none")]
background_image: Option<String>,
},
List {
items: Vec<String>,
#[serde(default)]
ordered: bool,
#[serde(default)]
color: Option<String>,
},
}
/// One column inside a `Row`. Width is a percentage of the
/// row's container; sum across siblings should be 100. The
/// renderer divides the difference evenly when the sum
/// drifts so a malformed template still produces a sane
/// table.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Column {
#[serde(default)]
pub blocks: Vec<EmailBlock>,
#[serde(default = "default_column_width")]
pub width_pct: u8,
/// Operator-set background colour for THIS column only
/// (overrides the row's background). `None` ⇒ transparent.
/// Hex `#rrggbb`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
/// Optional background image URL for the column. Same
/// `http(s)://` allowlist as `Row::background_image`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_image: Option<String>,
}
fn default_column_width() -> u8 {
100
}
fn default_heading_level() -> u8 {
2
}
fn default_paragraph_size() -> u8 {
16
}
fn default_button_bg() -> String {
"#4f46e5".into()
}
fn default_button_text() -> String {
"#ffffff".into()
}
fn default_align_center() -> TextAlign {
TextAlign::Center
}
fn default_divider_color() -> String {
"#e5e7eb".into()
}
fn default_spacer_height() -> u32 {
24
}
/// Render the block array into one self-contained HTML string
/// suitable for an email body. Wraps the content in a 600-px-
/// max centered table that collapses to 100% width on
/// narrower viewports (mobile mail clients) so the operator
/// doesn't have to author the email-safe boilerplate AND the
/// recipient doesn't horizontal-scroll on a phone.
///
/// The `viewport` meta tag tells iOS Mail / Gmail to render at
/// device width instead of zooming out to fit the 600px
/// canvas. The `mso-table-lspace` / `mso-table-rspace` zeros
/// strip Outlook's default 1.5pt cell padding that breaks
/// pixel-precise layouts.
pub fn render_template(blocks: &[EmailBlock], vars: &HashMap<String, String>) -> String {
render_template_with_bg(blocks, vars, None)
}
/// Same as `render_template` but takes an operator-set page
/// background colour (and optional image, via the `_image`
/// helper). `None` falls back to the conventional `#f5f5f5`
/// (the email-on-page card look). Caller-supplied values pass
/// through the sanitisers so a malformed string can't smuggle
/// CSS injection.
pub fn render_template_with_bg(
blocks: &[EmailBlock],
vars: &HashMap<String, String>,
page_background: Option<&str>,
) -> String {
render_template_with_page_bg(blocks, vars, page_background, None)
}
/// Full-fat helper — colour + image. The two-arg variant
/// above stays for callers that haven't surfaced an image
/// field yet (compose pre-image, tests). Image URL gates on
/// `sanitize_url` (http/https only); colour gates on
/// `sanitize_color` (default `#f5f5f5`).
pub fn render_template_with_page_bg(
blocks: &[EmailBlock],
vars: &HashMap<String, String>,
page_background: Option<&str>,
page_background_image: Option<&str>,
) -> String {
let body = blocks.iter().map(|b| render_block(b, vars)).collect::<String>();
let bg_color = page_background
.map(|c| sanitize_color(c, "#f5f5f5"))
.unwrap_or_else(|| "#f5f5f5".to_string());
let bg_image = page_background_image.and_then(sanitize_url);
// Build the page-level background fragment. We always emit
// a `background:` colour so dark-mode + missing-image
// fallback both work; image stacks on top via shorthand.
let img_css = bg_image
.as_ref()
.map(|u| format!("background-image:url({u});background-size:cover;background-position:center;background-repeat:no-repeat;"))
.unwrap_or_default();
let img_attr = bg_image
.as_ref()
.map(|u| format!(r#" background="{}""#, escape_html_attr(u)))
.unwrap_or_default();
format!(
r#"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="color-scheme" content="light dark"/><meta name="supported-color-schemes" content="light dark"/><style type="text/css">:root{{color-scheme:light dark;supported-color-schemes:light dark}}table{{mso-table-lspace:0;mso-table-rspace:0}}img{{max-width:100%;height:auto;display:block}}@media only screen and (max-width:520px){{.email-col{{display:block!important;width:100%!important;padding:0 0 12px 0!important}}}}@media (prefers-color-scheme:dark){{body,.email-bg{{background:#1a1a1a!important}}.email-card{{background:#262626!important}}.ed-h{{color:#f3f4f6!important}}.ed-p{{color:#cbd5e1!important}}.email-divider{{border-color:#404040!important}}}}</style></head><body class="email-bg" style="margin:0;padding:0;background:{bg_color};{img_css}"{img_attr}><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="email-bg" style="background:{bg_color};{img_css}"{img_attr}><tr><td align="center" style="padding:24px 12px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" class="email-card" style="max-width:600px;width:100%;background:#ffffff;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;"><tbody>{body}</tbody></table></td></tr></table></body></html>"#
)
}
/// Render a single block as a `<tr>...</tr>` (the parent
/// renderer wraps everything in a 600px table). TwoColumn is
/// the one exception — emits its own nested table inside a tr.
pub fn render_block(block: &EmailBlock, vars: &HashMap<String, String>) -> String {
match block {
EmailBlock::Heading { text, text_html, level, color, align } => {
// Prefer the rich-text body when set. The sanitizer
// already ran when the operator saved; we run it
// again defensively so a forged blocks_json can't
// sneak `<script>` past the renderer.
let body = match text_html.as_deref() {
Some(html) if !html.is_empty() => {
substitute_vars(&crate::email_template::email_safe_html(html), vars)
}
_ => {
let plain = substitute_vars(text, vars);
escape_html(&plain)
}
};
let level = match *level {
1 | 2 | 3 => *level,
_ => 2,
};
let size = match level {
1 => 32,
2 => 24,
_ => 20,
};
// `ed-h` ("email default heading") is the dark-mode
// hook. Only emitted when the operator left color
// unset — so a operator-chosen colour wins via the
// `style` attribute (inline non-!important > class
// rule under dark-mode media query, since the class
// rule omits `!important` for operator-set values
// and only the default branch carries the class).
let (color, class_attr) = match color.as_deref() {
Some(c) => (c, ""),
None => ("#1a1a1a", r#" class="ed-h""#),
};
format!(
r#"<tr><td style="padding:12px 24px;text-align:{align};"><h{level}{class_attr} style="margin:0;font-size:{size}px;line-height:1.3;color:{color};font-weight:600;">{body}</h{level}></td></tr>"#,
align = align.as_str(),
)
}
EmailBlock::Paragraph { text, text_html, color, align, font_size } => {
let body = match text_html.as_deref() {
Some(html) if !html.is_empty() => {
substitute_vars(&crate::email_template::email_safe_html(html), vars)
}
_ => {
let plain = substitute_vars(text, vars);
escape_html(&plain).replace('\n', "<br/>")
}
};
// Same dark-mode opt-in pattern as Heading: the
// `ed-p` class is only emitted on the default-colour
// branch so an operator-chosen colour wins inline.
let (color, class_attr) = match color.as_deref() {
Some(c) => (c, ""),
None => ("#374151", r#" class="ed-p""#),
};
let size = (*font_size).clamp(10, 32);
format!(
r#"<tr><td style="padding:8px 24px;text-align:{align};"><p{class_attr} style="margin:0;font-size:{size}px;line-height:1.5;color:{color};">{body}</p></td></tr>"#,
align = align.as_str(),
)
}
EmailBlock::Button { text, url, bg_color, text_color, align } => {
let text = substitute_vars(text, vars);
let text = escape_html(&text);
let url = substitute_vars(url, vars);
let url_attr = escape_html_attr(&url);
let bg = sanitize_color(bg_color, "#4f46e5");
let fg = sanitize_color(text_color, "#ffffff");
format!(
r#"<tr><td style="padding:16px 24px;text-align:{align};"><a href="{url_attr}" style="display:inline-block;padding:12px 28px;background:{bg};color:{fg};text-decoration:none;border-radius:6px;font-weight:600;font-size:15px;">{text}</a></td></tr>"#,
align = align.as_str(),
)
}
EmailBlock::Image { url, alt, width, align, link_url, embed } => {
let url = substitute_vars(url, vars);
// CID embed rewrites the src to `cid:{sha}` so the
// recipient's mail client resolves to the inline
// multipart/related part the publisher attached.
// The SHA is the last URL segment minus extension —
// see `extract_sha_from_asset_url` for the parser.
let final_src = match embed {
ImageEmbed::Cid => match extract_sha_from_asset_url(&url) {
Some(sha) => format!("cid:{sha}"),
None => url.clone(), // fallback: send URL anyway
},
ImageEmbed::Url => url.clone(),
};
let url_attr = escape_html_attr(&final_src);
let alt = escape_html_attr(alt);
// Alt-fallback styling — props applied to the
// `<img>` propagate to the placeholder rendering
// when the client blocks images (Outlook default,
// Gmail "ask before showing"). Without these the
// alt text lands as 12-px Times-New-Roman in a
// blue-bordered box; with them it reads like a
// styled message:
// border:0 → kill Outlook's blue link-image border
// display:block → kill the 4-px baseline gap
// font-family/size/style/color → style the alt
// text the recipient sees on a blocked-image
let base_style = "border:0;display:block;outline:none;\
font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;\
font-size:14px;font-style:italic;color:#6b7280;line-height:1.4;";
let (width_attr, size_style) = match width {
Some(w) => (
format!(r#" width="{w}""#),
format!("max-width:{w}px;width:100%;"),
),
None => (String::new(), "max-width:100%;".to_string()),
};
let img_tag = format!(
r#"<img src="{url_attr}" alt="{alt}"{width_attr} style="{base_style}{size_style}"/>"#
);
let inner = match link_url {
Some(link) if !link.is_empty() => {
let link = substitute_vars(link, vars);
let link_attr = escape_html_attr(&link);
format!(r#"<a href="{link_attr}">{img_tag}</a>"#)
}
_ => img_tag,
};
format!(
r#"<tr><td style="padding:8px 24px;text-align:{align};">{inner}</td></tr>"#,
align = align.as_str(),
)
}
EmailBlock::Divider { color } => {
let color = sanitize_color(color, "#e5e7eb");
// Same dark-mode opt-in as Heading / Paragraph:
// class is only emitted when the colour is the
// bundled default so operator-chosen colours win.
let class_attr = if color == "#e5e7eb" {
r#" class="email-divider""#
} else {
""
};
format!(
r#"<tr><td style="padding:12px 24px;"><hr{class_attr} style="border:0;border-top:1px solid {color};margin:0;"/></td></tr>"#
)
}
EmailBlock::Spacer { height_px } => {
let h = (*height_px).clamp(4, 200);
format!(
r#"<tr><td style="height:{h}px;line-height:{h}px;font-size:0;"> </td></tr>"#
)
}
EmailBlock::TwoColumn { left, right } => {
let left_html = left.iter().map(|b| render_block(b, vars)).collect::<String>();
let right_html = right.iter().map(|b| render_block(b, vars)).collect::<String>();
// Two nested tables side by side. `valign="top"` so
// unequal heights don't push one column to the middle.
// Width 50% each on desktop; the `email-col` class
// hooks the head <style> media query that stacks them
// to 100% on viewports < 520px (Gmail mobile, Apple
// Mail iOS, Outlook iOS — Outlook desktop ignores
// <style> but its viewport is wide, so the 50/50 lands
// ~270px per column which is still readable).
format!(
r#"<tr><td style="padding:8px 12px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td valign="top" width="50%" class="email-col" style="padding:0 12px 0 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tbody>{left_html}</tbody></table></td><td valign="top" width="50%" class="email-col" style="padding:0 0 0 12px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tbody>{right_html}</tbody></table></td></tr></table></td></tr>"#
)
}
EmailBlock::Row { columns, background, background_image } => {
if columns.is_empty() {
return r#"<tr><td style="padding:4px 12px;height:24px;"></td></tr>"#
.to_string();
}
let widths = normalize_column_widths(columns);
let mut tds = String::new();
for (i, col) in columns.iter().enumerate() {
let inner = col
.blocks
.iter()
.map(|b| render_block(b, vars))
.collect::<String>();
let pad_left = if i == 0 { 0 } else { 12 };
let pad_right = if i == columns.len() - 1 { 0 } else { 12 };
let w = widths[i];
// Per-column background — sanitised so a
// malicious string ("expression()", javascript
// url, …) can't slip into the inline style.
// Returns both the inline CSS and the legacy
// `background="URL"` HTML attr for clients that
// ignore CSS-set background-images.
let (col_bg, col_attr) = build_bg(
col.background.as_deref(),
col.background_image.as_deref(),
);
tds.push_str(&format!(
r#"<td valign="top" width="{w}%" class="email-col"{col_attr} style="{col_bg}padding:0 {pad_right}px 0 {pad_left}px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tbody>{inner}</tbody></table></td>"#
));
}
// Row-level background lives on the outer TD that
// wraps the column-table; covers the whole row
// band (operator's intent for a "section" colour).
let (row_bg, row_attr) = build_bg(
background.as_deref(),
background_image.as_deref(),
);
format!(
r#"<tr><td{row_attr} style="{row_bg}padding:8px 12px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>{tds}</tr></table></td></tr>"#
)
}
EmailBlock::List { items, ordered, color } => {
let tag = if *ordered { "ol" } else { "ul" };
let color = color.as_deref().unwrap_or("#374151");
let list_items = items
.iter()
.map(|i| {
let i = substitute_vars(i, vars);
format!(
r#"<li style="margin:4px 0;font-size:16px;line-height:1.5;color:{color};">{}</li>"#,
escape_html(&i)
)
})
.collect::<String>();
format!(
r#"<tr><td style="padding:8px 24px 8px 40px;"><{tag} style="margin:0;padding:0 0 0 16px;">{list_items}</{tag}></td></tr>"#
)
}
}
}
// ── Helpers ──────────────────────────────────────────────────────
/// One block-walk pass that collects every `Image` whose
/// `embed` is `Cid`. The compose / approve handlers call this
/// after rendering to know which assets to load from the
/// `AssetStore` and attach as `multipart/related` parts.
///
/// The walk recurses into `TwoColumn` so an image nested in
/// either column is included. Returned in document order so
/// the attachment list mirrors the HTML's rendering order
/// (helps Outlook's heuristic that walks parts top-to-bottom).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineImageRef {
pub sha256: String,
pub content_id: String,
/// MIME may not be known until the asset is loaded; the
/// caller fills it in then. Kept here so the helper can
/// inherit the file extension hint from the URL.
pub url_extension_hint: Option<String>,
}
pub fn collect_inline_image_refs(
blocks: &[EmailBlock],
vars: &HashMap<String, String>,
) -> Vec<InlineImageRef> {
let mut out = Vec::new();
walk_for_inline(blocks, vars, &mut out);
out
}
fn walk_for_inline(
blocks: &[EmailBlock],
vars: &HashMap<String, String>,
out: &mut Vec<InlineImageRef>,
) {
for b in blocks {
match b {
EmailBlock::Image { url, embed: ImageEmbed::Cid, .. } => {
let url = substitute_vars(url, vars);
if let Some(sha) = extract_sha_from_asset_url(&url) {
let ext = url
.rsplit_once('.')
.map(|(_, e)| e.to_string())
.filter(|e| e.len() <= 5);
out.push(InlineImageRef {
// CID == SHA so the render and the
// attachment list always agree without
// a separate id table.
content_id: sha.clone(),
sha256: sha,
url_extension_hint: ext,
});
}
}
EmailBlock::TwoColumn { left, right } => {
walk_for_inline(left, vars, out);
walk_for_inline(right, vars, out);
}
EmailBlock::Row { columns, .. } => {
for col in columns {
walk_for_inline(&col.blocks, vars, out);
}
}
_ => {}
}
}
}
/// Extract the SHA-256 from an asset URL of the shape
/// `{base}/t/asset/{tenant}/{sha}.{ext}`. Returns `None` if
/// the path doesn't match — handler keeps the URL src in
/// that case (fallback). Lowercases so the cid: ref matches
/// the AssetStore's lowercase keying.
fn extract_sha_from_asset_url(url: &str) -> Option<String> {
let last = url.rsplit('/').next()?;
let stem = last.split('.').next()?;
if stem.len() == 64 && stem.chars().all(|c| c.is_ascii_hexdigit()) {
Some(stem.to_ascii_lowercase())
} else {
None
}
}
/// Convert a legacy flat-blocks template into the Row /
/// Column model. Each top-level non-Row block becomes a
/// 1-column Row of width 100; existing Rows pass through
/// untouched. `TwoColumn` is rewritten to a 2-column Row at
/// 50/50 so the canvas treats both shapes uniformly. Idempotent
/// — running on already-migrated data is a no-op (every
/// block is already a Row, so nothing changes).
///
/// Called at API load time so the editor always sees Rows.
/// Saves go back as Rows, completing the migration on the
/// first round-trip.
pub fn migrate_legacy_to_rows(blocks: Vec<EmailBlock>) -> Vec<EmailBlock> {
let mut out: Vec<EmailBlock> = Vec::with_capacity(blocks.len());
for b in blocks {
match b {
EmailBlock::Row { .. } => out.push(b),
EmailBlock::TwoColumn { left, right } => {
out.push(EmailBlock::Row {
columns: vec![
Column {
blocks: migrate_legacy_to_rows_inner(left),
width_pct: 50,
background: None,
background_image: None,
},
Column {
blocks: migrate_legacy_to_rows_inner(right),
width_pct: 50,
background: None,
background_image: None,
},
],
background: None,
background_image: None,
});
}
other => {
out.push(EmailBlock::Row {
columns: vec![Column {
blocks: vec![other],
width_pct: 100,
background: None,
background_image: None,
}],
background: None,
background_image: None,
});
}
}
}
out
}
/// Same as `migrate_legacy_to_rows` but keeps non-Row blocks
/// flat — used for the contents INSIDE a column / TwoColumn
/// pane where wrapping each element in another Row would
/// create infinite nesting.
fn migrate_legacy_to_rows_inner(blocks: Vec<EmailBlock>) -> Vec<EmailBlock> {
blocks
}
/// Spread the operator-set `width_pct` values across the
/// columns so they sum to exactly 100. Operators usually
/// pick from preset layouts (50/50, 33/33/33, etc.) that sum
/// correctly; this guard catches manual fiddling and legacy
/// data so a malformed template still renders sane widths
/// instead of overflowing or under-filling the row.
fn normalize_column_widths(columns: &[Column]) -> Vec<u8> {
if columns.is_empty() {
return Vec::new();
}
let raw: Vec<u32> = columns
.iter()
.map(|c| c.width_pct.max(1) as u32)
.collect();
let sum: u32 = raw.iter().sum();
if sum == 100 {
return raw.iter().map(|&w| w as u8).collect();
}
// Scale proportionally; round; redistribute the rounding
// delta to the widest column so the total lands at 100.
let mut out: Vec<u32> =
raw.iter().map(|&w| (w * 100) / sum.max(1)).collect();
let total: u32 = out.iter().sum();
if total < 100 {
// Funnel the missing % to the widest column.
let widest = out
.iter()
.enumerate()
.max_by_key(|(_, &w)| w)
.map(|(i, _)| i)
.unwrap_or(0);
out[widest] += 100 - total;
} else if total > 100 {
let widest = out
.iter()
.enumerate()
.max_by_key(|(_, &w)| w)
.map(|(i, _)| i)
.unwrap_or(0);
out[widest] = out[widest].saturating_sub(total - 100);
}
out.iter().map(|&w| w.min(100) as u8).collect()
}
fn substitute_vars(template: &str, vars: &HashMap<String, String>) -> String {
if !template.contains("{{") {
return template.to_string();
}
let mut out = template.to_string();
for (k, v) in vars {
let needle = format!("{{{{{k}}}}}");
out = out.replace(&needle, v);
}
out
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn escape_html_attr(s: &str) -> String {
escape_html(s).replace('"', """)
}
/// Build the `background:` CSS shorthand for a row/column/page
/// taking both an optional colour and an optional image. The
/// image URL passes through `sanitize_url` so a malicious
/// template can't inject `javascript:` / break out of the
/// `url(...)` quoting. The colour passes through
/// `sanitize_color` (the caller can pass `"transparent"` as
/// default and we filter it out so we don't emit a useless
/// rule).
///
/// Returns `(style_fragment, html_attr)`:
/// - `style_fragment` is the inline CSS, ends with a semicolon
/// so the caller can concatenate, empty when both inputs
/// are absent / rejected.
/// - `html_attr` is the legacy `background="URL"` attribute
/// for clients that ignore CSS background-image (Outlook
/// 2007+ desktop, some older webmail). Empty when no image.
fn build_bg(color: Option<&str>, image: Option<&str>) -> (String, String) {
let col = color
.map(|c| sanitize_color(c, "transparent"))
.filter(|c| c != "transparent");
let img = image.and_then(sanitize_url);
let mut style = String::new();
if let Some(c) = &col {
style.push_str(&format!("background-color:{c};"));
}
if let Some(u) = &img {
// url() with no quotes — sanitize_url already banned
// every char that could break it. background-size:cover
// + center positioning is the most common operator
// intent for a "hero" or section bg.
style.push_str(&format!(
"background-image:url({u});background-size:cover;background-position:center;background-repeat:no-repeat;"
));
}
let attr = match &img {
Some(u) => format!(r#" background="{}""#, escape_html_attr(u)),
None => String::new(),
};
(style, attr)
}
#[cfg(test)]
mod tests {
use super::*;
fn vars(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn heading_renders_with_level_and_color() {
let b = EmailBlock::Heading {
text: "Hola Juan".into(),
text_html: None,
level: 1,
color: Some("#000000".into()),
align: TextAlign::Center,
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("<h1"));
assert!(out.contains("Hola Juan"));
assert!(out.contains("text-align:center"));
assert!(out.contains("#000000"));
}
#[test]
fn heading_invalid_level_falls_back_to_h2() {
let b = EmailBlock::Heading {
text: "x".into(),
text_html: None,
level: 7,
color: None,
align: TextAlign::default(),
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("<h2"));
}
#[test]
fn variables_substitute_in_text() {
let b = EmailBlock::Paragraph {
text: "Hola {{name}}, gracias.".into(),
text_html: None,
color: None,
align: TextAlign::Left,
font_size: 16,
};
let out = render_block(&b, &vars(&[("name", "Camila")]));
assert!(out.contains("Hola Camila, gracias."));
}
#[test]
fn rich_text_html_renders_inline_formatting() {
let b = EmailBlock::Paragraph {
text: "fallback".into(),
text_html: Some(
"Hola <strong>{{name}}</strong>, gracias.".into(),
),
color: None,
align: TextAlign::Left,
font_size: 16,
};
let out = render_block(&b, &vars(&[("name", "Camila")]));
assert!(out.contains("<strong>Camila</strong>"));
// Plain `text` ignored when text_html present.
assert!(!out.contains("fallback"));
}
#[test]
fn rich_text_html_strips_script_defensively() {
// Even if a forged blocks_json carries a `<script>` in
// text_html, the renderer's defensive sanitizer drops
// it before output.
let b = EmailBlock::Paragraph {
text: "x".into(),
text_html: Some("<script>alert(1)</script>safe".into()),
color: None,
align: TextAlign::Left,
font_size: 16,
};
let out = render_block(&b, &HashMap::new());
assert!(!out.contains("<script"));
assert!(out.contains("safe"));
}
#[test]
fn unknown_variables_pass_through() {
let b = EmailBlock::Paragraph {
text: "Hi {{unknown}} there".into(),
text_html: None,
color: None,
align: TextAlign::Left,
font_size: 16,
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("{{unknown}}"));
}
#[test]
fn html_escapes_user_text() {
let b = EmailBlock::Heading {
text: "<script>alert(1)</script>".into(),
text_html: None,
level: 2,
color: None,
align: TextAlign::default(),
};
let out = render_block(&b, &HashMap::new());
assert!(!out.contains("<script"));
assert!(out.contains("<script>"));
}
#[test]
fn button_renders_as_anchor_tag() {
let b = EmailBlock::Button {
text: "Click".into(),
url: "https://example.com".into(),
bg_color: "#ff0000".into(),
text_color: "#ffffff".into(),
align: TextAlign::Center,
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("<a "));
assert!(out.contains("href=\"https://example.com\""));
assert!(out.contains("background:#ff0000"));
}
#[test]
fn button_url_substitutes_vars() {
let b = EmailBlock::Button {
text: "Demo".into(),
url: "https://app.com/u/{{token}}".into(),
bg_color: "#000".into(),
text_color: "#fff".into(),
align: TextAlign::Left,
};
let out = render_block(&b, &vars(&[("token", "abc123")]));
assert!(out.contains("/u/abc123"));
}
#[test]
fn malicious_color_falls_back_to_default() {
let b = EmailBlock::Button {
text: "Click".into(),
url: "x".into(),
bg_color: "javascript:alert(1)".into(),
text_color: "#fff".into(),
align: TextAlign::Left,
};
let out = render_block(&b, &HashMap::new());
// Default button color is the fallback, not the
// malicious value.
assert!(!out.contains("javascript"));
assert!(out.contains("#4f46e5"));
}
#[test]
fn image_with_link_wraps_in_anchor() {
let b = EmailBlock::Image {
url: "https://x.com/banner.png".into(),
alt: "Banner".into(),
width: Some(300),
align: TextAlign::Center,
link_url: Some("https://example.com/promo".into()),
embed: ImageEmbed::Url,
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("<a href=\"https://example.com/promo\">"));
assert!(out.contains("<img src=\"https://x.com/banner.png\""));
assert!(out.contains("width=\"300\""));
}
#[test]
fn divider_emits_hr() {
let b = EmailBlock::Divider {
color: "#cccccc".into(),
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("<hr"));
assert!(out.contains("#cccccc"));
}
#[test]
fn spacer_clamps_to_safe_range() {
let big = EmailBlock::Spacer { height_px: 9999 };
let small = EmailBlock::Spacer { height_px: 1 };
let out_big = render_block(&big, &HashMap::new());
let out_small = render_block(&small, &HashMap::new());
assert!(out_big.contains("height:200px"));
assert!(out_small.contains("height:4px"));
}
#[test]
fn two_column_nests_blocks_side_by_side() {
let b = EmailBlock::TwoColumn {
left: vec![EmailBlock::Paragraph {
text: "left side".into(),
text_html: None,
color: None,
align: TextAlign::Left,
font_size: 14,
}],
right: vec![EmailBlock::Paragraph {
text: "right side".into(),
text_html: None,
color: None,
align: TextAlign::Right,
font_size: 14,
}],
};
let out = render_block(&b, &HashMap::new());
assert!(out.contains("left side"));
assert!(out.contains("right side"));
// Two cells with valign=top.
assert_eq!(out.matches("valign=\"top\"").count(), 2);
}
#[test]
fn list_renders_ul_or_ol() {
let ul = EmailBlock::List {
items: vec!["one".into(), "two".into()],
ordered: false,
color: None,
};
let ol = EmailBlock::List {
items: vec!["a".into(), "b".into()],
ordered: true,
color: None,
};
let ul_out = render_block(&ul, &HashMap::new());
let ol_out = render_block(&ol, &HashMap::new());
assert!(ul_out.contains("<ul"));
assert!(ol_out.contains("<ol"));
assert_eq!(ul_out.matches("<li").count(), 2);
}
#[test]
fn render_template_wraps_in_600px_table() {
let blocks = vec![EmailBlock::Heading {
text: "Hi".into(),
text_html: None,
level: 1,
color: None,
align: TextAlign::default(),
}];
let out = render_template(&blocks, &HashMap::new());
assert!(out.contains("<!DOCTYPE"));
// Responsive: max-width:600px lets clients < 600px scale.
assert!(out.contains("max-width:600px"));
// viewport meta tells iOS Mail / Gmail to render at device
// width — without it the email zooms out to fit the canvas.
assert!(out.contains(r#"name="viewport""#));
assert!(out.contains("<h1"));
assert!(out.contains("Hi"));
}
/// Dark-mode hooks: meta tags + media query + opt-in class
/// on default-colour heading. Operator-set colour does NOT
/// receive the class (so an explicit picker choice wins
/// over the dark-mode override).
#[test]
fn dark_mode_hooks_only_on_default_colours() {
let blocks = vec![
EmailBlock::Heading {
text: "Default".into(),
text_html: None,
level: 1,
color: None,
align: TextAlign::default(),
},
EmailBlock::Heading {
text: "Operator".into(),
text_html: None,
level: 2,
color: Some("#ff0000".into()),
align: TextAlign::default(),
},
EmailBlock::Paragraph {
text: "p".into(),
text_html: None,
color: None,
align: TextAlign::default(),
font_size: 16,
},
];
let out = render_template(&blocks, &HashMap::new());
// Meta + supported-color-schemes + media query present.
assert!(out.contains(r#"name="color-scheme""#));
assert!(out.contains("prefers-color-scheme:dark"));
assert!(out.contains(".email-card"));
// Wrapper backgrounds are class-tagged for inversion.
assert!(out.contains(r#"class="email-bg""#));
assert!(out.contains(r#"class="email-card""#));
// Default-coloured Heading + Paragraph carry the
// ed-h / ed-p hook; the operator-coloured Heading does
// not (otherwise dark mode would override the picker).
assert!(out.contains(r#"<h1 class="ed-h""#));
assert!(!out.contains(r#"<h2 class="ed-h""#));
assert!(out.contains(r#"<p class="ed-p""#));
}
/// Image carries alt-fallback styling so blocked-image
/// placeholders render readable instead of 12-px Times
/// New Roman in a blue-bordered box.
#[test]
/// CID embed rewrites the `<img src>` to `cid:{sha}` and
/// `collect_inline_image_refs` returns the SHA so the
/// publisher knows to attach the bytes as a related part.
#[test]
fn cid_embed_rewrites_src_and_collects_ref() {
let sha = "a".repeat(64);
let url = format!("https://h.example/t/asset/tenant1/{sha}.png");
let blocks = vec![EmailBlock::Image {
url: url.clone(),
alt: "logo".into(),
width: None,
align: TextAlign::default(),
link_url: None,
embed: ImageEmbed::Cid,
}];
let html = render_template(&blocks, &HashMap::new());
// Public URL gone; cid: ref present.
assert!(html.contains(&format!("cid:{sha}")), "cid src missing in: {html}");
assert!(!html.contains(&url), "url should not appear when CID");
let refs = collect_inline_image_refs(&blocks, &HashMap::new());
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].sha256, sha);
assert_eq!(refs[0].content_id, sha);
assert_eq!(refs[0].url_extension_hint.as_deref(), Some("png"));
}
/// CID inside a TwoColumn still gets collected.
#[test]
fn cid_embed_in_twocolumn_collected() {
let sha = "b".repeat(64);
let url = format!("https://h.example/t/asset/t/{sha}.jpg");
let blocks = vec![EmailBlock::TwoColumn {
left: vec![EmailBlock::Image {
url,
alt: "left".into(),
width: None,
align: TextAlign::default(),
link_url: None,
embed: ImageEmbed::Cid,
}],
right: vec![],
}];
let refs = collect_inline_image_refs(&blocks, &HashMap::new());
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].sha256, sha);
}
fn image_has_alt_fallback_styling() {
let blocks = vec![EmailBlock::Image {
url: "https://x.com/banner.png".into(),
alt: "company logo".into(),
width: Some(300),
align: TextAlign::default(),
link_url: None,
embed: ImageEmbed::Url,
}];
let out = render_template(&blocks, &HashMap::new());
// border:0 kills Outlook's blue link-image border.
assert!(out.contains("border:0"));
// display:block kills the 4-px baseline gap.
assert!(out.contains("display:block"));
// Alt-text styling props that propagate to the
// blocked-image placeholder.
assert!(out.contains("font-style:italic"));
assert!(out.contains("font-size:14px"));
}
#[test]
/// Row renders N columns side-by-side with normalised
/// widths. Two columns at 60/40 stay 60/40; weird sums
/// round to a clean 100.
#[test]
fn row_renders_columns_with_widths() {
let blocks = vec![EmailBlock::Row {
columns: vec![
Column {
blocks: vec![EmailBlock::Heading {
text: "L".into(),
text_html: None,
level: 1,
color: None,
align: TextAlign::default(),
}],
width_pct: 60,
background: None,
background_image: None,
},
Column {
blocks: vec![EmailBlock::Paragraph {
text: "R".into(),
text_html: None,
color: None,
align: TextAlign::default(),
font_size: 16,
}],
width_pct: 40,
background: None,
background_image: None,
},
],
background: None,
background_image: None,
}];
let html = render_template(&blocks, &HashMap::new());
assert!(html.contains(r#"width="60%""#));
assert!(html.contains(r#"width="40%""#));
assert!(html.contains("<h1"));
assert!(html.contains("<p"));
// Mobile-stacking class lands on every column TD.
assert!(html.matches("email-col").count() >= 2);
}
#[test]
fn row_widths_normalize_to_100() {
let widths = normalize_column_widths(&[
Column { blocks: vec![], width_pct: 30, background: None, background_image: None },
Column { blocks: vec![], width_pct: 30, background: None, background_image: None },
Column { blocks: vec![], width_pct: 30, background: None, background_image: None },
]);
// 30+30+30=90 → scales then redistributes the 10 to widest.
let sum: u32 = widths.iter().map(|&w| w as u32).sum();
assert_eq!(sum, 100);
}
#[test]
fn row_empty_renders_placeholder() {
let blocks = vec![EmailBlock::Row { columns: vec![], background: None, background_image: None }];
let html = render_template(&blocks, &HashMap::new());
// A row with no columns still emits a <tr> so a fresh
// canvas drop has visual feedback.
assert!(html.matches("<tr>").count() >= 1);
}
#[test]
fn migrate_legacy_wraps_flat_blocks_in_rows() {
let legacy = vec![
EmailBlock::Heading {
text: "Hi".into(),
text_html: None,
level: 1,
color: None,
align: TextAlign::default(),
},
EmailBlock::Paragraph {
text: "p".into(),
text_html: None,
color: None,
align: TextAlign::default(),
font_size: 16,
},
];
let migrated = migrate_legacy_to_rows(legacy);
assert_eq!(migrated.len(), 2);
for b in &migrated {
match b {
EmailBlock::Row { columns, .. } => {
assert_eq!(columns.len(), 1);
assert_eq!(columns[0].width_pct, 100);
}
_ => panic!("expected Row, got {b:?}"),
}
}
}
#[test]
fn migrate_legacy_rewrites_twocolumn_to_row() {
let legacy = vec![EmailBlock::TwoColumn {
left: vec![EmailBlock::Heading {
text: "L".into(),
text_html: None,
level: 1,
color: None,
align: TextAlign::default(),
}],
right: vec![EmailBlock::Paragraph {
text: "R".into(),
text_html: None,
color: None,
align: TextAlign::default(),
font_size: 16,
}],
}];
let migrated = migrate_legacy_to_rows(legacy);
assert_eq!(migrated.len(), 1);
match &migrated[0] {
EmailBlock::Row { columns, .. } => {
assert_eq!(columns.len(), 2);
assert_eq!(columns[0].width_pct, 50);
assert_eq!(columns[1].width_pct, 50);
}
other => panic!("expected Row, got {other:?}"),
}
}
#[test]
fn migrate_legacy_idempotent_on_rows() {
let already = vec![EmailBlock::Row {
columns: vec![Column {
blocks: vec![EmailBlock::Spacer { height_px: 20 }],
width_pct: 100,
background: None,
background_image: None,
}],
background: None,
background_image: None,
}];
let migrated = migrate_legacy_to_rows(already.clone());
assert_eq!(migrated, already);
}
#[test]
fn cid_image_inside_row_collected() {
let sha = "c".repeat(64);
let url = format!("https://h.example/t/asset/t/{sha}.png");
let blocks = vec![EmailBlock::Row {
columns: vec![Column {
blocks: vec![EmailBlock::Image {
url,
alt: "logo".into(),
width: None,
align: TextAlign::default(),
link_url: None,
embed: ImageEmbed::Cid,
}],
width_pct: 100,
background: None,
background_image: None,
}],
background: None,
background_image: None,
}];
let refs = collect_inline_image_refs(&blocks, &HashMap::new());
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].sha256, sha);
}
#[test]
fn json_round_trip_preserves_blocks() {
let blocks = vec![
EmailBlock::Heading {
text: "Title".into(),
text_html: None,
level: 1,
color: Some("#000".into()),
align: TextAlign::Center,
},
EmailBlock::Spacer { height_px: 16 },
EmailBlock::Paragraph {
text: "Body".into(),
text_html: None,
color: None,
align: TextAlign::Left,
font_size: 14,
},
];
let json = serde_json::to_string(&blocks).unwrap();
let back: Vec<EmailBlock> = serde_json::from_str(&json).unwrap();
assert_eq!(blocks, back);
}
// sanitize_url / sanitize_color tests moved to
// crates/sanitize once the helpers were lifted to the
// framework. The integration tests below still exercise
// them via render_template_with_page_bg.
#[test]
fn page_background_image_renders_on_body() {
let html = render_template_with_page_bg(
&[],
&HashMap::new(),
Some("#fafafa"),
Some("https://cdn.example/page-bg.jpg"),
);
// CSS shorthand on <body> + the wrapper table.
assert!(html.contains("background-image:url(https://cdn.example/page-bg.jpg)"));
assert!(html.contains("background-size:cover"));
// Legacy HTML attr for Outlook desktop / clients that
// ignore CSS background-image.
assert!(html.contains(r#"background="https://cdn.example/page-bg.jpg""#));
// Colour fallback still emitted.
assert!(html.contains("background:#fafafa"));
}
#[test]
fn row_background_image_renders_on_outer_td() {
let blocks = vec![EmailBlock::Row {
columns: vec![Column {
blocks: vec![],
width_pct: 100,
background: None,
background_image: None,
}],
background: Some("#eef".into()),
background_image: Some("https://cdn.example/row-bg.jpg".into()),
}];
let html = render_template(&blocks, &HashMap::new());
assert!(html.contains("background-color:#eef"));
assert!(html.contains("background-image:url(https://cdn.example/row-bg.jpg)"));
assert!(html.contains(r#"background="https://cdn.example/row-bg.jpg""#));
}
#[test]
fn column_background_image_renders_on_column_td() {
let blocks = vec![EmailBlock::Row {
columns: vec![Column {
blocks: vec![],
width_pct: 100,
background: None,
background_image: Some("https://cdn.example/col-bg.jpg".into()),
}],
background: None,
background_image: None,
}];
let html = render_template(&blocks, &HashMap::new());
assert!(html.contains("background-image:url(https://cdn.example/col-bg.jpg)"));
}
#[test]
fn malicious_background_image_dropped() {
// sanitize_url rejects javascript: → no background-image
// emitted at all. Colour passes through.
let blocks = vec![EmailBlock::Row {
columns: vec![],
background: Some("#fff".into()),
background_image: Some("javascript:alert(1)".into()),
}];
let html = render_template(&blocks, &HashMap::new());
assert!(!html.contains("javascript:"));
assert!(!html.contains("background-image"));
}
}