1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
+1
-70
@@ -53,8 +53,6 @@ static TD_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<td\b[^>]*>"#
|
||||
|
||||
static DIV_TAG_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"<div\b[^>]*>"#).unwrap());
|
||||
|
||||
/// Compiled word-boundary regexes for each named CSS color, ensuring exact
|
||||
/// matching so that e.g. "white" does not match inside "whitesmoke".
|
||||
static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new(|| {
|
||||
NAMED_COLORS
|
||||
.iter()
|
||||
@@ -65,7 +63,6 @@ static NAMED_COLOR_REGEXES: LazyLock<Vec<(&'static str, Regex)>> = LazyLock::new
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// Named CSS colors mapped to RGB values.
|
||||
type NamedColorList = Vec<(&'static str, (u8, u8, u8))>;
|
||||
static NAMED_COLORS: LazyLock<NamedColorList> = LazyLock::new(|| {
|
||||
vec![
|
||||
@@ -132,15 +129,6 @@ impl ToSql for EmailTheme {
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect whether an email's HTML is light, dark, transparent, or adaptive.
|
||||
///
|
||||
/// Detection priority:
|
||||
/// 1. If the email has `@media(prefers-color-scheme:dark)` rules with background
|
||||
/// overrides, classify as Adaptive (it handles dark mode natively).
|
||||
/// 2. Extract background colors from body, table, td, div elements (inline styles,
|
||||
/// style tags, bgcolor attrs). Group by luminance (light vs dark). If one group
|
||||
/// dominates (>= 50% of total weight), return that group's classification.
|
||||
/// 3. If no backgrounds found, return Transparent.
|
||||
pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
||||
if html.trim().is_empty() {
|
||||
return None;
|
||||
@@ -148,9 +136,6 @@ pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
||||
|
||||
let decoded = decode_quoted_printable(html);
|
||||
|
||||
// Check for @media(prefers-color-scheme:dark) with background overrides.
|
||||
// These emails handle their own dark adaptation — classify as Adaptive
|
||||
// so the frontend sets color-scheme and lets the email CSS handle it.
|
||||
if has_dark_mode_media_query(&decoded) {
|
||||
return Some(EmailTheme::Adaptive);
|
||||
}
|
||||
@@ -164,8 +149,6 @@ pub fn detect_email_theme(html: &str) -> Option<EmailTheme> {
|
||||
classify_by_luminance_groups(&backgrounds)
|
||||
}
|
||||
|
||||
/// Check if the HTML has a @media(prefers-color-scheme:dark) block that sets
|
||||
/// background colors, indicating the email handles dark mode natively.
|
||||
fn has_dark_mode_media_query(html: &str) -> bool {
|
||||
let lower = html.to_lowercase();
|
||||
for cap in DARK_MEDIA_QUERY_RE.captures_iter(&lower) {
|
||||
@@ -177,8 +160,6 @@ fn has_dark_mode_media_query(html: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Group all extracted colors into "light" and "dark" buckets by their
|
||||
/// luminance, then check which bucket dominates.
|
||||
fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme> {
|
||||
let mut light_weight: f32 = 0.0;
|
||||
let mut dark_weight: f32 = 0.0;
|
||||
@@ -205,20 +186,12 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme
|
||||
let light_coverage = light_weight / total_weight;
|
||||
let dark_coverage = dark_weight / total_weight;
|
||||
|
||||
// Require at least 50% of weighted colors to agree
|
||||
if light_coverage >= 0.5 {
|
||||
// If the only signal is from a wrapper table/td with white background
|
||||
// and there's no body-level background, treat as transparent.
|
||||
// A lone "white" on a wrapper element is usually just a reset, not
|
||||
// intentional theming.
|
||||
if !has_body_background && is_only_white_wrapper(colors) {
|
||||
return Some(EmailTheme::Transparent);
|
||||
}
|
||||
Some(EmailTheme::Light)
|
||||
} else if dark_coverage >= 0.5 {
|
||||
// Guard against tiny accent elements being the only signal:
|
||||
// if total weight is very low (only 1-2 low-weight elements like td),
|
||||
// that's likely an accent, not a real background.
|
||||
if total_weight <= 1.0 && colors.len() <= 2 && !has_body_background {
|
||||
Some(EmailTheme::Transparent)
|
||||
} else {
|
||||
@@ -229,19 +202,15 @@ fn classify_by_luminance_groups(colors: &[BackgroundColor]) -> Option<EmailTheme
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if all color signals are just white (#ffffff or named "white") on
|
||||
/// wrapper elements (table or td) without any body-level background. This
|
||||
/// usually indicates a boilerplate reset rather than intentional light theming.
|
||||
fn is_only_white_wrapper(colors: &[BackgroundColor]) -> bool {
|
||||
if colors.is_empty() {
|
||||
return false;
|
||||
}
|
||||
for bg in colors {
|
||||
// If there's a body-level background, it's intentional
|
||||
if bg.element_weight >= 1.0 {
|
||||
return false;
|
||||
}
|
||||
// Only consider pure white as a wrapper reset
|
||||
|
||||
let c = bg.color.trim().to_lowercase();
|
||||
if c != "#ffffff" && c != "white" {
|
||||
return false;
|
||||
@@ -273,21 +242,16 @@ fn extract_background_colors(html: &str) -> Vec<BackgroundColor> {
|
||||
extract_style_tag_colors(html, &mut colors);
|
||||
extract_bgcolor_attrs(html, &mut colors);
|
||||
|
||||
// Deduplicate: if the same color appears on the same element from both
|
||||
// bgcolor and style attributes, keep only the one with higher weight.
|
||||
deduplicate_colors(&mut colors);
|
||||
|
||||
colors
|
||||
}
|
||||
|
||||
/// Remove duplicate color entries that likely come from the same element
|
||||
/// having both bgcolor="X" and style="background-color:X".
|
||||
fn deduplicate_colors(colors: &mut Vec<BackgroundColor>) {
|
||||
if colors.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track seen (normalized_color, weight) pairs and remove exact duplicates
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
colors.retain(|bg| {
|
||||
let key = format!("{}:{:.1}", bg.color, bg.element_weight);
|
||||
@@ -334,7 +298,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract from body{...} rules
|
||||
if let Some(body_cap) = BODY_CSS_RE.captures(style_content) {
|
||||
let body_styles = &body_cap[1];
|
||||
if body_styles.to_lowercase().contains("background") {
|
||||
@@ -342,7 +305,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from class rules that mention background
|
||||
for class_cap in CLASS_CSS_RE.captures_iter(style_content) {
|
||||
let class_styles = &class_cap[1];
|
||||
if class_styles.to_lowercase().contains("background") {
|
||||
@@ -350,7 +312,6 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract from element-level rules (td{...}, table{...}, etc.)
|
||||
for elem_cap in ELEM_CSS_RE.captures_iter(style_content) {
|
||||
let tag = elem_cap[1].to_lowercase();
|
||||
let elem_styles = &elem_cap[2];
|
||||
@@ -368,12 +329,9 @@ fn extract_style_tag_colors(html: &str, colors: &mut Vec<BackgroundColor>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract color values only from background/background-color properties in a CSS rule body.
|
||||
fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<BackgroundColor>) {
|
||||
let lower = css.to_lowercase();
|
||||
|
||||
// Only extract colors from background/background-color property values,
|
||||
// not from the entire CSS rule (which may contain color, border-color, etc.)
|
||||
for bg_cap in BG_PROP_RE.captures_iter(&lower) {
|
||||
let bg_value = bg_cap[1].trim();
|
||||
|
||||
@@ -425,8 +383,6 @@ fn extract_all_colors_from_css(css: &str, weight: f32, colors: &mut Vec<Backgrou
|
||||
}
|
||||
}
|
||||
|
||||
// Check for named colors using word-boundary matching to avoid
|
||||
// false positives (e.g. "white" matching inside "whitesmoke").
|
||||
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
||||
if re.is_match(bg_value) {
|
||||
colors.push(BackgroundColor {
|
||||
@@ -462,7 +418,6 @@ fn extract_bgcolor_attrs(html: &str, colors: &mut Vec<BackgroundColor>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A color extracted from an inline style, with its alpha value.
|
||||
struct ExtractedColor {
|
||||
color: String,
|
||||
alpha: f32,
|
||||
@@ -478,8 +433,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
||||
.map(|m| m.as_str().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Only extract colors from background/background-color property values,
|
||||
// not from the entire style string (which may contain border-color, color, etc.)
|
||||
for bg_cap in BG_PROP_RE.captures_iter(&style_value) {
|
||||
let bg_value = bg_cap[1].trim();
|
||||
|
||||
@@ -512,8 +465,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for named colors using word-boundary matching to avoid
|
||||
// false positives (e.g. "white" matching inside "whitesmoke").
|
||||
for (name, re) in NAMED_COLOR_REGEXES.iter() {
|
||||
if re.is_match(bg_value) {
|
||||
colors.push(ExtractedColor {
|
||||
@@ -529,8 +480,6 @@ fn extract_colors_from_style(tag_content: &str) -> Vec<ExtractedColor> {
|
||||
colors
|
||||
}
|
||||
|
||||
/// Parse the alpha channel from an 8-digit hex color (e.g. `#rrggbbaa`).
|
||||
/// Returns 1.0 for 3/6-digit hex or if parsing fails.
|
||||
fn parse_hex_alpha(color: &str) -> f32 {
|
||||
let c = color.trim().to_lowercase();
|
||||
if let Some(hex) = c.strip_prefix('#') {
|
||||
@@ -545,7 +494,6 @@ fn parse_hex_alpha(color: &str) -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Parse an alpha string that may be a decimal (`0.5`) or percentage (`50%`).
|
||||
fn parse_alpha_str(s: &str) -> f32 {
|
||||
if let Some(pct) = s.strip_suffix('%') {
|
||||
pct.parse::<f32>().unwrap_or(100.0) / 100.0
|
||||
@@ -577,7 +525,6 @@ fn normalize_color(color: &str) -> String {
|
||||
}
|
||||
6 => c,
|
||||
8 => {
|
||||
// 8-digit hex: strip alpha channel
|
||||
format!("#{}", &hex[0..6])
|
||||
}
|
||||
_ => c,
|
||||
@@ -595,7 +542,6 @@ fn calculate_luminance(color: &str) -> f64 {
|
||||
fn parse_color(color: &str) -> (u8, u8, u8) {
|
||||
let c = color.trim().to_lowercase();
|
||||
|
||||
// Check named colors first
|
||||
for (name, rgb) in NAMED_COLORS.iter() {
|
||||
if c == *name {
|
||||
return *rgb;
|
||||
@@ -629,7 +575,6 @@ fn parse_color(color: &str) -> (u8, u8, u8) {
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown color - default to white (high luminance = light, safe fallback)
|
||||
(255, 255, 255)
|
||||
}
|
||||
|
||||
@@ -686,7 +631,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_luminance_grouping_mixed_light_colors() {
|
||||
// Multiple light colors should still classify as Light
|
||||
let html = r#"<html>
|
||||
<body style="background-color: #ffffff">
|
||||
<table style="background-color: #FAF9F5">
|
||||
@@ -700,29 +644,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_accent_td_not_treated_as_background() {
|
||||
// A single td with a dark accent color shouldn't classify the email as dark
|
||||
let html = r##"<html><body><table><tr><td bgcolor="#2672ec">Click here</td></tr></table></body></html>"##;
|
||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deduplicate_bgcolor_and_style() {
|
||||
// Same color from both bgcolor and style on same td should be deduplicated
|
||||
let html = r##"<html><body><table><tr><td bgcolor="#2672ec" style="background-color:#2672ec">Click</td></tr></table></body></html>"##;
|
||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lone_white_wrapper_table_is_transparent() {
|
||||
// A wrapper table with background-color:white but no body background
|
||||
// should be treated as transparent (boilerplate reset)
|
||||
let html = r#"<html><body><table style="background-color:white"><tr><td>Content</td></tr></table></body></html>"#;
|
||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Transparent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_named_color_white() {
|
||||
// Body with white background is intentional
|
||||
let html = r#"<html><body style="background-color: white">Content</body></html>"#;
|
||||
assert_eq!(detect_email_theme(html), Some(EmailTheme::Light));
|
||||
}
|
||||
@@ -760,8 +699,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_white_does_not_match_whitesmoke() {
|
||||
// "whitesmoke" is a distinct named color (very light gray, RGB 245,245,245).
|
||||
// It must NOT be misidentified as "white".
|
||||
let html = r#"<html><body style="background-color: whitesmoke">Content</body></html>"#;
|
||||
let colors = extract_background_colors(html);
|
||||
assert_eq!(colors.len(), 1);
|
||||
@@ -810,7 +747,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_named_color_in_style_tag_exact_match() {
|
||||
// Ensure style-tag extraction also uses exact matching
|
||||
let html = r#"<html><head><style>body { background-color: whitesmoke; }</style></head><body>Content</body></html>"#;
|
||||
let colors = extract_background_colors(html);
|
||||
assert!(
|
||||
@@ -826,8 +762,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rgba_half_alpha_reduced_weight() {
|
||||
// rgba(0,0,0,0.5) on body should have weight 1.0 * 0.5 = 0.5
|
||||
// compared to rgb(0,0,0) which would have weight 1.0
|
||||
let html_rgba =
|
||||
r#"<html><body style="background-color: rgba(0,0,0,0.5)">Content</body></html>"#;
|
||||
let html_rgb = r#"<html><body style="background-color: rgb(0,0,0)">Content</body></html>"#;
|
||||
@@ -851,7 +785,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rgba_fully_transparent_ignored() {
|
||||
// rgba(0,0,0,0) should be skipped entirely (alpha < 0.1)
|
||||
let html = r#"<html><body style="background-color: rgba(0,0,0,0)">Content</body></html>"#;
|
||||
let colors = extract_background_colors(html);
|
||||
assert!(
|
||||
@@ -863,7 +796,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rgba_full_alpha_same_as_rgb() {
|
||||
// rgba(255,255,255,1.0) should behave identically to rgb(255,255,255)
|
||||
let html_rgba =
|
||||
r#"<html><body style="background-color: rgba(255,255,255,1.0)">Content</body></html>"#;
|
||||
let html_rgb =
|
||||
@@ -885,7 +817,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_rgba_in_style_tag() {
|
||||
// RGBA in a <style> block should also have reduced weight
|
||||
let html = r#"<html><head><style>body { background-color: rgba(0,0,0,0.5); }</style></head><body>Content</body></html>"#;
|
||||
let colors = extract_background_colors(html);
|
||||
assert_eq!(colors.len(), 1);
|
||||
|
||||
Reference in new issue
Block a user