Skip to main content

ecr_store/
mime.rs

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    /// Whether the message actually carries a text/html part. mail-parser will
10    /// synthesise HTML from a text part, and serving that costs the client an
11    /// iframe, a sandbox and a resize for a message with no markup in it.
12    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    /// True when the message carries real markup, not text dressed up as HTML.
139    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
170/// Rewrites a sender's dark-mode block so it can never match.
171///
172/// Messages are rendered on white regardless of the app theme, so a
173/// `prefers-color-scheme: dark` block would style light-on-dark text over a
174/// light canvas — invisible. Renaming the feature leaves the stylesheet
175/// otherwise intact rather than dropping rules the message needs.
176fn 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        // Only the dark branch is neutralised; a light branch is what we want.
187        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
206/// Applies `f` to the contents of every `<style>` element.
207fn 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
249/// ammonia's default allowlist is written for user comments, not for mail.
250/// It drops `<table>`, `<style>`, width/height/align and the inline colours
251/// that almost every real message is built from, which is why messages came
252/// out as unstyled runs of text. Layout and presentation are allowed back in;
253/// what stays banned is anything that can execute or navigate on its own.
254fn 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        // Keeping <style> means keeping its contents; ammonia strips the text
317        // of unknown tags otherwise, which leaves a stylesheet-shaped hole.
318        .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        // mail-parser will happily synthesise HTML from a text part. Doing so
713        // costs an iframe, a sandbox and a resize for a message that has no
714        // markup at all, so the client is told what it really is.
715        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        // The client uses this to decide whether offering "as html" is honest.
777        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}