Skip to main content

postcrate_core/rendering/
profile.rs

1//! Per-client profile transforms.
2//!
3//! Each profile takes an HTML blob and returns it with the client's
4//! known limitations applied. The transforms are deterministic
5//! regex/HTML rewrites — no headless browser, no network access.
6//!
7//! When adding a new profile, extend the test coverage in this file
8//! and update the profile's `fidelity` so callers can label previews
9//! honestly. A profile that lies about what it simulates is worse
10//! than no profile.
11
12use regex::Regex;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "specta", derive(specta::Type))]
17#[serde(rename_all = "snake_case")]
18pub enum Profile {
19    GmailWeb,
20    GmailIos,
21    OutlookDesktop,
22    OutlookWeb,
23    AppleMailMac,
24    AppleMailIos,
25    YahooMail,
26}
27
28impl Profile {
29    pub fn name(self) -> &'static str {
30        match self {
31            Profile::GmailWeb => "Gmail Web",
32            Profile::GmailIos => "Gmail iOS",
33            Profile::OutlookDesktop => "Outlook Desktop (Windows)",
34            Profile::OutlookWeb => "Outlook Web",
35            Profile::AppleMailMac => "Apple Mail (macOS)",
36            Profile::AppleMailIos => "Apple Mail (iOS)",
37            Profile::YahooMail => "Yahoo Mail",
38        }
39    }
40
41    /// Honest fidelity report.
42    pub fn fidelity(self) -> Fidelity {
43        match self {
44            // Apple Mail uses WebKit; render is closest to a stock browser.
45            Profile::AppleMailMac | Profile::AppleMailIos => Fidelity::High,
46            // Gmail strips `<style>` and rewrites `class` attributes.
47            // We approximate the common cases.
48            Profile::GmailWeb | Profile::GmailIos => Fidelity::Approximate,
49            // Outlook Desktop is the hardest to simulate: it uses
50            // Word's rendering engine. Mark experimental.
51            Profile::OutlookDesktop => Fidelity::Experimental,
52            // Outlook Web is closer to a real browser, but `<style>`
53            // and CSS support is famously inconsistent.
54            Profile::OutlookWeb => Fidelity::Approximate,
55            // Yahoo: similar limitations to Outlook Web.
56            Profile::YahooMail => Fidelity::Approximate,
57        }
58    }
59}
60
61#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62#[cfg_attr(feature = "specta", derive(specta::Type))]
63#[serde(rename_all = "lowercase")]
64pub enum Fidelity {
65    High,
66    Approximate,
67    Experimental,
68}
69
70#[derive(Debug, Clone, Serialize)]
71#[cfg_attr(feature = "specta", derive(specta::Type))]
72#[serde(rename_all = "camelCase")]
73pub struct RenderedPreview {
74    pub profile: Profile,
75    pub fidelity: Fidelity,
76    pub html: String,
77    /// Notes about transforms that ran — useful for "why does it
78    /// look different here?" tooltips in the UI.
79    pub applied: Vec<&'static str>,
80}
81
82/// Apply `profile` to `html`. Idempotent; safe to run multiple times.
83pub fn apply(html: &str, profile: Profile) -> RenderedPreview {
84    let (out, applied) = match profile {
85        Profile::GmailWeb | Profile::GmailIos => transform_gmail(html),
86        Profile::OutlookDesktop => transform_outlook_desktop(html),
87        Profile::OutlookWeb => transform_outlook_web(html),
88        Profile::AppleMailMac | Profile::AppleMailIos => transform_apple(html),
89        Profile::YahooMail => transform_yahoo(html),
90    };
91    RenderedPreview {
92        profile,
93        fidelity: profile.fidelity(),
94        html: out,
95        applied,
96    }
97}
98
99fn transform_gmail(html: &str) -> (String, Vec<&'static str>) {
100    let mut out = html.to_string();
101    let mut notes: Vec<&'static str> = Vec::new();
102
103    // Gmail strips `<style>` blocks inside `<body>`.
104    if has_style_in_body(&out) {
105        out = strip_style_in_body(&out);
106        notes.push("style-in-body stripped (Gmail removes inline <style> blocks)");
107    }
108    // Gmail strips CSS Grid: replace `display: grid` with `display: block`.
109    if out.contains("display: grid") || out.contains("display:grid") {
110        out = out.replace("display: grid", "display: block");
111        out = out.replace("display:grid", "display:block");
112        notes.push("CSS Grid replaced with block (Gmail strips grid)");
113    }
114    (out, notes)
115}
116
117fn transform_outlook_desktop(html: &str) -> (String, Vec<&'static str>) {
118    let mut out = html.to_string();
119    let mut notes: Vec<&'static str> = Vec::new();
120
121    // Outlook desktop ignores most modern CSS — strip <style> blocks
122    // entirely so the preview falls back to inline + table layout.
123    if has_style_tag(&out) {
124        out = strip_all_style_tags(&out);
125        notes.push("All <style> blocks stripped (Outlook Desktop uses Word renderer)");
126    }
127    // Web fonts: Outlook ignores @font-face. Force a stock fallback
128    // by removing font-family declarations that look like web fonts.
129    out = strip_webfont_imports(&out);
130    notes.push("Web fonts ignored (Outlook falls back to system fonts)");
131
132    // CSS Grid + Flexbox: unsupported. Same trick as Gmail.
133    if out.contains("display: grid") || out.contains("display: flex") {
134        out = out.replace("display: grid", "display: block");
135        out = out.replace("display:grid", "display:block");
136        out = out.replace("display: flex", "display: block");
137        out = out.replace("display:flex", "display:block");
138        notes.push("Grid/Flex replaced with block (Outlook only supports tables)");
139    }
140    (out, notes)
141}
142
143fn transform_outlook_web(html: &str) -> (String, Vec<&'static str>) {
144    let mut out = html.to_string();
145    let mut notes: Vec<&'static str> = Vec::new();
146    if has_style_in_body(&out) {
147        out = strip_style_in_body(&out);
148        notes.push("style-in-body stripped (Outlook Web rewrites these)");
149    }
150    (out, notes)
151}
152
153fn transform_apple(html: &str) -> (String, Vec<&'static str>) {
154    // Apple Mail uses WebKit; passes most modern CSS. Light touch:
155    // surface no notes when nothing changes.
156    (html.to_string(), Vec::new())
157}
158
159fn transform_yahoo(html: &str) -> (String, Vec<&'static str>) {
160    let mut out = html.to_string();
161    let mut notes: Vec<&'static str> = Vec::new();
162    if has_style_in_body(&out) {
163        out = strip_style_in_body(&out);
164        notes.push("style-in-body stripped (Yahoo rewrites these)");
165    }
166    (out, notes)
167}
168
169// ---- helpers ------------------------------------------------------
170
171fn style_tag_regex() -> &'static Regex {
172    static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
173    R.get_or_init(|| Regex::new(r"(?is)<style\b[^>]*>.*?</style>").unwrap())
174}
175
176fn body_open_regex() -> &'static Regex {
177    static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
178    R.get_or_init(|| Regex::new(r"(?is)<body\b[^>]*>").unwrap())
179}
180
181fn has_style_tag(s: &str) -> bool {
182    style_tag_regex().is_match(s)
183}
184
185fn has_style_in_body(html: &str) -> bool {
186    let Some(body_open) = body_open_regex().find(html) else {
187        // No <body>; treat any <style> as "in body".
188        return has_style_tag(html);
189    };
190    let after_body = &html[body_open.end()..];
191    has_style_tag(after_body)
192}
193
194fn strip_all_style_tags(html: &str) -> String {
195    style_tag_regex().replace_all(html, "").into_owned()
196}
197
198fn strip_style_in_body(html: &str) -> String {
199    let Some(body_open) = body_open_regex().find(html) else {
200        return strip_all_style_tags(html);
201    };
202    let split = body_open.end();
203    let (head, body) = html.split_at(split);
204    let body = style_tag_regex().replace_all(body, "").into_owned();
205    format!("{head}{body}")
206}
207
208fn strip_webfont_imports(html: &str) -> String {
209    static R: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
210    let re = R.get_or_init(|| {
211        Regex::new(r#"(?is)@import\s+url\(['"]?https?://[^)'"]*['"]?\);?"#).unwrap()
212    });
213    re.replace_all(html, "").into_owned()
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn gmail_strips_style_in_body() {
222        let html = r#"<html><head></head><body><style>.x{color:red}</style><p>hi</p></body></html>"#;
223        let r = apply(html, Profile::GmailWeb);
224        assert!(!r.html.contains("<style>"));
225        assert!(!r.applied.is_empty());
226    }
227
228    #[test]
229    fn gmail_preserves_style_in_head() {
230        let html = r#"<html><head><style>.x{color:red}</style></head><body><p>hi</p></body></html>"#;
231        let r = apply(html, Profile::GmailWeb);
232        assert!(r.html.contains("<style>"));
233        assert!(r.applied.is_empty());
234    }
235
236    #[test]
237    fn outlook_strips_all_styles() {
238        let html = r#"<html><head><style>.x{}</style></head><body><p>hi</p></body></html>"#;
239        let r = apply(html, Profile::OutlookDesktop);
240        assert!(!r.html.contains("<style>"));
241    }
242
243    #[test]
244    fn outlook_replaces_grid() {
245        let html = r#"<div style="display: grid">x</div>"#;
246        let r = apply(html, Profile::OutlookDesktop);
247        assert!(r.html.contains("display: block"));
248    }
249
250    #[test]
251    fn apple_passes_through() {
252        let html = r#"<div style="display: grid">x</div>"#;
253        let r = apply(html, Profile::AppleMailMac);
254        assert_eq!(r.html, html);
255        assert!(r.applied.is_empty());
256    }
257}