Skip to main content

postcrate_core/rendering/
lint.rs

1//! HTML linter for captured emails.
2//!
3//! Each rule is a cheap text pattern + a single human sentence. We
4//! deliberately avoid HTML parsing: we want this to run on every
5//! captured email cheaply, and for the warnings to point at literal
6//! source lines.
7
8use 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    /// "high" / "medium" / "low".
23    pub severity: &'static str,
24    pub message: &'static str,
25    /// Where in the HTML we hit it — UI can highlight.
26    pub byte_offset: Option<usize>,
27    /// Which clients are affected by this issue.
28    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    // Rule 1: <style> inside <body>.
36    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    // Rule 2: CSS Grid usage.
49    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    // Rule 3: Flexbox.
60    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    // Rule 4: Web fonts via @import.
71    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    // Rule 5: <link rel="stylesheet"> — almost always stripped.
82    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    // Rule 6: <script>.
96    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    // Rule 7: <video> / <audio>.
107    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    // Rule 8: position: absolute / fixed.
118    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}