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