postcrate_core/rendering/
lint.rs1use serde::Serialize;
9
10#[derive(Debug, Clone, Serialize)]
11#[cfg_attr(feature = "specta", derive(specta::Type))]
12#[serde(rename_all = "camelCase")]
13pub struct LintReport {
14 pub warnings: Vec<LintWarning>,
15}
16
17#[derive(Debug, Clone, Serialize)]
18#[cfg_attr(feature = "specta", derive(specta::Type))]
19#[serde(rename_all = "camelCase")]
20pub struct LintWarning {
21 pub rule: &'static str,
22 pub severity: &'static str,
24 pub message: &'static str,
25 pub byte_offset: Option<usize>,
27 pub affects: Vec<&'static str>,
29}
30
31pub fn lint(html: &str) -> LintReport {
32 let mut warnings: Vec<LintWarning> = Vec::new();
33 let lower = html.to_ascii_lowercase();
34
35 if let Some(body_pos) = lower.find("<body") {
37 if let Some(style_pos) = lower[body_pos..].find("<style") {
38 warnings.push(LintWarning {
39 rule: "STYLE_IN_BODY",
40 severity: "high",
41 message: "Gmail / Outlook Web strip <style> blocks inside <body>. Move them to <head> or inline.",
42 byte_offset: Some(body_pos + style_pos),
43 affects: vec!["Gmail Web", "Gmail iOS", "Outlook Web", "Yahoo Mail"],
44 });
45 }
46 }
47
48 if let Some(pos) = lower.find("display: grid").or_else(|| lower.find("display:grid")) {
50 warnings.push(LintWarning {
51 rule: "CSS_GRID",
52 severity: "high",
53 message: "CSS Grid is not supported in Outlook or older Gmail clients. Use tables.",
54 byte_offset: Some(pos),
55 affects: vec!["Outlook Desktop", "Outlook Web", "Gmail iOS"],
56 });
57 }
58
59 if let Some(pos) = lower.find("display: flex").or_else(|| lower.find("display:flex")) {
61 warnings.push(LintWarning {
62 rule: "CSS_FLEX",
63 severity: "medium",
64 message: "Flexbox is unsupported in Outlook Desktop. Provide a table fallback.",
65 byte_offset: Some(pos),
66 affects: vec!["Outlook Desktop"],
67 });
68 }
69
70 if let Some(pos) = lower.find("@import url") {
72 warnings.push(LintWarning {
73 rule: "WEB_FONT_IMPORT",
74 severity: "medium",
75 message: "Outlook ignores @import @font-face. Declare a system-font fallback.",
76 byte_offset: Some(pos),
77 affects: vec!["Outlook Desktop"],
78 });
79 }
80
81 if let Some(pos) = lower.find("rel=\"stylesheet\"")
83 .or_else(|| lower.find("rel='stylesheet'"))
84 .or_else(|| lower.find("rel=stylesheet"))
85 {
86 warnings.push(LintWarning {
87 rule: "EXTERNAL_STYLESHEET",
88 severity: "high",
89 message: "External stylesheets are not loaded by most email clients. Inline the CSS.",
90 byte_offset: Some(pos),
91 affects: vec!["Gmail Web", "Gmail iOS", "Outlook Desktop", "Outlook Web", "Yahoo Mail"],
92 });
93 }
94
95 if let Some(pos) = lower.find("<script") {
97 warnings.push(LintWarning {
98 rule: "SCRIPT_TAG",
99 severity: "high",
100 message: "JavaScript is stripped by every major email client. Remove <script> tags.",
101 byte_offset: Some(pos),
102 affects: vec!["All clients"],
103 });
104 }
105
106 if lower.contains("<video") || lower.contains("<audio") {
108 warnings.push(LintWarning {
109 rule: "MEDIA_TAG",
110 severity: "medium",
111 message: "<video>/<audio> are not supported in most clients. Use a static preview image.",
112 byte_offset: None,
113 affects: vec!["Outlook Desktop", "Outlook Web", "Yahoo Mail"],
114 });
115 }
116
117 if lower.contains("position: absolute") || lower.contains("position:absolute") {
119 warnings.push(LintWarning {
120 rule: "POSITION_ABSOLUTE",
121 severity: "high",
122 message: "Absolute positioning is unreliable across clients; use tables for layout.",
123 byte_offset: None,
124 affects: vec!["Outlook Desktop", "Outlook Web", "Gmail Web"],
125 });
126 }
127
128 LintReport { warnings }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[test]
136 fn style_in_body_flagged() {
137 let html = "<html><body><style>.x{}</style></body></html>";
138 let r = lint(html);
139 assert!(r.warnings.iter().any(|w| w.rule == "STYLE_IN_BODY"));
140 }
141
142 #[test]
143 fn no_warnings_for_clean_html() {
144 let html = "<html><head><style>.x{color:red}</style></head><body><p>hi</p></body></html>";
145 let r = lint(html);
146 assert!(r.warnings.is_empty(), "got {:?}", r.warnings);
147 }
148
149 #[test]
150 fn script_flagged_high() {
151 let html = "<body><script>alert(1)</script></body>";
152 let r = lint(html);
153 let w = r.warnings.iter().find(|w| w.rule == "SCRIPT_TAG").expect("script warning");
154 assert_eq!(w.severity, "high");
155 }
156}