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 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 let pg_class = if *in_sig { "hm-sig-text" } else { "hm-pg" };
33
34 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 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 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 }
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("&"),
90 '<' => result.push_str("<"),
91 '>' => result.push_str(">"),
92 '"' => result.push_str("""),
93 '\'' => result.push_str("'"),
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 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 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 if showhtml == 0 {
143 let escaped = escape_html(&b.line);
144 b.line = escaped;
145 continue;
146 }
147 if showhtml == 1 {
148 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 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>"), "<test>");
232 assert_eq!(escape_html("a&b"), "a&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, "<script>alert('xss')</script>");
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\"><script>bad</script></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 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 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("<b>"));
449 assert!(!result.contains("<b>"));
450 }
451
452 #[test]
453 fn test_escape_html_quote() {
454 assert_eq!(escape_html("it's"), "it's");
455 }
456
457 #[test]
458 fn test_escape_html_double_quote() {
459 assert_eq!(escape_html(r#"say "hi""#), "say "hi"");
460 }
461
462 #[test]
463 fn test_escape_html_tab_expanded() {
464 let result = escape_html("a\tb");
465 assert!(result.contains(" ")); 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 assert!(!result.contains("<img"), "SVG should be blocked from inline embedding");
477 }
478}