use cratefield_core::{Config, Venture};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const THEME_CONFIG_KEY: &str = "MAIL_THEME";
pub const DATA_THEME: &str = "theme";
pub const DATA_THEME_OVERRIDE: &str = "theme_override";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct Palette {
pub bg: String,
pub card: String,
pub border: String,
pub ink: String,
pub text: String,
pub muted: String,
pub accent: String,
pub button: String,
pub button_text: String,
pub code_bg: String,
}
macro_rules! setters {
($ty:ty { $($field:ident),* $(,)? }) => {
impl $ty {
$(
#[doc = concat!("Sets `", stringify!($field), "`.")]
#[must_use]
pub fn $field(mut self, value: impl Into<String>) -> Self {
self.$field = value.into();
self
}
)*
}
};
}
setters!(Palette {
bg,
card,
border,
ink,
text,
muted,
accent,
button,
button_text,
code_bg
});
impl Palette {
pub fn neutral_light() -> Self {
Self {
bg: "#F4F4F5".to_owned(),
card: "#FFFFFF".to_owned(),
border: "#E4E4E7".to_owned(),
ink: "#18181B".to_owned(),
text: "#3F3F46".to_owned(),
muted: "#71717A".to_owned(),
accent: "#18181B".to_owned(),
button: "#18181B".to_owned(),
button_text: "#FFFFFF".to_owned(),
code_bg: "#F4F4F5".to_owned(),
}
}
pub fn neutral_dark() -> Self {
Self {
bg: "#09090B".to_owned(),
card: "#18181B".to_owned(),
border: "#27272A".to_owned(),
ink: "#FAFAFA".to_owned(),
text: "#D4D4D8".to_owned(),
muted: "#A1A1AA".to_owned(),
accent: "#FAFAFA".to_owned(),
button: "#FAFAFA".to_owned(),
button_text: "#18181B".to_owned(),
code_bg: "#09090B".to_owned(),
}
}
pub(crate) fn sanitized(&self, fallback: &Palette) -> Palette {
let pick = |value: &str, default: &str| {
if is_hex_colour(value) {
value.to_owned()
} else {
default.to_owned()
}
};
Palette {
bg: pick(&self.bg, &fallback.bg),
card: pick(&self.card, &fallback.card),
border: pick(&self.border, &fallback.border),
ink: pick(&self.ink, &fallback.ink),
text: pick(&self.text, &fallback.text),
muted: pick(&self.muted, &fallback.muted),
accent: pick(&self.accent, &fallback.accent),
button: pick(&self.button, &fallback.button),
button_text: pick(&self.button_text, &fallback.button_text),
code_bg: pick(&self.code_bg, &fallback.code_bg),
}
}
}
impl Default for Palette {
fn default() -> Self {
Self::neutral_light()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct MailTheme {
pub brand_name: String,
pub wordmark: String,
pub site_url: String,
pub logo_url: Option<String>,
pub logo_alt: String,
pub logo_width: u16,
pub logo_height: u16,
pub logo_bg: Option<String>,
pub light: Palette,
pub dark: Palette,
pub font: String,
pub mono: String,
pub display_font: String,
pub header_bg: Option<String>,
pub header_text: Option<String>,
pub radius: u8,
pub button_radius: u8,
pub footer: Vec<String>,
pub contact: Option<String>,
}
setters!(MailTheme {
brand_name,
wordmark,
site_url,
logo_alt,
font,
mono
});
impl Default for MailTheme {
fn default() -> Self {
Self {
brand_name: String::new(),
wordmark: String::new(),
site_url: String::new(),
logo_url: None,
logo_alt: String::new(),
logo_width: 32,
logo_height: 32,
logo_bg: None,
light: Palette::neutral_light(),
dark: Palette::neutral_dark(),
font: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif"
.to_owned(),
mono: "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace".to_owned(),
display_font: String::new(),
header_bg: None,
header_text: None,
radius: 12,
button_radius: 8,
footer: Vec::new(),
contact: None,
}
}
}
impl MailTheme {
pub fn new(brand_name: impl Into<String>, site_url: impl Into<String>) -> Self {
Self {
brand_name: brand_name.into(),
site_url: site_url.into(),
..Self::default()
}
}
#[must_use]
pub fn logo(mut self, url: impl Into<String>, alt: impl Into<String>) -> Self {
self.logo_url = Some(url.into());
self.logo_alt = alt.into();
self
}
#[must_use]
pub fn logo_size(mut self, width: u16, height: u16) -> Self {
self.logo_width = width;
self.logo_height = height;
self
}
#[must_use]
pub fn logo_tile(mut self, colour: impl Into<String>) -> Self {
self.logo_bg = Some(colour.into());
self
}
#[must_use]
pub fn light(mut self, palette: Palette) -> Self {
self.light = palette;
self
}
#[must_use]
pub fn dark(mut self, palette: Palette) -> Self {
self.dark = palette;
self
}
#[must_use]
pub fn radii(mut self, card: u8, button: u8) -> Self {
self.radius = card;
self.button_radius = button;
self
}
#[must_use]
pub fn header_band(mut self, background: impl Into<String>, text: impl Into<String>) -> Self {
self.header_bg = Some(background.into());
self.header_text = Some(text.into());
self
}
#[must_use]
pub fn display_font(mut self, stack: impl Into<String>) -> Self {
self.display_font = stack.into();
self
}
#[must_use]
pub fn footer_line(mut self, line: impl Into<String>) -> Self {
self.footer.push(line.into());
self
}
#[must_use]
pub fn contact(mut self, address: impl Into<String>) -> Self {
self.contact = Some(address.into());
self
}
pub fn name_or<'a>(&'a self, fallback: &'a str) -> &'a str {
if self.brand_name.trim().is_empty() {
fallback
} else {
&self.brand_name
}
}
pub fn cratefield() -> Self {
Self::new("Cratefield", "https://cratefield.com")
.wordmark("Cratefield")
.logo(
"https://cratefield.com/assets/email/logo-64.png",
"Cratefield",
)
.light(Palette {
bg: "#F4F3EF".to_owned(),
card: "#FFFFFF".to_owned(),
border: "#E2E0DA".to_owned(),
ink: "#0A0A0B".to_owned(),
text: "#3A3A3F".to_owned(),
muted: "#6B6B70".to_owned(),
accent: "#3A5BEF".to_owned(),
button: "#4C6FFF".to_owned(),
button_text: "#0A0A0B".to_owned(),
code_bg: "#F4F3EF".to_owned(),
})
.dark(Palette {
bg: "#0A0A0B".to_owned(),
card: "#0E0E10".to_owned(),
border: "#2A2A2E".to_owned(),
ink: "#EDEBE6".to_owned(),
text: "#A9A8A5".to_owned(),
muted: "#8A8A8E".to_owned(),
accent: "#8FA3FF".to_owned(),
button: "#4C6FFF".to_owned(),
button_text: "#0A0A0B".to_owned(),
code_bg: "#141416".to_owned(),
})
.font("Archivo,'Helvetica Neue',Helvetica,Arial,system-ui,sans-serif")
.mono("'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace")
.radii(0, 0)
.contact("hello@cratefield.com")
}
pub fn for_venture(venture: &Venture) -> Self {
let mut theme = Self::new(venture.name.clone(), venture.public_url.clone());
theme.apply_brand(&venture.brand);
theme
}
fn apply_brand(&mut self, brand: &cratefield_core::Brand) {
if is_hex_colour(&brand.accent) {
self.light.accent.clone_from(&brand.accent);
self.dark.accent.clone_from(&brand.accent);
}
self.logo_url.clone_from(&brand.logo_url);
if let Some(footer) = &brand.footer {
self.footer.push(footer.clone());
}
}
pub fn from_config(venture: &Venture, config: &dyn Config) -> Self {
let base = Self::for_venture(venture);
match config_override(config) {
Some(overrides) => base.merged(&overrides),
None => base,
}
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
#[must_use]
pub fn merged(&self, overrides: &Value) -> Self {
if !overrides.is_object() {
return self.clone();
}
let Ok(mut base) = serde_json::to_value(self) else {
return self.clone();
};
merge(&mut base, overrides);
match serde_json::from_value(base) {
Ok(theme) => theme,
Err(error) => {
tracing::warn!(%error, "MAIL_THEME does not fit the theme; ignoring it");
self.clone()
}
}
}
pub(crate) fn sanitized(&self) -> Self {
let mut theme = self.clone();
theme.light = self.light.sanitized(&Palette::neutral_light());
theme.dark = self.dark.sanitized(&Palette::neutral_dark());
let defaults = Self::default();
theme.font = font_stack(&self.font, &defaults.font);
theme.mono = font_stack(&self.mono, &defaults.mono);
theme.display_font = font_stack(&self.display_font, &theme.font);
theme.header_bg = self.header_bg.clone().filter(|c| is_hex_colour(c));
theme.header_text = self.header_text.clone().filter(|c| is_hex_colour(c));
theme.logo_url = self.logo_url.clone().filter(|url| is_web_url(url));
theme.logo_bg = self.logo_bg.clone().filter(|c| is_hex_colour(c));
theme.logo_width = self.logo_width.clamp(8, 280);
theme.logo_height = self.logo_height.clamp(8, 120);
theme.radius = self.radius.min(32);
theme.button_radius = self.button_radius.min(32);
self.site_url
.trim_end_matches('/')
.clone_into(&mut theme.site_url);
if !is_web_url(&theme.site_url) {
theme.site_url = String::new();
}
theme
}
}
pub fn attach_theme(data: &mut Value, venture: &Venture, config: &dyn Config) {
let theme = MailTheme::from_config(venture, config);
let overrides = config_override(config);
if let Value::Object(map) = data {
if let Ok(theme) = serde_json::to_value(theme) {
map.insert(DATA_THEME.to_owned(), theme);
}
if let Some(overrides) = overrides {
map.insert(DATA_THEME_OVERRIDE.to_owned(), overrides);
}
}
}
pub fn theme_for_template(composed: Option<&MailTheme>, data: &Value) -> MailTheme {
if let Some(theme) = composed {
return match data.get(DATA_THEME_OVERRIDE) {
Some(overrides) => theme.merged(overrides),
None => theme.clone(),
};
}
if let Some(theme) = data
.get(DATA_THEME)
.and_then(|theme| serde_json::from_value(theme.clone()).ok())
{
return theme;
}
let name = data
.get("venture")
.and_then(Value::as_str)
.unwrap_or_default();
let mut theme = MailTheme::new(name, "");
if let Some(brand) = data
.get("brand")
.and_then(|brand| serde_json::from_value::<cratefield_core::Brand>(brand.clone()).ok())
{
theme.apply_brand(&brand);
}
theme
}
fn config_override(config: &dyn Config) -> Option<Value> {
let raw = config.get(THEME_CONFIG_KEY)?;
if raw.trim().is_empty() {
return None;
}
match serde_json::from_str::<Value>(&raw) {
Ok(value) if value.is_object() => Some(value),
_ => {
tracing::warn!("MAIL_THEME is not a JSON object; ignoring it");
None
}
}
}
fn merge(base: &mut Value, overrides: &Value) {
match (base, overrides) {
(Value::Object(base), Value::Object(overrides)) => {
for (key, value) in overrides {
match base.get_mut(key) {
Some(slot) if slot.is_object() && value.is_object() => merge(slot, value),
_ => {
base.insert(key.clone(), value.clone());
}
}
}
}
(base, overrides) => *base = overrides.clone(),
}
}
pub(crate) fn is_hex_colour(value: &str) -> bool {
let Some(hex) = value.strip_prefix('#') else {
return false;
};
matches!(hex.len(), 3 | 4 | 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit())
}
pub(crate) fn is_web_url(value: &str) -> bool {
let lower = value.trim().to_ascii_lowercase();
(lower.starts_with("https://") || lower.starts_with("http://")) && lower.len() > 8
}
fn font_stack(value: &str, default: &str) -> String {
let cleaned: String = value
.chars()
.map(|c| if c == '"' { '\'' } else { c })
.filter(|c| c.is_alphanumeric() || matches!(c, ' ' | ',' | '-' | '.' | '\'' | '_'))
.collect();
if cleaned.trim().is_empty() {
default.to_owned()
} else {
cleaned
}
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::{Brand, MapConfig};
use serde_json::json;
fn venture() -> Venture {
Venture::new("acme", "acme.test").brand(Brand {
accent: "#123456".to_owned(),
logo_url: Some("https://acme.test/logo.png".to_owned()),
footer: Some("Acme, 1 Road".to_owned()),
})
}
#[test]
fn the_venture_default_carries_its_brand() {
let theme = MailTheme::for_venture(&venture());
assert_eq!(theme.brand_name, "acme");
assert_eq!(theme.site_url, "https://acme.test");
assert_eq!(theme.light.accent, "#123456");
assert_eq!(
theme.logo_url.as_deref(),
Some("https://acme.test/logo.png")
);
assert_eq!(theme.footer, vec!["Acme, 1 Road".to_owned()]);
}
#[test]
fn config_merges_field_by_field_and_a_bad_value_changes_nothing() {
let cfg = MapConfig::from_pairs([(
THEME_CONFIG_KEY,
r##"{"brand_name":"Acme","light":{"button":"#FF0000"}}"##,
)]);
let theme = MailTheme::from_config(&venture(), &cfg);
assert_eq!(theme.brand_name, "Acme");
assert_eq!(theme.light.button, "#FF0000");
assert_eq!(theme.light.accent, "#123456");
for raw in ["not json", "[1,2]", r#"{"radius":"big"}"#] {
let cfg = MapConfig::from_pairs([(THEME_CONFIG_KEY, raw)]);
assert_eq!(
MailTheme::from_config(&venture(), &cfg),
MailTheme::for_venture(&venture()),
"{raw}"
);
}
}
#[test]
fn a_composed_theme_wins_and_the_deployment_override_still_applies() {
let composed = MailTheme::new("Composed", "https://composed.test");
let cfg =
MapConfig::from_pairs([(THEME_CONFIG_KEY, r##"{"light":{"accent":"#00FF00"}}"##)]);
let mut data = json!({ "venture": "acme" });
attach_theme(&mut data, &venture(), &cfg);
let from_data = theme_for_template(None, &data);
assert_eq!(from_data.brand_name, "acme");
assert_eq!(from_data.light.accent, "#00FF00");
let themed = theme_for_template(Some(&composed), &data);
assert_eq!(themed.brand_name, "Composed");
assert_eq!(themed.light.accent, "#00FF00");
assert_eq!(theme_for_template(None, &json!({})), MailTheme::default());
let legacy = theme_for_template(
None,
&json!({ "venture": "acme", "brand": { "accent": "#ABCDEF", "logo_url": null, "footer": "Acme, 1 Road" } }),
);
assert_eq!(legacy.brand_name, "acme");
assert_eq!(legacy.light.accent, "#ABCDEF");
assert_eq!(legacy.footer, vec!["Acme, 1 Road".to_owned()]);
}
#[test]
fn sanitizing_refuses_css_injection_through_a_theme() {
let theme = MailTheme::new("x", "javascript:alert(1)")
.light(Palette::neutral_light().bg("red;background:url(https://evil.test/p)"))
.font("x\";background:url(evil)")
.logo("javascript:alert(1)", "x")
.logo_tile("expression(alert(1))")
.sanitized();
assert_eq!(theme.light.bg, Palette::neutral_light().bg);
assert!(
!theme.font.contains(';') && !theme.font.contains('"') && !theme.font.contains('(')
);
assert_eq!(theme.logo_url, None);
assert_eq!(theme.logo_bg, None);
assert_eq!(theme.site_url, "");
let band = MailTheme::new("x", "https://x.test")
.header_band("url(evil)", "#fff;color:red")
.display_font("x\";background:url(evil)")
.sanitized();
assert_eq!(band.header_bg, None);
assert_eq!(band.header_text, None);
assert!(!band.display_font.contains(';') && !band.display_font.contains('('));
}
#[test]
fn a_header_band_and_a_display_font_come_from_config() {
let cfg = MapConfig::from_pairs([(
THEME_CONFIG_KEY,
r##"{"header_bg":"#0C0C0D","header_text":"#ECEAE4","display_font":"'Archivo Black',Arial,sans-serif"}"##,
)]);
let theme = MailTheme::from_config(&venture(), &cfg).sanitized();
assert_eq!(theme.header_bg.as_deref(), Some("#0C0C0D"));
assert_eq!(theme.header_text.as_deref(), Some("#ECEAE4"));
assert_eq!(theme.display_font, "'Archivo Black',Arial,sans-serif");
let plain = MailTheme::for_venture(&venture()).sanitized();
assert_eq!(plain.display_font, plain.font);
assert_eq!(plain.header_bg, None);
}
#[test]
fn hex_colours() {
for ok in ["#fff", "#FFFF", "#a1b2c3", "#a1b2c3d4"] {
assert!(is_hex_colour(ok), "{ok}");
}
for bad in ["fff", "#ggg", "#12345", "red", "#fff;"] {
assert!(!is_hex_colour(bad), "{bad}");
}
}
}