Skip to main content

hypermail/
txt2html.rs

1use crate::config::Config;
2use crate::message::BodyChain;
3use crate::quotes::{find_quote_class, is_sig_start, unquote};
4use crate::string_utils::conv_urls;
5
6pub fn txt2html(body_chain: &mut BodyChain, config: &Config) {
7    let mut in_sig = false;
8    for body in &mut body_chain.bodies {
9        if body.attached || body.header || body.html {
10            continue;
11        }
12        body.line = txt2html_line(&body.line, config, &mut in_sig);
13    }
14}
15
16fn txt2html_line(line: &str, config: &Config, in_sig: &mut bool) -> String {
17    let line = line.trim_end_matches('\n').trim_end_matches('\r');
18
19    if line.is_empty() {
20        // Emit a class-less marker that format_body() will collapse into a
21        // bare newline within the surrounding run, producing a real
22        // paragraph break inside one flowing pre-wrap block.
23        return String::from("<div class=\"hm-blank\"></div>\n");
24    }
25
26    if is_sig_start(line) {
27        *in_sig = true;
28        return String::from("<hr class=\"hm-sig\">\n");
29    }
30
31    // Determine CSS class: monospace for signatures, proportional for body/quotes
32    let pg_class = if *in_sig { "hm-sig-text" } else { "hm-pg" };
33
34    // Check for inline image marker: [INLINE_IMAGE:mime/type:base64data]
35    if line.starts_with("[INLINE_IMAGE:") && line.ends_with(']') {
36        if let Some(marker_content) =
37            line.strip_prefix("[INLINE_IMAGE:").and_then(|s| s.strip_suffix(']'))
38        {
39            if let Some((mime_type, base64_data)) = marker_content.split_once(':') {
40                // SEC-1: Validate MIME type against allowlist before emitting data URI.
41                // image/svg+xml is excluded — SVG can contain scripts.
42                const SAFE_IMAGE_TYPES: &[&str] = &[
43                    "image/gif",
44                    "image/jpeg",
45                    "image/jpg",
46                    "image/png",
47                    "image/webp",
48                    "image/bmp",
49                    "image/tiff",
50                ];
51                if SAFE_IMAGE_TYPES.contains(&mime_type) {
52                    // Convert marker to actual HTML img tag
53                    return format!(
54                        "<div class=\"{}\"><img src=\"data:{};base64,{}\" alt=\"Embedded image\" style=\"max-width:100%;height:auto\"></div>\n",
55                        pg_class, mime_type, base64_data
56                    );
57                }
58            }
59        }
60        // If parsing fails or MIME type not in allowlist, fall through to normal processing
61    }
62
63    let is_quote = line.starts_with('>');
64    if is_quote {
65        let unquoted = unquote(line);
66        let quote_class = find_quote_class(line);
67        let escaped = escape_html(&unquoted);
68        let with_links = if config.href_detection {
69            conv_urls(&escaped)
70        } else {
71            escaped
72        };
73        format!("<div class=\"{}\">{}</div>\n", quote_class, with_links)
74    } else {
75        let escaped = escape_html(line);
76        let with_links = if config.href_detection {
77            conv_urls(&escaped)
78        } else {
79            escaped
80        };
81        format!("<div class=\"{}\">{}</div>\n", pg_class, with_links)
82    }
83}
84
85pub fn escape_html(s: &str) -> String {
86    let mut result = String::with_capacity(s.len());
87    for c in s.chars() {
88        match c {
89            '&' => result.push_str("&amp;"),
90            '<' => result.push_str("&lt;"),
91            '>' => result.push_str("&gt;"),
92            '"' => result.push_str("&quot;"),
93            '\'' => result.push_str("&#39;"),
94            '\t' => result.push_str("        "),
95            _ => result.push(c),
96        }
97    }
98    result
99}
100
101pub fn conv_showhtml(body: &mut BodyChain, config: &Config) {
102    let showhtml = config.showhtml;
103    let mut in_sig = false;
104
105    for b in &mut body.bodies {
106        if b.attached || b.header {
107            continue;
108        }
109
110        // Detect signature start
111        let trimmed = b.line.trim_end_matches('\n').trim_end_matches('\r');
112        if is_sig_start(trimmed) {
113            in_sig = true;
114            b.line = String::from("<hr class=\"hm-sig\">\n");
115            continue;
116        }
117
118        let pg_class = if in_sig { "hm-sig-text" } else { "hm-pg" };
119
120        if b.html {
121            if showhtml >= 2 {
122                continue;
123            }
124            if showhtml == 0 {
125                let escaped = escape_html(&b.line);
126                b.line = escaped;
127                continue;
128            }
129            // showhtml == 1: escape HTML body text
130            if showhtml == 1 || showhtml >= 4 {
131                let escaped = escape_html(&b.line);
132                b.line = if escaped.is_empty() {
133                    String::new()
134                } else {
135                    format!("<div class=\"{}\">{}</div>\n", pg_class, escaped)
136                };
137                continue;
138            }
139            continue;
140        }
141        // !b.html (plain text body)
142        if showhtml == 0 {
143            let escaped = escape_html(&b.line);
144            b.line = escaped;
145            continue;
146        }
147        if showhtml == 1 {
148            // Check for inline image marker first
149            let line = b.line.trim_end_matches('\n').trim_end_matches('\r');
150            if line.starts_with("[INLINE_IMAGE:") && line.ends_with(']') {
151                if let Some(marker_content) =
152                    line.strip_prefix("[INLINE_IMAGE:").and_then(|s| s.strip_suffix(']'))
153                {
154                    if let Some((mime_type, base64_data)) = marker_content.split_once(':') {
155                        const SAFE_IMAGE_TYPES: &[&str] = &[
156                            "image/gif",
157                            "image/jpeg",
158                            "image/jpg",
159                            "image/png",
160                            "image/webp",
161                            "image/bmp",
162                            "image/tiff",
163                        ];
164                        if SAFE_IMAGE_TYPES.contains(&mime_type) {
165                            b.line = format!(
166                                "<div class=\"{}\"><img src=\"data:{};base64,{}\" alt=\"Embedded image\" style=\"max-width:100%;height:auto\"></div>\n",
167                                pg_class, mime_type, base64_data
168                            );
169                            continue;
170                        }
171                    }
172                }
173            }
174
175            // Normal text processing
176            let escaped = escape_html(&b.line);
177            b.line = if escaped.is_empty() {
178                String::from("<div class=\"hm-blank\"></div>\n")
179            } else {
180                format!("<div class=\"{}\">{}</div>\n", pg_class, escaped)
181            };
182            continue;
183        }
184        if showhtml == 2 || showhtml == 3 {
185            b.line = txt2html_line(&b.line, config, &mut in_sig);
186            continue;
187        }
188    }
189}
190
191pub fn conv_body_line(line: &str, config: &Config) -> String {
192    if is_sig_start(line) {
193        return String::from("<hr class=\"hm-sig\">\n");
194    }
195
196    let is_quote = line.starts_with('>');
197    let escaped = escape_html(line);
198    let with_links = if config.href_detection {
199        conv_urls(&escaped)
200    } else {
201        escaped
202    };
203
204    if is_quote {
205        format!("<div class=\"{}\">{}</div>\n", find_quote_class_with_fallback(line), with_links)
206    } else {
207        format!("<div class=\"hm-pg\">{}</div>\n", with_links)
208    }
209}
210
211fn find_quote_class_with_fallback(line: &str) -> String {
212    let class = find_quote_class(line);
213    if class.is_empty() {
214        "hm-quote-1".to_string()
215    } else {
216        class
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::config::Config;
224
225    fn make_config() -> Config {
226        Config::default()
227    }
228
229    #[test]
230    fn test_escape_html() {
231        assert_eq!(escape_html("<test>"), "&lt;test&gt;");
232        assert_eq!(escape_html("a&b"), "a&amp;b");
233        assert_eq!(escape_html("hello"), "hello");
234    }
235
236    #[test]
237    fn test_txt2html_line_normal() {
238        let config = make_config();
239        let mut in_sig = false;
240        let result = txt2html_line("hello world", &config, &mut in_sig);
241        assert_eq!(result, "<div class=\"hm-pg\">hello world</div>\n");
242    }
243
244    #[test]
245    fn test_txt2html_line_quote() {
246        let config = make_config();
247        let mut in_sig = false;
248        let result = txt2html_line("> quoted text", &config, &mut in_sig);
249        assert!(result.contains("hm-quote-1"));
250        assert!(result.contains("quoted text"));
251    }
252
253    #[test]
254    fn test_txt2html_line_sig() {
255        let config = make_config();
256        let mut in_sig = false;
257        let result = txt2html_line("-- ", &config, &mut in_sig);
258        assert_eq!(result, "<hr class=\"hm-sig\">\n");
259        assert!(in_sig);
260    }
261
262    #[test]
263    fn test_txt2html_line_empty() {
264        let config = make_config();
265        let mut in_sig = false;
266        let result = txt2html_line("", &config, &mut in_sig);
267        assert_eq!(result, "<div class=\"hm-blank\"></div>\n");
268    }
269
270    #[test]
271    fn test_txt2html_line_urls() {
272        let config = make_config();
273        let mut in_sig = false;
274        let result = txt2html_line("Visit https://example.com", &config, &mut in_sig);
275        assert!(result.contains("<a href=\"https://example.com\""));
276        assert!(result.contains("rel=\"noopener noreferrer\""));
277    }
278
279    fn make_body_chain(text: &str) -> BodyChain {
280        let mut chain = BodyChain { bodies: Vec::new() };
281        chain.bodies.push(crate::message::Body {
282            line: text.to_string(),
283            html: false,
284            header: false,
285            parsed_header: false,
286            attached: false,
287            demimed: false,
288            msgnum: 1,
289        });
290        chain
291    }
292
293    #[test]
294    fn test_conv_showhtml_showhtml_0_escapes() {
295        let mut config = make_config();
296        config.showhtml = 0;
297        let mut chain = make_body_chain("<script>alert('xss')</script>");
298        conv_showhtml(&mut chain, &config);
299        assert_eq!(chain.bodies[0].line, "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;");
300    }
301
302    #[test]
303    fn test_conv_showhtml_showhtml_0_plain_text() {
304        let mut config = make_config();
305        config.showhtml = 0;
306        let mut chain = make_body_chain("Hello World");
307        conv_showhtml(&mut chain, &config);
308        assert_eq!(chain.bodies[0].line, "Hello World");
309    }
310
311    #[test]
312    fn test_conv_showhtml_showhtml_1_wraps_in_div() {
313        let mut config = make_config();
314        config.showhtml = 1;
315        let mut chain = make_body_chain("Hello World");
316        conv_showhtml(&mut chain, &config);
317        assert_eq!(chain.bodies[0].line, "<div class=\"hm-pg\">Hello World</div>\n");
318    }
319
320    #[test]
321    fn test_conv_showhtml_showhtml_1_escapes_xss() {
322        let mut config = make_config();
323        config.showhtml = 1;
324        let mut chain = make_body_chain("<script>bad</script>");
325        conv_showhtml(&mut chain, &config);
326        assert_eq!(
327            chain.bodies[0].line,
328            "<div class=\"hm-pg\">&lt;script&gt;bad&lt;/script&gt;</div>\n"
329        );
330    }
331
332    #[test]
333    fn test_conv_showhtml_showhtml_2_txt2html() {
334        let mut config = make_config();
335        config.showhtml = 2;
336        let mut chain = make_body_chain("> quote");
337        conv_showhtml(&mut chain, &config);
338        assert!(chain.bodies[0].line.contains("hm-quote"));
339    }
340
341    #[test]
342    fn test_conv_showhtml_attached_unchanged() {
343        let mut config = make_config();
344        config.showhtml = 0;
345        let mut chain = BodyChain { bodies: Vec::new() };
346        chain.bodies.push(crate::message::Body {
347            line: "<script>attack</script>".to_string(),
348            html: false,
349            header: false,
350            parsed_header: false,
351            attached: true,
352            demimed: false,
353            msgnum: 1,
354        });
355        conv_showhtml(&mut chain, &config);
356        // Attached bodies should not be processed
357        assert_eq!(chain.bodies[0].line, "<script>attack</script>");
358    }
359
360    #[test]
361    fn test_conv_showhtml_header_unchanged() {
362        let mut config = make_config();
363        config.showhtml = 0;
364        let mut chain = BodyChain { bodies: Vec::new() };
365        chain.bodies.push(crate::message::Body {
366            line: "<script>attack</script>".to_string(),
367            html: false,
368            header: true,
369            parsed_header: false,
370            attached: false,
371            demimed: false,
372            msgnum: 1,
373        });
374        conv_showhtml(&mut chain, &config);
375        assert_eq!(chain.bodies[0].line, "<script>attack</script>");
376    }
377
378    #[test]
379    fn test_txt2html_line_inline_image() {
380        let config = make_config();
381        let mut in_sig = false;
382        let line =
383            "[INLINE_IMAGE:image/gif:R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7]";
384        let result = txt2html_line(line, &config, &mut in_sig);
385
386        // Should convert to actual HTML img tag
387        assert!(
388            result.contains("<img src=\"data:image/gif;base64,"),
389            "Should convert marker to img tag"
390        );
391        assert!(
392            result.contains("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
393            "Should contain base64 data"
394        );
395        assert!(result.contains("alt=\"Embedded image\""), "Should have alt text");
396        assert!(!result.contains("[INLINE_IMAGE:"), "Should not contain marker text");
397    }
398
399    #[test]
400    fn test_txt2html_line_inline_image_jpeg() {
401        let config = make_config();
402        let mut in_sig = false;
403        let line = "[INLINE_IMAGE:image/jpeg:abcd1234]";
404        let result = txt2html_line(line, &config, &mut in_sig);
405
406        assert!(
407            result.contains("<img src=\"data:image/jpeg;base64,abcd1234\""),
408            "Should create data URI with correct MIME type"
409        );
410    }
411
412    #[test]
413    fn test_txt2html_sig_then_body_uses_sig_class() {
414        let config = make_config();
415        let mut in_sig = false;
416        let _ = txt2html_line("-- ", &config, &mut in_sig);
417        assert!(in_sig);
418        let result = txt2html_line("John Doe", &config, &mut in_sig);
419        assert_eq!(result, "<div class=\"hm-sig-text\">John Doe</div>\n");
420    }
421
422    #[test]
423    fn test_conv_body_line_plain() {
424        let config = make_config();
425        let result = conv_body_line("Hello world", &config);
426        assert_eq!(result, "<div class=\"hm-pg\">Hello world</div>\n");
427    }
428
429    #[test]
430    fn test_conv_body_line_quote() {
431        let config = make_config();
432        let result = conv_body_line("> quoted", &config);
433        assert!(result.contains("hm-quote-1"));
434        assert!(result.contains("quoted"));
435    }
436
437    #[test]
438    fn test_conv_body_line_sig() {
439        let config = make_config();
440        let result = conv_body_line("-- ", &config);
441        assert_eq!(result, "<hr class=\"hm-sig\">\n");
442    }
443
444    #[test]
445    fn test_conv_body_line_escapes_html() {
446        let config = make_config();
447        let result = conv_body_line("<b>bold</b>", &config);
448        assert!(result.contains("&lt;b&gt;"));
449        assert!(!result.contains("<b>"));
450    }
451
452    #[test]
453    fn test_escape_html_quote() {
454        assert_eq!(escape_html("it's"), "it&#39;s");
455    }
456
457    #[test]
458    fn test_escape_html_double_quote() {
459        assert_eq!(escape_html(r#"say "hi""#), "say &quot;hi&quot;");
460    }
461
462    #[test]
463    fn test_escape_html_tab_expanded() {
464        let result = escape_html("a\tb");
465        assert!(result.contains("        ")); // 8 spaces
466        assert!(!result.contains('\t'));
467    }
468
469    #[test]
470    fn test_inline_image_svg_blocked() {
471        let config = make_config();
472        let mut in_sig = false;
473        let line = "[INLINE_IMAGE:image/svg+xml:PHN2Zy8+]";
474        let result = txt2html_line(line, &config, &mut in_sig);
475        // SVG is not in the allowlist; should NOT produce an <img> tag
476        assert!(!result.contains("<img"), "SVG should be blocked from inline embedding");
477    }
478}