use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[serde(rename_all = "snake_case")]
pub enum Profile {
GmailWeb,
GmailIos,
OutlookDesktop,
OutlookWeb,
AppleMailMac,
AppleMailIos,
YahooMail,
}
impl Profile {
pub fn name(self) -> &'static str {
match self {
Profile::GmailWeb => "Gmail Web",
Profile::GmailIos => "Gmail iOS",
Profile::OutlookDesktop => "Outlook Desktop (Windows)",
Profile::OutlookWeb => "Outlook Web",
Profile::AppleMailMac => "Apple Mail (macOS)",
Profile::AppleMailIos => "Apple Mail (iOS)",
Profile::YahooMail => "Yahoo Mail",
}
}
pub fn fidelity(self) -> Fidelity {
match self {
Profile::AppleMailMac | Profile::AppleMailIos => Fidelity::High,
Profile::GmailWeb | Profile::GmailIos => Fidelity::Approximate,
Profile::OutlookDesktop => Fidelity::Experimental,
Profile::OutlookWeb => Fidelity::Approximate,
Profile::YahooMail => Fidelity::Approximate,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[serde(rename_all = "lowercase")]
pub enum Fidelity {
High,
Approximate,
Experimental,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "specta", derive(specta::Type))]
#[serde(rename_all = "camelCase")]
pub struct RenderedPreview {
pub profile: Profile,
pub fidelity: Fidelity,
pub html: String,
pub applied: Vec<&'static str>,
}
pub fn apply(html: &str, profile: Profile) -> RenderedPreview {
let (out, applied) = match profile {
Profile::GmailWeb | Profile::GmailIos => transform_gmail(html),
Profile::OutlookDesktop => transform_outlook_desktop(html),
Profile::OutlookWeb => transform_outlook_web(html),
Profile::AppleMailMac | Profile::AppleMailIos => transform_apple(html),
Profile::YahooMail => transform_yahoo(html),
};
RenderedPreview {
profile,
fidelity: profile.fidelity(),
html: out,
applied,
}
}
fn transform_gmail(html: &str) -> (String, Vec<&'static str>) {
let mut out = html.to_string();
let mut notes: Vec<&'static str> = Vec::new();
if has_style_in_body(&out) {
out = strip_style_in_body(&out);
notes.push("style-in-body stripped (Gmail removes inline <style> blocks)");
}
if out.contains("display: grid") || out.contains("display:grid") {
out = out.replace("display: grid", "display: block");
out = out.replace("display:grid", "display:block");
notes.push("CSS Grid replaced with block (Gmail strips grid)");
}
(out, notes)
}
fn transform_outlook_desktop(html: &str) -> (String, Vec<&'static str>) {
let mut out = html.to_string();
let mut notes: Vec<&'static str> = Vec::new();
if has_style_tag(&out) {
out = strip_all_style_tags(&out);
notes.push("All <style> blocks stripped (Outlook Desktop uses Word renderer)");
}
out = strip_webfont_imports(&out);
notes.push("Web fonts ignored (Outlook falls back to system fonts)");
if out.contains("display: grid") || out.contains("display: flex") {
out = out.replace("display: grid", "display: block");
out = out.replace("display:grid", "display:block");
out = out.replace("display: flex", "display: block");
out = out.replace("display:flex", "display:block");
notes.push("Grid/Flex replaced with block (Outlook only supports tables)");
}
(out, notes)
}
fn transform_outlook_web(html: &str) -> (String, Vec<&'static str>) {
let mut out = html.to_string();
let mut notes: Vec<&'static str> = Vec::new();
if has_style_in_body(&out) {
out = strip_style_in_body(&out);
notes.push("style-in-body stripped (Outlook Web rewrites these)");
}
(out, notes)
}
fn transform_apple(html: &str) -> (String, Vec<&'static str>) {
(html.to_string(), Vec::new())
}
fn transform_yahoo(html: &str) -> (String, Vec<&'static str>) {
let mut out = html.to_string();
let mut notes: Vec<&'static str> = Vec::new();
if has_style_in_body(&out) {
out = strip_style_in_body(&out);
notes.push("style-in-body stripped (Yahoo rewrites these)");
}
(out, notes)
}
fn style_tag_regex() -> &'static Regex {
static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
R.get_or_init(|| Regex::new(r"(?is)<style\b[^>]*>.*?</style>").unwrap())
}
fn body_open_regex() -> &'static Regex {
static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
R.get_or_init(|| Regex::new(r"(?is)<body\b[^>]*>").unwrap())
}
fn has_style_tag(s: &str) -> bool {
style_tag_regex().is_match(s)
}
fn has_style_in_body(html: &str) -> bool {
let Some(body_open) = body_open_regex().find(html) else {
return has_style_tag(html);
};
let after_body = &html[body_open.end()..];
has_style_tag(after_body)
}
fn strip_all_style_tags(html: &str) -> String {
style_tag_regex().replace_all(html, "").into_owned()
}
fn strip_style_in_body(html: &str) -> String {
let Some(body_open) = body_open_regex().find(html) else {
return strip_all_style_tags(html);
};
let split = body_open.end();
let (head, body) = html.split_at(split);
let body = style_tag_regex().replace_all(body, "").into_owned();
format!("{head}{body}")
}
fn strip_webfont_imports(html: &str) -> String {
static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = R.get_or_init(|| {
Regex::new(r#"(?is)@import\s+url\(['"]?https?://[^)'"]*['"]?\);?"#).unwrap()
});
re.replace_all(html, "").into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gmail_strips_style_in_body() {
let html = r#"<html><head></head><body><style>.x{color:red}</style><p>hi</p></body></html>"#;
let r = apply(html, Profile::GmailWeb);
assert!(!r.html.contains("<style>"));
assert!(!r.applied.is_empty());
}
#[test]
fn gmail_preserves_style_in_head() {
let html = r#"<html><head><style>.x{color:red}</style></head><body><p>hi</p></body></html>"#;
let r = apply(html, Profile::GmailWeb);
assert!(r.html.contains("<style>"));
assert!(r.applied.is_empty());
}
#[test]
fn outlook_strips_all_styles() {
let html = r#"<html><head><style>.x{}</style></head><body><p>hi</p></body></html>"#;
let r = apply(html, Profile::OutlookDesktop);
assert!(!r.html.contains("<style>"));
}
#[test]
fn outlook_replaces_grid() {
let html = r#"<div style="display: grid">x</div>"#;
let r = apply(html, Profile::OutlookDesktop);
assert!(r.html.contains("display: block"));
}
#[test]
fn apple_passes_through() {
let html = r#"<div style="display: grid">x</div>"#;
let r = apply(html, Profile::AppleMailMac);
assert_eq!(r.html, html);
assert!(r.applied.is_empty());
}
}