use regex::Regex; use rusqlite::types::{FromSql, FromSqlResult, ToSql, ToSqlOutput, ValueRef}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::sync::LazyLock; static HEX_COLOR_RE: LazyLock = LazyLock::new(|| Regex::new(r#"#[0-9a-fA-F]{3,8}\b"#).unwrap()); static RGB_COLOR_RE: LazyLock = LazyLock::new(|| Regex::new(r#"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)"#).unwrap()); static RGBA_COLOR_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+%?)\s*\)"#) .unwrap() }); static RGBA_MODERN_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"rgb\s*\(\s*(\d+)\s+(\d+)\s+(\d+)\s*/\s*([0-9]*\.?[0-9]+%?)\s*\)"#).unwrap() }); static STYLE_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(?is)]*>(.*?)"#).unwrap()); static STYLE_ATTR_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)style\s*=\s*(?:"([^"]+)"|'([^']+)')"#).unwrap()); static BGCOLOR_ATTR_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)bgcolor\s*=\s*["']([^"']+)["']"#).unwrap()); static DARK_MEDIA_QUERY_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?is)@media[^{]*prefers-color-scheme\s*:\s*dark[^{]*\{(.+?\})\s*\}"#).unwrap() }); static BODY_CSS_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)body\s*\{([^}]+)\}"#).unwrap()); static CLASS_CSS_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)\.[a-zA-Z][a-zA-Z0-9_-]*\s*\{([^}]+)\}"#).unwrap()); static ELEM_CSS_RE: LazyLock = LazyLock::new(|| { Regex::new(r#"(?i)(body|table|td|div)\s*(?:,\s*#\w+)?\s*\{([^}]+)\}"#).unwrap() }); static BG_PROP_RE: LazyLock = LazyLock::new(|| Regex::new(r#"(?i)background(?:-color)?\s*:\s*([^;}"']+)"#).unwrap()); static BODY_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); static TABLE_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); static TD_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); static DIV_TAG_RE: LazyLock = LazyLock::new(|| Regex::new(r#"]*>"#).unwrap()); static NAMED_COLOR_REGEXES: LazyLock> = LazyLock::new(|| { NAMED_COLORS .iter() .map(|(name, _)| { let pattern = format!(r#"(?i)\b{name}\b"#); (*name, Regex::new(&pattern).unwrap()) }) .collect() }); type NamedColorList = Vec<(&'static str, (u8, u8, u8))>; static NAMED_COLORS: LazyLock = LazyLock::new(|| { vec![ ("black", (0, 0, 0)), ("white", (255, 255, 255)), ("red", (255, 0, 0)), ("green", (0, 128, 0)), ("blue", (0, 0, 255)), ("yellow", (255, 255, 0)), ("navy", (0, 0, 128)), ("gray", (128, 128, 128)), ("grey", (128, 128, 128)), ("silver", (192, 192, 192)), ("maroon", (128, 0, 0)), ("purple", (128, 0, 128)), ("teal", (0, 128, 128)), ("olive", (128, 128, 0)), ("aqua", (0, 255, 255)), ("fuchsia", (255, 0, 255)), ("lime", (0, 255, 0)), ("darkgray", (169, 169, 169)), ("darkgrey", (169, 169, 169)), ("lightgray", (211, 211, 211)), ("lightgrey", (211, 211, 211)), ("whitesmoke", (245, 245, 245)), ("ghostwhite", (248, 248, 255)), ("floralwhite", (255, 250, 240)), ("linen", (250, 240, 230)), ("cornsilk", (255, 248, 220)), ("gainsboro", (220, 220, 220)), ] }); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EmailTheme { Light = 0, Dark = 1, Transparent = 2, Adaptive = 3, } impl EmailTheme { pub fn as_i32(self) -> i32 { self as i32 } } impl FromSql for EmailTheme { fn column_result(value: ValueRef<'_>) -> FromSqlResult { match value.as_i64()? { 0 => Ok(EmailTheme::Light), 1 => Ok(EmailTheme::Dark), 2 => Ok(EmailTheme::Transparent), 3 => Ok(EmailTheme::Adaptive), _ => Err(rusqlite::types::FromSqlError::InvalidType), } } } impl ToSql for EmailTheme { fn to_sql(&self) -> rusqlite::Result> { Ok(ToSqlOutput::from(*self as i32)) } } pub fn detect_email_theme(html: &str) -> Option { if html.trim().is_empty() { return None; } let decoded = decode_quoted_printable(html); if has_dark_mode_media_query(&decoded) { return Some(EmailTheme::Adaptive); } let backgrounds = extract_background_colors(&decoded); if backgrounds.is_empty() { return Some(EmailTheme::Transparent); } classify_by_luminance_groups(&backgrounds) } fn has_dark_mode_media_query(html: &str) -> bool { let lower = html.to_lowercase(); for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) { let block = &cap[1]; if block.contains("background") { return true; } } false } fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option { let mut light_weight: f32 = 0.0; let mut dark_weight: f32 = 0.0; let mut total_weight: f32 = 0.0; let mut has_body_background = false; for bg in colors { let luminance = calculate_luminance(&bg.color); total_weight += bg.element_weight; if bg.element_weight >= 1.0 { has_body_background = true; } if luminance < 0.5 { dark_weight += bg.element_weight; } else { light_weight += bg.element_weight; } } if total_weight == 0.0 { return Some(EmailTheme::Transparent); } let light_coverage = light_weight / total_weight; let dark_coverage = dark_weight / total_weight; if light_coverage >= 0.5 { if !has_body_background && is_only_white_wrapper(colors) { return Some(EmailTheme::Transparent); } Some(EmailTheme::Light) } else if dark_coverage >= 0.5 { if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background { Some(EmailTheme::Transparent) } else { Some(EmailTheme::Dark) } } else { Some(EmailTheme::Transparent) } } fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool { if colors.is_empty() { return false; } for bg in colors { if bg.element_weight >= 1.0 { return false; } let c = bg.color.trim().to_lowercase(); if c != "#ffffff" && c != "white" { return false; } } true } fn decode_quoted_printable(html: &str) -> String { let mut result = html.to_string(); result = result.replace("=3D", "="); result = result.replace("=3d", "="); result = result.replace("=20", " "); result = result.replace("=\r\n", ""); result = result.replace("=\n", ""); result } #[derive(Debug, Clone)] struct BackgroundColor { color: String, element_weight: f32, } fn extract_background_colors(html: &str) -> Vec { let mut colors = Vec::new(); extract_inline_style_colors(html, &mut colors); extract_style_tag_colors(html, &mut colors); extract_bgcolor_attrs(html, &mut colors); deduplicate_colors(&mut colors); colors } fn deduplicate_colors(colors: &mut Vec) { if colors.len() <= 1 { return; } let mut seen: HashSet = HashSet::new(); colors.retain(|bg| { let key = format!("{}:{:.1}", bg.color, bg.element_weight); seen.insert(key) }); } fn extract_inline_style_colors(html: &str, colors: &mut Vec) { let element_weights: &[(&LazyLock, f32)] = &[ (&BODY_TAG_RE, 1.0), (&TABLE_TAG_RE, 0.8), (&TD_TAG_RE, 0.6), (&DIV_TAG_RE, 0.4), ]; for (tag_re, weight) in element_weights { for m in tag_re.find_iter(html) { let tag_content = m.as_str(); let extracted = extract_colors_from_style(tag_content); for ec in extracted { if ec.alpha < 0.1 { continue; } if !is_transparent(&ec.color) { let normalized = normalize_color(&ec.color); if !normalized.is_empty() { colors.push(BackgroundColor { color: normalized, element_weight: *weight * ec.alpha, }); } } } } } } fn extract_style_tag_colors(html: &str, colors: &mut Vec) { for cap in STYLE_TAG_RE.captures_iter(html) { let style_content = &cap[1]; let lower = style_content.to_lowercase(); if !lower.contains("background") { continue; } if let Some(body_cap) = BODY_CSS_RE.captures(style_content) { let body_styles = &body_cap[1]; if body_styles.to_lowercase().contains("background") { extract_all_colors_from_css(body_styles, 1.0, colors); } } for class_cap in CLASS_CSS_RE.captures_iter(style_content) { let class_styles = &class_cap[1]; if class_styles.to_lowercase().contains("background") { extract_all_colors_from_css(class_styles, 0.3, colors); } } for elem_cap in ELEM_CSS_RE.captures_iter(style_content) { let tag = elem_cap[1].to_lowercase(); let elem_styles = &elem_cap[2]; if elem_styles.to_lowercase().contains("background") { let weight = match tag.as_str() { "body" => 1.0, "table" => 0.8, "td" => 0.6, "div" => 0.4, _ => 0.3, }; extract_all_colors_from_css(elem_styles, weight, colors); } } } } fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec) { let lower = css.to_lowercase(); for bg_cap in BG_PROP_RE.captures_iter(&lower) { let bg_value = bg_cap[1].trim(); for hex_cap in HEX_COLOR_RE.captures_iter(bg_value) { let color = normalize_color(&hex_cap[0]); if !is_transparent(&color) && !color.is_empty() { colors.push(BackgroundColor { color, element_weight: weight, }); } } for rgb_cap in RGB_COLOR_RE.captures_iter(bg_value) { let color = format!("rgb({}, {}, {})", &rgb_cap[1], &rgb_cap[2], &rgb_cap[3]); if !is_transparent(&color) { colors.push(BackgroundColor { color, element_weight: weight, }); } } for rgba_cap in RGBA_COLOR_RE.captures_iter(bg_value) { let alpha = parse_alpha_str(&rgba_cap[4]); if alpha < 0.1 { continue; } let color = format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]); if !is_transparent(&color) { colors.push(BackgroundColor { color, element_weight: weight * alpha, }); } } for rgba_cap in RGBA_MODERN_RE.captures_iter(bg_value) { let alpha = parse_alpha_str(&rgba_cap[4]); if alpha < 0.1 { continue; } let color = format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]); if !is_transparent(&color) { colors.push(BackgroundColor { color, element_weight: weight * alpha, }); } } for (name, re) in NAMED_COLOR_REGEXES.iter() { if re.is_match(bg_value) { colors.push(BackgroundColor { color: name.to_string(), element_weight: weight, }); break; } } } } fn extract_bgcolor_attrs(html: &str, colors: &mut Vec) { let element_weights: &[(&LazyLock, f32)] = &[(&BODY_TAG_RE, 1.0), (&TABLE_TAG_RE, 0.8), (&TD_TAG_RE, 0.6)]; for (tag_re, weight) in element_weights { for m in tag_re.find_iter(html) { let tag_content = m.as_str(); if let Some(cap) = BGCOLOR_ATTR_RE.captures(tag_content) { let color = cap[1].to_string(); if !is_transparent(&color) { let normalized = normalize_color(&color); if !normalized.is_empty() { colors.push(BackgroundColor { color: normalized, element_weight: *weight, }); } } } } } } struct ExtractedColor { color: String, alpha: f32, } fn extract_colors_from_style(tag_content: &str) -> Vec { let mut colors = Vec::new(); if let Some(cap) = STYLE_ATTR_RE.captures(tag_content) { let style_value = cap .get(1) .or_else(|| cap.get(2)) .map(|m| m.as_str().to_lowercase()) .unwrap_or_default(); for bg_cap in BG_PROP_RE.captures_iter(&style_value) { let bg_value = bg_cap[1].trim(); for hex_cap in HEX_COLOR_RE.captures_iter(bg_value) { let raw = hex_cap[0].to_string(); let alpha = parse_hex_alpha(&raw); colors.push(ExtractedColor { color: raw, alpha }); } for rgb_cap in RGB_COLOR_RE.captures_iter(bg_value) { colors.push(ExtractedColor { color: format!("rgb({}, {}, {})", &rgb_cap[1], &rgb_cap[2], &rgb_cap[3]), alpha: 1.0, }); } for rgba_cap in RGBA_COLOR_RE.captures_iter(bg_value) { let alpha = parse_alpha_str(&rgba_cap[4]); colors.push(ExtractedColor { color: format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]), alpha, }); } for rgba_cap in RGBA_MODERN_RE.captures_iter(bg_value) { let alpha = parse_alpha_str(&rgba_cap[4]); colors.push(ExtractedColor { color: format!("rgb({}, {}, {})", &rgba_cap[1], &rgba_cap[2], &rgba_cap[3]), alpha, }); } for (name, re) in NAMED_COLOR_REGEXES.iter() { if re.is_match(bg_value) { colors.push(ExtractedColor { color: name.to_string(), alpha: 1.0, }); break; } } } } colors } fn parse_hex_alpha(color: &str) -> f32 { let c = color.trim().to_lowercase(); if let Some(hex) = c.strip_prefix('#') { if hex.len() == 8 { return u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) as f32 / 255.0; } if hex.len() == 4 { let digit = u8::from_str_radix(&hex[3..4], 16).unwrap_or(15); return (digit * 17) as f32 / 255.0; } } 1.0 } fn parse_alpha_str(s: &str) -> f32 { if let Some(pct) = s.strip_suffix('%') { pct.parse::().unwrap_or(100.0) / 100.0 } else { s.parse::().unwrap_or(1.0) } } fn is_transparent(color: &str) -> bool { let c = color.to_lowercase(); c.contains("transparent") || c.contains("rgba(0, 0, 0, 0)") || c.contains("rgba(0,0,0,0)") || c == "initial" || c == "inherit" || c == "none" } fn normalize_color(color: &str) -> String { let c = color.trim().to_lowercase(); if let Some(hex) = c.strip_prefix('#') { match hex.len() { 3 => { let mut chars = hex.chars(); let r = chars.next().unwrap_or('0'); let g = chars.next().unwrap_or('0'); let b = chars.next().unwrap_or('0'); format!("#{r}{r}{g}{g}{b}{b}") } 6 => c, 8 => { format!("#{}", &hex[0..6]) } _ => c, } } else { c } } fn calculate_luminance(color: &str) -> f64 { let (r, g, b) = parse_color(color); (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0 } fn parse_color(color: &str) -> (u8, u8, u8) { let c = color.trim().to_lowercase(); for (name, rgb) in NAMED_COLORS.iter() { if c == *name { return *rgb; } } if let Some(hex) = c.strip_prefix('#') { let hex = hex.trim(); if hex.len() >= 6 { return ( u8::from_str_radix(&hex[0..2], 16).unwrap_or(255), u8::from_str_radix(&hex[2..4], 16).unwrap_or(255), u8::from_str_radix(&hex[4..6], 16).unwrap_or(255), ); } } if let Some(cap) = RGB_COLOR_RE.captures(&c) { return ( cap[1].parse().unwrap_or(255), cap[2].parse().unwrap_or(255), cap[3].parse().unwrap_or(255), ); } if let Some(cap) = RGBA_COLOR_RE.captures(&c) { return ( cap[1].parse().unwrap_or(255), cap[2].parse().unwrap_or(255), cap[3].parse().unwrap_or(255), ); } (255, 255, 255) } #[cfg(test)] mod tests { use super::*; #[test] fn test_detect_dark_email_by_background() { let html = r#"Dark content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark)); } #[test] fn test_detect_light_email_by_background() { let html = r#"Light content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } #[test] fn test_detect_light_email_with_table() { let html = r#"
Light
"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } #[test] fn test_detect_transparent_email() { let html = r#"Just text, no background"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] fn test_empty_html_returns_none() { assert_eq!(detect_email_theme(""), None); } #[test] fn test_luminance_calculation() { assert!(calculate_luminance("#000000") < 0.1); assert!(calculate_luminance("#ffffff") > 0.9); assert!(calculate_luminance("#101519") < 0.5); } #[test] fn test_adaptive_media_query_detection() { let html = r#"Content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Adaptive)); } #[test] fn test_luminance_grouping_mixed_light_colors() { let html = r#"
Content
Footer
"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } #[test] fn test_accent_td_not_treated_as_background() { let html = r##"
Click here
"##; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] fn test_deduplicate_bgcolor_and_style() { let html = r##"
Click
"##; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] fn test_lone_white_wrapper_table_is_transparent() { let html = r#"
Content
"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent)); } #[test] fn test_named_color_white() { let html = r#"Content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } #[test] fn test_named_color_black() { let html = r#"Content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark)); } #[test] fn test_rgb_color() { let html = r#"
Content
"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } #[test] fn test_3_char_hex() { let html = r#"Content"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Light)); } #[test] fn test_quoted_printable_decoding() { let encoded = r#""#; let decoded = decode_quoted_printable(encoded); assert!(decoded.contains("style=\"background-color:")); } #[test] fn test_detect_dark_with_style_tag() { let html = r#"Dark"#; assert_eq!(detect_email_theme(html), Some(EmailTheme::Dark)); } #[test] fn test_white_does_not_match_whitesmoke() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert_eq!(colors[0].color, "whitesmoke"); } #[test] fn test_white_does_not_match_ghostwhite() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert_eq!(colors[0].color, "ghostwhite"); } #[test] fn test_white_does_not_match_floralwhite() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert_eq!(colors[0].color, "floralwhite"); } #[test] fn test_gray_does_not_match_darkgray() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert_eq!(colors[0].color, "darkgray"); } #[test] fn test_gray_does_not_match_lightgray() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert_eq!(colors[0].color, "lightgray"); } #[test] fn test_exact_white_still_matches() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert_eq!(colors[0].color, "white"); } #[test] fn test_named_color_in_style_tag_exact_match() { let html = r#"Content"#; let colors = extract_background_colors(html); assert!( colors.iter().any(|c| c.color == "whitesmoke"), "expected whitesmoke, got: {colors:?}" ); assert!( !colors.iter().any(|c| c.color == "white"), "white should not match whitesmoke" ); } #[test] fn test_rgba_half_alpha_reduced_weight() { let html_rgba = r#"Content"#; let html_rgb = r#"Content"#; let colors_rgba = extract_background_colors(html_rgba); let colors_rgb = extract_background_colors(html_rgb); assert_eq!(colors_rgba.len(), 1); assert_eq!(colors_rgb.len(), 1); assert!( (colors_rgba[0].element_weight - 0.5).abs() < 0.01, "rgba(0,0,0,0.5) on body should have weight ~0.5, got {}", colors_rgba[0].element_weight ); assert!( (colors_rgb[0].element_weight - 1.0).abs() < 0.01, "rgb(0,0,0) on body should have weight 1.0, got {}", colors_rgb[0].element_weight ); } #[test] fn test_rgba_fully_transparent_ignored() { let html = r#"Content"#; let colors = extract_background_colors(html); assert!( colors.is_empty(), "Fully transparent rgba should produce no colors, got {colors:?}" ); } #[test] fn test_rgba_full_alpha_same_as_rgb() { let html_rgba = r#"Content"#; let html_rgb = r#"Content"#; assert_eq!(detect_email_theme(html_rgba), Some(EmailTheme::Light)); assert_eq!(detect_email_theme(html_rgb), Some(EmailTheme::Light)); let colors_rgba = extract_background_colors(html_rgba); let colors_rgb = extract_background_colors(html_rgb); assert_eq!(colors_rgba.len(), 1); assert_eq!(colors_rgb.len(), 1); assert!( (colors_rgba[0].element_weight - colors_rgb[0].element_weight).abs() < 0.01, "rgba with alpha=1.0 should have same weight as rgb" ); } #[test] fn test_rgba_in_style_tag() { let html = r#"Content"#; let colors = extract_background_colors(html); assert_eq!(colors.len(), 1); assert!( (colors[0].element_weight - 0.5).abs() < 0.01, "rgba in style tag on body should have weight ~0.5, got {}", colors[0].element_weight ); } }