postcrate_core/rendering/
profile.rs1use 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 pub fn fidelity(self) -> Fidelity {
43 match self {
44 Profile::AppleMailMac | Profile::AppleMailIos => Fidelity::High,
46 Profile::GmailWeb | Profile::GmailIos => Fidelity::Approximate,
49 Profile::OutlookDesktop => Fidelity::Experimental,
52 Profile::OutlookWeb => Fidelity::Approximate,
55 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 pub applied: Vec<&'static str>,
80}
81
82pub 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 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 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 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 out = strip_webfont_imports(&out);
130 notes.push("Web fonts ignored (Outlook falls back to system fonts)");
131
132 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 (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
169fn 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 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}