1use crate::error::{Error, Result};
2use ecr_core::message::{Body, BodyFormat, Disposition, Part, PartId, PartMeta};
3use mail_parser::{MessageParser, MessagePart, MimeHeaders, PartType};
4
5pub struct ParsedMessage {
6 parts: Vec<StoredPart>,
7 html: Option<String>,
8 text: Option<String>,
9 has_html_part: bool,
13}
14
15struct StoredPart {
16 meta: PartMeta,
17 bytes: Vec<u8>,
18}
19
20pub fn parse(id: &str, raw: &[u8]) -> Result<ParsedMessage> {
21 let parsed = MessageParser::default()
22 .parse(raw)
23 .ok_or_else(|| Error::MessageParse {
24 id: id.to_string(),
25 message: "not a well-formed RFC 5322 message".to_string(),
26 })?;
27
28 let html = parsed.body_html(0).map(|c| c.into_owned());
29 let text = parsed.body_text(0).map(|c| c.into_owned());
30
31 let has_html_part = parsed
32 .parts
33 .iter()
34 .any(|p| matches!(p.body, PartType::Html(_)));
35
36 let mut parts = Vec::new();
37 for (index, part) in parsed.parts.iter().enumerate() {
38 if part.is_multipart() {
39 continue;
40 }
41 if let Some(stored) = store_part(index as u32, part) {
42 parts.push(stored);
43 }
44 }
45
46 Ok(ParsedMessage {
47 parts,
48 html,
49 text,
50 has_html_part,
51 })
52}
53
54fn store_part(index: u32, part: &MessagePart<'_>) -> Option<StoredPart> {
55 let content_type = part
56 .content_type()
57 .map(|ct| match ct.subtype() {
58 Some(sub) => format!("{}/{}", ct.ctype(), sub),
59 None => ct.ctype().to_string(),
60 })
61 .unwrap_or_else(|| default_content_type(part).to_string());
62
63 let content_id = part
64 .content_id()
65 .map(|id| id.trim_matches(['<', '>']).to_string());
66
67 let is_attachment = part
68 .content_disposition()
69 .is_some_and(|d| d.ctype().eq_ignore_ascii_case("attachment"));
70
71 let bytes = match &part.body {
72 PartType::Text(text) | PartType::Html(text) => text.as_bytes().to_vec(),
73 PartType::Binary(data) | PartType::InlineBinary(data) => data.to_vec(),
74 PartType::Message(_) | PartType::Multipart(_) => return None,
75 };
76
77 Some(StoredPart {
78 meta: PartMeta {
79 id: PartId(index),
80 size: bytes.len(),
81 content_type,
82 filename: part.attachment_name().map(str::to_string),
83 disposition: if is_attachment {
84 Disposition::Attachment
85 } else {
86 Disposition::Inline
87 },
88 content_id,
89 },
90 bytes,
91 })
92}
93
94fn default_content_type(part: &MessagePart<'_>) -> &'static str {
95 match &part.body {
96 PartType::Html(_) => "text/html",
97 PartType::Text(_) => "text/plain",
98 _ => "application/octet-stream",
99 }
100}
101
102impl ParsedMessage {
103 pub fn parts(&self) -> Vec<PartMeta> {
104 self.parts.iter().map(|p| p.meta.clone()).collect()
105 }
106
107 pub fn part(&self, id: &PartId) -> Option<Part> {
108 self.parts.iter().find(|p| &p.meta.id == id).map(|p| Part {
109 meta: p.meta.clone(),
110 bytes: p.bytes.clone(),
111 })
112 }
113
114 pub fn part_by_content_id(&self, content_id: &str) -> Option<&PartMeta> {
115 self.parts
116 .iter()
117 .find(|p| p.meta.content_id.as_deref() == Some(content_id))
118 .map(|p| &p.meta)
119 }
120
121 pub fn text(&self) -> Option<&str> {
122 self.text.as_deref()
123 }
124
125 pub fn html(&self) -> Option<&str> {
126 self.html.as_deref()
127 }
128
129 fn as_text(&self) -> Body {
130 Body {
131 format: BodyFormat::Text,
132 content: self.text.clone().unwrap_or_default(),
133 remote_resources_blocked: 0,
134 has_html: self.has_html_part,
135 }
136 }
137
138 pub fn is_html(&self) -> bool {
140 self.has_html_part
141 }
142
143 pub fn body(&self, format: BodyFormat, ctx: &SanitizeContext) -> Body {
144 match format {
145 BodyFormat::Text => self.as_text(),
146 BodyFormat::Html if self.has_html_part => match &self.html {
147 Some(html) => sanitize(html, self, ctx),
148 None => self.as_text(),
149 },
150 BodyFormat::Html => self.as_text(),
151 }
152 }
153}
154
155#[derive(Debug, Clone)]
156pub struct SanitizeContext {
157 pub part_url_prefix: String,
158 pub allow_remote_resources: bool,
159}
160
161impl SanitizeContext {
162 pub fn new(part_url_prefix: impl Into<String>, allow_remote_resources: bool) -> Self {
163 Self {
164 part_url_prefix: part_url_prefix.into(),
165 allow_remote_resources,
166 }
167 }
168}
169
170fn neutralize_dark_mode(css: &str) -> String {
177 let lower = css.to_ascii_lowercase();
178 let needle = "prefers-color-scheme";
179 let mut out = String::with_capacity(css.len());
180 let mut at = 0;
181
182 while let Some(found) = lower[at..].find(needle) {
183 let start = at + found;
184 let after = start + needle.len();
185
186 let value_end = lower[after..]
188 .find(')')
189 .map(|i| after + i)
190 .unwrap_or(lower.len());
191 let value = &lower[after..value_end];
192
193 out.push_str(&css[at..start]);
194 if value.contains("dark") {
195 out.push_str("ecr-neutralised-color-scheme");
196 } else {
197 out.push_str(&css[start..after]);
198 }
199 at = after;
200 }
201
202 out.push_str(&css[at..]);
203 out
204}
205
206fn map_style_blocks(html: &str, f: impl Fn(&str) -> String) -> String {
208 let lower = html.to_ascii_lowercase();
209 let mut out = String::with_capacity(html.len());
210 let mut at = 0;
211
212 while let Some(found) = lower[at..].find("<style") {
213 let tag_start = at + found;
214 let Some(open_end) = lower[tag_start..].find('>').map(|i| tag_start + i + 1) else {
215 break;
216 };
217 let Some(close) = lower[open_end..].find("</style").map(|i| open_end + i) else {
218 break;
219 };
220
221 out.push_str(&html[at..open_end]);
222 out.push_str(&f(&html[open_end..close]));
223 at = close;
224 }
225
226 out.push_str(&html[at..]);
227 out
228}
229
230fn sanitize(html: &str, message: &ParsedMessage, ctx: &SanitizeContext) -> Body {
231 let html = &map_style_blocks(html, neutralize_dark_mode);
232 let rewritten = rewrite_cid_references(html, message, ctx);
233 let (stripped, blocked) = if ctx.allow_remote_resources {
234 (rewritten, 0)
235 } else {
236 strip_remote_resources(&rewritten)
237 };
238
239 let cleaned = sanitizer().clean(&stripped).to_string();
240
241 Body {
242 format: BodyFormat::Html,
243 content: cleaned,
244 remote_resources_blocked: blocked,
245 has_html: true,
246 }
247}
248
249fn sanitizer() -> ammonia::Builder<'static> {
255 let mut builder = ammonia::Builder::default();
256
257 builder
258 .add_tags([
259 "table",
260 "thead",
261 "tbody",
262 "tfoot",
263 "tr",
264 "td",
265 "th",
266 "caption",
267 "colgroup",
268 "col",
269 "center",
270 "font",
271 "style",
272 "span",
273 "div",
274 "section",
275 "article",
276 "header",
277 "footer",
278 "figure",
279 "figcaption",
280 "picture",
281 "source",
282 "map",
283 "area",
284 "big",
285 "small",
286 "s",
287 "strike",
288 "u",
289 "address",
290 ])
291 .add_generic_attributes([
292 "style",
293 "align",
294 "valign",
295 "width",
296 "height",
297 "bgcolor",
298 "color",
299 "background",
300 "border",
301 "cellpadding",
302 "cellspacing",
303 "colspan",
304 "rowspan",
305 "face",
306 "size",
307 "dir",
308 "lang",
309 "title",
310 "class",
311 "id",
312 ])
313 .add_tag_attributes("img", ["srcset", "sizes", "loading", "usemap"])
314 .add_tag_attributes("a", ["target"])
315 .add_tag_attributes("table", ["summary"])
316 .clean_content_tags(std::collections::HashSet::from([
319 "script", "iframe", "object", "embed", "applet", "form", "title",
320 ]))
321 .link_rel(Some("noopener noreferrer"))
322 .url_relative(ammonia::UrlRelative::PassThrough);
323
324 builder
325}
326
327fn rewrite_cid_references(html: &str, message: &ParsedMessage, ctx: &SanitizeContext) -> String {
328 let mut out = String::with_capacity(html.len());
329 let mut rest = html;
330
331 while let Some(pos) = rest.find("cid:") {
332 out.push_str(&rest[..pos]);
333 let after = &rest[pos + 4..];
334 let end = after
335 .find(|c: char| c == '"' || c == '\'' || c.is_whitespace() || c == '>')
336 .unwrap_or(after.len());
337 let cid = &after[..end];
338
339 match message.part_by_content_id(cid) {
340 Some(meta) => {
341 out.push_str(&ctx.part_url_prefix);
342 out.push_str(&meta.id.to_string());
343 }
344 None => {
345 out.push_str("cid:");
346 out.push_str(cid);
347 }
348 }
349 rest = &after[end..];
350 }
351 out.push_str(rest);
352 out
353}
354
355fn strip_remote_resources(html: &str) -> (String, usize) {
356 let mut out = String::with_capacity(html.len());
357 let mut blocked = 0;
358 let mut rest = html;
359
360 while let Some(pos) = rest.find("<img") {
361 let tag_end = match rest[pos..].find('>') {
362 Some(end) => pos + end + 1,
363 None => break,
364 };
365 let tag = &rest[pos..tag_end];
366
367 out.push_str(&rest[..pos]);
368 if tag.contains("src=\"http") || tag.contains("src='http") {
369 blocked += 1;
370 } else {
371 out.push_str(tag);
372 }
373 rest = &rest[tag_end..];
374 }
375 out.push_str(rest);
376 (out, blocked)
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 fn fixture(name: &str) -> Vec<u8> {
384 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
385 .join("../../fixtures/mime")
386 .join(name);
387 std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
388 }
389
390 fn related() -> ParsedMessage {
391 parse("mime1@example.com", &fixture("multipart_related.eml")).unwrap()
392 }
393
394 fn ctx(allow_remote: bool) -> SanitizeContext {
395 SanitizeContext::new("/api/v1/messages/mime1@example.com/parts/", allow_remote)
396 }
397
398 #[test]
399 fn decodes_quoted_printable_and_utf8_in_the_text_body() {
400 let text = related().text().unwrap().to_string();
401 assert!(text.contains("Plain text fallback"), "{text}");
402 assert!(text.contains('☁'), "{text}");
403 }
404
405 #[test]
406 fn prefers_the_html_alternative_for_the_html_body() {
407 let html = related().html().unwrap().to_string();
408 assert!(html.contains("Rich <b>HTML</b> body"), "{html}");
409 }
410
411 #[test]
412 fn decodes_a_latin1_body_into_utf8() {
413 let message = parse("mime2@example.com", &fixture("latin1_plain.eml")).unwrap();
414 let text = message.text().unwrap();
415
416 assert!(text.contains("Café au lait"), "{text}");
417 assert!(text.contains("coûte"), "{text}");
418 }
419
420 #[test]
421 fn finds_the_inline_image_and_the_attachment() {
422 let message = related();
423 let parts = message.parts();
424
425 let logo = parts
426 .iter()
427 .find(|p| p.content_id.as_deref() == Some("logo@example.com"))
428 .expect("inline logo");
429 assert_eq!(logo.content_type, "image/png");
430 assert_eq!(logo.disposition, Disposition::Inline);
431 assert!(logo.is_image());
432
433 let pdf = parts
434 .iter()
435 .find(|p| p.disposition == Disposition::Attachment)
436 .expect("attachment");
437 assert_eq!(pdf.content_type, "application/pdf");
438 assert_eq!(pdf.filename.as_deref(), Some("report.pdf"));
439 }
440
441 #[test]
442 fn part_bytes_round_trip_to_the_real_payload() {
443 let message = related();
444 let pdf_meta = message
445 .parts()
446 .into_iter()
447 .find(|p| p.filename.as_deref() == Some("report.pdf"))
448 .unwrap();
449
450 let part = message.part(&pdf_meta.id).unwrap();
451 assert!(
452 part.bytes.starts_with(b"%PDF-1.4"),
453 "{:?}",
454 &part.bytes[..8]
455 );
456 }
457
458 #[test]
459 fn cid_references_are_rewritten_to_the_parts_endpoint() {
460 let message = related();
461 let body = message.body(BodyFormat::Html, &ctx(true));
462
463 assert!(
464 body.content
465 .contains("/api/v1/messages/mime1@example.com/parts/"),
466 "{}",
467 body.content
468 );
469 assert!(
470 !body.content.contains("cid:logo@example.com"),
471 "{}",
472 body.content
473 );
474 }
475
476 #[test]
477 fn scripts_and_event_handlers_are_removed() {
478 let body = related().body(BodyFormat::Html, &ctx(true));
479
480 assert!(!body.content.contains("<script"), "{}", body.content);
481 assert!(!body.content.contains("alert("), "{}", body.content);
482 assert!(!body.content.contains("onclick"), "{}", body.content);
483 assert!(body.content.contains("<a href"), "{}", body.content);
484 }
485
486 #[test]
487 fn remote_images_are_blocked_by_default_and_counted() {
488 let body = related().body(BodyFormat::Html, &ctx(false));
489
490 assert_eq!(body.remote_resources_blocked, 1);
491 assert!(
492 !body.content.contains("tracker.example.com"),
493 "{}",
494 body.content
495 );
496 }
497
498 #[test]
499 fn remote_images_survive_when_explicitly_allowed() {
500 let body = related().body(BodyFormat::Html, &ctx(true));
501
502 assert_eq!(body.remote_resources_blocked, 0);
503 assert!(
504 body.content.contains("tracker.example.com"),
505 "{}",
506 body.content
507 );
508 }
509
510 #[test]
511 fn inline_images_are_never_blocked_as_remote() {
512 let body = related().body(BodyFormat::Html, &ctx(false));
513 assert!(body.content.contains("/parts/"), "{}", body.content);
514 }
515
516 #[test]
517 fn an_html_only_message_still_yields_text() {
518 let message = parse("mime3@example.com", &fixture("html_only.eml")).unwrap();
519 let text = message.text().unwrap();
520
521 assert!(text.contains("Heading"), "{text}");
522 assert!(text.contains("Paragraph one."), "{text}");
523 }
524
525 #[test]
526 fn a_text_only_message_falls_back_to_text_when_html_is_requested() {
527 let message = parse("mime2@example.com", &fixture("latin1_plain.eml")).unwrap();
528 let body = message.body(BodyFormat::Html, &ctx(false));
529
530 assert!(body.content.contains("Café"), "{}", body.content);
531 }
532
533 #[test]
534 fn an_unknown_cid_is_left_alone_rather_than_pointing_at_a_wrong_part() {
535 let message = related();
536 let html = r#"<img src="cid:missing@example.com">"#;
537 let rewritten = rewrite_cid_references(html, &message, &ctx(true));
538
539 assert!(rewritten.contains("cid:missing@example.com"), "{rewritten}");
540 }
541
542 #[test]
543 fn garbage_input_yields_an_empty_message_rather_than_panicking() {
544 let message = parse("bad@example.com", &[0xff, 0xfe, 0x00, 0x01]).unwrap();
545
546 assert!(message.parts().iter().all(|p| p.size < 8));
547 assert!(message
548 .body(BodyFormat::Html, &ctx(false))
549 .content
550 .trim()
551 .is_empty());
552 }
553
554 #[test]
555 fn a_truncated_multipart_does_not_lose_the_parts_it_did_parse() {
556 let raw = fixture("multipart_related.eml");
557 let truncated = &raw[..raw.len() * 2 / 3];
558 let message = parse("mime1@example.com", truncated).unwrap();
559
560 assert!(message.html().is_some(), "html body should survive");
561 }
562}
563
564#[cfg(test)]
565mod sanitizer_tests {
566 use super::*;
567
568 fn clean(html: &str) -> String {
569 let message = parse(
570 "x@y.z",
571 format!("Content-Type: text/html\r\n\r\n{html}").as_bytes(),
572 )
573 .unwrap();
574 message
575 .body(BodyFormat::Html, &SanitizeContext::new("/parts/", true))
576 .content
577 }
578
579 #[test]
580 fn keeps_the_table_layout_real_mail_is_built_from() {
581 let html = clean(
582 r##"<table width="600" cellpadding="0"><tr><td align="center" bgcolor="#ffffff">Hi</td></tr></table>"##,
583 );
584 assert!(html.contains("<table"), "{html}");
585 assert!(html.contains("<td"), "{html}");
586 assert!(html.contains("bgcolor"), "{html}");
587 assert!(html.contains("align"), "{html}");
588 }
589
590 #[test]
591 fn keeps_inline_styles_and_the_stylesheet() {
592 let html = clean(r##"<style>.a{color:red}</style><p style="color:#333">text</p>"##);
593 assert!(html.contains("color:red"), "{html}");
594 assert!(html.contains("color:#333"), "{html}");
595 }
596
597 #[test]
598 fn keeps_presentational_tags_older_senders_still_use() {
599 let html = clean(r#"<center><font face="Arial" size="3">Sale</font></center>"#);
600 assert!(html.contains("<center"), "{html}");
601 assert!(html.contains("<font"), "{html}");
602 }
603
604 #[test]
605 fn keeps_image_sizing_so_layout_does_not_collapse() {
606 let html = clean(r#"<img src="https://x/y.png" width="600" height="80" alt="banner">"#);
607 assert!(html.contains("width=\"600\""), "{html}");
608 }
609
610 #[test]
611 fn still_removes_anything_that_can_execute() {
612 let html = clean(
613 r#"<script>alert(1)</script><p onclick="steal()">x</p><iframe src="//evil"></iframe>"#,
614 );
615 assert!(!html.contains("<script"), "{html}");
616 assert!(!html.contains("alert(1)"), "{html}");
617 assert!(!html.contains("onclick"), "{html}");
618 assert!(!html.contains("<iframe"), "{html}");
619 }
620
621 #[test]
622 fn still_removes_forms_so_credentials_cannot_be_phished_inline() {
623 let html = clean(r#"<form action="//evil"><input name="password"></form>"#);
624 assert!(!html.contains("<form"), "{html}");
625 assert!(!html.contains("<input"), "{html}");
626 }
627
628 #[test]
629 fn a_javascript_url_does_not_survive() {
630 let html = clean(r#"<a href="javascript:alert(1)">click</a>"#);
631 assert!(!html.contains("javascript:"), "{html}");
632 }
633}
634
635#[cfg(test)]
636mod dark_mode_tests {
637 use super::*;
638
639 fn clean(html: &str) -> String {
640 let message = parse(
641 "x@y.z",
642 format!("Content-Type: text/html\r\n\r\n{html}").as_bytes(),
643 )
644 .unwrap();
645 message
646 .body(BodyFormat::Html, &SanitizeContext::new("/parts/", true))
647 .content
648 }
649
650 #[test]
651 fn a_dark_mode_block_is_neutralised_so_it_cannot_match() {
652 let html = clean(
653 "<style>@media (prefers-color-scheme: dark) { body { background: #111; } }</style><p>hi</p>",
654 );
655 assert!(!html.contains("prefers-color-scheme: dark"), "{html}");
656 assert!(html.contains("hi"), "{html}");
657 }
658
659 #[test]
660 fn spacing_and_quoting_variants_are_caught_too() {
661 for query in [
662 "@media(prefers-color-scheme:dark)",
663 "@media screen and (prefers-color-scheme: dark)",
664 "@media (prefers-color-scheme:DARK)",
665 ] {
666 let html = clean(&format!(
667 "<style>{query} {{ body {{ color: #fff; }} }}</style>"
668 ));
669 assert!(
670 !html.to_lowercase().contains("prefers-color-scheme:dark")
671 && !html.to_lowercase().contains("prefers-color-scheme: dark"),
672 "{query} survived: {html}"
673 );
674 }
675 }
676
677 #[test]
678 fn a_light_mode_block_is_left_alone() {
679 let html =
680 clean("<style>@media (prefers-color-scheme: light) { p { color: #222; } }</style>");
681 assert!(html.contains("prefers-color-scheme: light"), "{html}");
682 }
683
684 #[test]
685 fn ordinary_media_queries_are_untouched() {
686 let html = clean("<style>@media (max-width: 600px) { p { font-size: 12px; } }</style>");
687 assert!(html.contains("max-width: 600px"), "{html}");
688 }
689
690 #[test]
691 fn the_rest_of_a_stylesheet_survives_neutralisation() {
692 let html = clean(
693 "<style>p{color:#131517}@media (prefers-color-scheme: dark){p{color:#fff}}h1{margin:0}</style>",
694 );
695 assert!(html.contains("#131517"), "{html}");
696 assert!(html.contains("margin:0"), "{html}");
697 }
698}
699
700#[cfg(test)]
701mod format_tests {
702 use super::*;
703
704 fn body_of(raw: &str, format: BodyFormat) -> Body {
705 parse("x@y.z", raw.as_bytes())
706 .unwrap()
707 .body(format, &SanitizeContext::new("/parts/", true))
708 }
709
710 #[test]
711 fn a_text_only_message_is_served_as_text_even_when_html_is_asked_for() {
712 let body = body_of(
716 "Content-Type: text/plain\r\n\r\nA single email with no replies.",
717 BodyFormat::Html,
718 );
719
720 assert_eq!(body.format, BodyFormat::Text);
721 assert!(body.content.contains("no replies"));
722 assert!(!body.content.contains("<div"), "{}", body.content);
723 }
724
725 #[test]
726 fn a_real_html_message_is_still_served_as_html() {
727 let body = body_of(
728 "Content-Type: text/html\r\n\r\n<p>Hello <b>there</b></p>",
729 BodyFormat::Html,
730 );
731
732 assert_eq!(body.format, BodyFormat::Html);
733 assert!(body.content.contains("<b>"), "{}", body.content);
734 }
735
736 #[test]
737 fn a_multipart_alternative_still_prefers_its_html_part() {
738 let raw = "Content-Type: multipart/alternative; boundary=B\r\n\r\n\
739 --B\r\nContent-Type: text/plain\r\n\r\nplain\r\n\
740 --B\r\nContent-Type: text/html\r\n\r\n<p>rich</p>\r\n--B--\r\n";
741 let body = body_of(raw, BodyFormat::Html);
742
743 assert_eq!(body.format, BodyFormat::Html);
744 assert!(body.content.contains("rich"), "{}", body.content);
745 }
746
747 #[test]
748 fn asking_for_text_always_gets_text() {
749 let body = body_of(
750 "Content-Type: text/html\r\n\r\n<p>Hello</p>",
751 BodyFormat::Text,
752 );
753 assert_eq!(body.format, BodyFormat::Text);
754 }
755
756 #[test]
757 fn a_message_with_no_body_at_all_is_empty_text() {
758 let body = body_of("Subject: nothing\r\n\r\n", BodyFormat::Html);
759 assert_eq!(body.format, BodyFormat::Text);
760 assert_eq!(body.content.trim(), "");
761 }
762}
763
764#[cfg(test)]
765mod alternative_tests {
766 use super::*;
767
768 fn body_of(raw: &str) -> Body {
769 parse("x@y.z", raw.as_bytes())
770 .unwrap()
771 .body(BodyFormat::Text, &SanitizeContext::new("/parts/", true))
772 }
773
774 #[test]
775 fn a_text_only_message_reports_no_html_alternative() {
776 assert!(!body_of("Content-Type: text/plain\r\n\r\nplain").has_html);
778 }
779
780 #[test]
781 fn an_html_message_reports_one() {
782 assert!(body_of("Content-Type: text/html\r\n\r\n<p>hi</p>").has_html);
783 }
784
785 #[test]
786 fn a_multipart_alternative_reports_one() {
787 let raw = "Content-Type: multipart/alternative; boundary=B\r\n\r\n\
788 --B\r\nContent-Type: text/plain\r\n\r\nplain\r\n\
789 --B\r\nContent-Type: text/html\r\n\r\n<p>rich</p>\r\n--B--\r\n";
790 assert!(body_of(raw).has_html);
791 }
792
793 #[test]
794 fn the_flag_does_not_depend_on_which_format_was_asked_for() {
795 let parsed = parse("x@y.z", b"Content-Type: text/html\r\n\r\n<p>hi</p>").unwrap();
796 let ctx = SanitizeContext::new("/parts/", true);
797
798 assert!(parsed.body(BodyFormat::Html, &ctx).has_html);
799 assert!(parsed.body(BodyFormat::Text, &ctx).has_html);
800 }
801}