mailparsing/
mimepart.rs

1use crate::header::{HeaderParseResult, MessageConformance};
2use crate::headermap::HeaderMap;
3use crate::strings::IntoSharedString;
4use crate::{
5    has_lone_cr_or_lf, Header, MailParsingError, MessageID, MimeParameterEncoding, MimeParameters,
6    Result, SharedString,
7};
8use charset::Charset;
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11use std::str::FromStr;
12
13/// Define our own because data_encoding::BASE64_MIME, despite its name,
14/// is not RFC2045 compliant, and will not ignore spaces
15const BASE64_RFC2045: data_encoding::Encoding = data_encoding_macro::new_encoding! {
16    symbols: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
17    padding: '=',
18    ignore: " \r\n\t",
19    wrap_width: 76,
20    wrap_separator: "\r\n",
21};
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct MimePart<'a> {
25    /// The bytes that comprise this part, from its beginning to its end
26    bytes: SharedString<'a>,
27    /// The parsed headers from the start of bytes
28    headers: HeaderMap<'a>,
29    /// The index into bytes of the first non-header byte.
30    body_offset: usize,
31    body_len: usize,
32    conformance: MessageConformance,
33    parts: Vec<Self>,
34    /// For multipart, the content the precedes the first boundary
35    intro: SharedString<'a>,
36    /// For multipart, the content the follows the last boundary
37    outro: SharedString<'a>,
38}
39
40#[derive(PartialEq, Debug)]
41pub struct Rfc2045Info {
42    pub encoding: ContentTransferEncoding,
43    pub charset: Result<Charset>,
44    pub content_type: Option<MimeParameters>,
45    pub is_text: bool,
46    pub is_multipart: bool,
47    pub attachment_options: Option<AttachmentOptions>,
48    pub invalid_mime_headers: bool,
49}
50
51impl Rfc2045Info {
52    // This must be infallible so that a basic mime structure can be parsed
53    // even if the mime headers are a bit borked
54    fn new(headers: &HeaderMap) -> Self {
55        let mut invalid_mime_headers = false;
56        let encoding = match headers.content_transfer_encoding() {
57            Ok(Some(cte)) => match ContentTransferEncoding::from_str(&cte.value) {
58                Ok(encoding) => encoding,
59                Err(_) => {
60                    invalid_mime_headers = true;
61                    ContentTransferEncoding::SevenBit
62                }
63            },
64            Ok(None) => ContentTransferEncoding::SevenBit,
65            Err(_) => {
66                invalid_mime_headers = true;
67                ContentTransferEncoding::SevenBit
68            }
69        };
70
71        let content_type = match headers.content_type() {
72            Ok(ct) => ct,
73            Err(_) => {
74                invalid_mime_headers = true;
75                None
76            }
77        };
78
79        let mut ct_name = None;
80        let charset = if let Some(ct) = &content_type {
81            ct_name = ct.get("name");
82            ct.get("charset")
83        } else {
84            None
85        };
86        let charset = charset.unwrap_or_else(|| "us-ascii".to_string());
87
88        let charset = Charset::for_label_no_replacement(charset.as_bytes())
89            .ok_or_else(|| MailParsingError::BodyParse(format!("unsupported charset {charset}")));
90
91        let (is_text, is_multipart) = if let Some(ct) = &content_type {
92            (ct.is_text(), ct.is_multipart())
93        } else {
94            (true, false)
95        };
96
97        let mut inline = false;
98        let mut cd_file_name = None;
99
100        match headers.content_disposition() {
101            Ok(Some(cd)) => {
102                inline = cd.value == "inline";
103                cd_file_name = cd.get("filename");
104            }
105            Ok(None) => {}
106            Err(_) => {
107                invalid_mime_headers = true;
108            }
109        };
110
111        let content_id = match headers.content_id() {
112            Ok(cid) => cid.map(|cid| cid.0),
113            Err(_) => {
114                invalid_mime_headers = true;
115                None
116            }
117        };
118
119        let file_name = match (cd_file_name, ct_name) {
120            (Some(name), _) | (None, Some(name)) => Some(name),
121            (None, None) => None,
122        };
123
124        let attachment_options = if inline || file_name.is_some() || content_id.is_some() {
125            Some(AttachmentOptions {
126                file_name,
127                inline,
128                content_id,
129            })
130        } else {
131            None
132        };
133
134        Self {
135            encoding,
136            charset,
137            content_type,
138            is_text,
139            is_multipart,
140            attachment_options,
141            invalid_mime_headers,
142        }
143    }
144}
145
146impl<'a> MimePart<'a> {
147    /// Parse some data into a tree of MimeParts
148    pub fn parse<S>(bytes: S) -> Result<Self>
149    where
150        S: IntoSharedString<'a>,
151    {
152        let (bytes, base_conformance) = bytes.into_shared_string();
153        Self::parse_impl(bytes, base_conformance, true)
154    }
155
156    /// Obtain a version of self that has a static lifetime
157    pub fn to_owned(&self) -> MimePart<'static> {
158        MimePart {
159            bytes: self.bytes.to_owned(),
160            headers: self.headers.to_owned(),
161            body_offset: self.body_offset,
162            body_len: self.body_len,
163            conformance: self.conformance,
164            parts: self.parts.iter().map(|p| p.to_owned()).collect(),
165            intro: self.intro.to_owned(),
166            outro: self.outro.to_owned(),
167        }
168    }
169
170    fn parse_impl(
171        bytes: SharedString<'a>,
172        base_conformance: MessageConformance,
173        is_top_level: bool,
174    ) -> Result<Self> {
175        let HeaderParseResult {
176            headers,
177            body_offset,
178            overall_conformance: mut conformance,
179        } = Header::parse_headers(bytes.clone())?;
180
181        conformance |= base_conformance;
182
183        let body_len = bytes.len();
184
185        if !bytes.is_ascii() {
186            conformance.set(MessageConformance::NEEDS_TRANSFER_ENCODING, true);
187        }
188        {
189            let mut prev = 0;
190            for idx in memchr::memchr_iter(b'\n', bytes.as_bytes()) {
191                if idx - prev > 78 {
192                    conformance.set(MessageConformance::LINE_TOO_LONG, true);
193                    break;
194                }
195                prev = idx;
196            }
197        }
198        conformance.set(
199            MessageConformance::NON_CANONICAL_LINE_ENDINGS,
200            has_lone_cr_or_lf(bytes.as_bytes()),
201        );
202
203        if is_top_level {
204            conformance.set(
205                MessageConformance::MISSING_DATE_HEADER,
206                !matches!(headers.date(), Ok(Some(_))),
207            );
208            conformance.set(
209                MessageConformance::MISSING_MESSAGE_ID_HEADER,
210                !matches!(headers.message_id(), Ok(Some(_))),
211            );
212            conformance.set(
213                MessageConformance::MISSING_MIME_VERSION,
214                match headers.mime_version() {
215                    Ok(Some(v)) => v.as_str() != "1.0",
216                    _ => true,
217                },
218            );
219        }
220
221        let mut part = Self {
222            bytes,
223            headers,
224            body_offset,
225            body_len,
226            conformance,
227            parts: vec![],
228            intro: SharedString::Borrowed(""),
229            outro: SharedString::Borrowed(""),
230        };
231
232        part.recursive_parse()?;
233
234        Ok(part)
235    }
236
237    fn recursive_parse(&mut self) -> Result<()> {
238        let info = Rfc2045Info::new(&self.headers);
239        if info.invalid_mime_headers {
240            self.conformance |= MessageConformance::INVALID_MIME_HEADERS;
241        }
242        if let Some((boundary, true)) = info
243            .content_type
244            .as_ref()
245            .and_then(|ct| ct.get("boundary").map(|b| (b, info.is_multipart)))
246        {
247            let boundary = format!("\n--{boundary}");
248            let raw_body = self
249                .bytes
250                .slice(self.body_offset.saturating_sub(1)..self.bytes.len());
251
252            let mut iter = memchr::memmem::find_iter(raw_body.as_bytes(), &boundary);
253            if let Some(first_boundary_pos) = iter.next() {
254                self.intro = raw_body.slice(0..first_boundary_pos);
255
256                // When we create parts, we ignore the original body span in
257                // favor of what we're parsing out here now
258                self.body_len = 0;
259
260                let mut boundary_end = first_boundary_pos + boundary.len();
261
262                while let Some(part_start) =
263                    memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
264                        .map(|p| p + boundary_end + 1)
265                {
266                    let part_end = iter
267                        .next()
268                        .map(|p| {
269                            // P is the newline; we want to include it in the raw
270                            // bytes for this part, so look beyond it
271                            p + 1
272                        })
273                        .unwrap_or(raw_body.len());
274
275                    let child = Self::parse_impl(
276                        raw_body.slice(part_start..part_end),
277                        MessageConformance::default(),
278                        false,
279                    )?;
280                    self.conformance |= child.conformance;
281                    self.parts.push(child);
282
283                    boundary_end = part_end -
284                        1 /* newline we adjusted for when assigning part_end */
285                        + boundary.len();
286
287                    if boundary_end + 2 > raw_body.len() {
288                        break;
289                    }
290                    if &raw_body.as_bytes()[boundary_end..boundary_end + 2] == b"--" {
291                        if let Some(after_boundary) =
292                            memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
293                                .map(|p| p + boundary_end + 1)
294                        {
295                            self.outro = raw_body.slice(after_boundary..raw_body.len());
296                        }
297                        break;
298                    }
299                }
300            }
301        }
302
303        Ok(())
304    }
305
306    pub fn conformance(&self) -> MessageConformance {
307        self.conformance
308    }
309
310    /// Obtain a reference to the child parts
311    pub fn child_parts(&self) -> &[Self] {
312        &self.parts
313    }
314
315    /// Obtain a mutable reference to the child parts
316    pub fn child_parts_mut(&mut self) -> &mut Vec<Self> {
317        &mut self.parts
318    }
319
320    /// Obtains a reference to the headers
321    pub fn headers(&'_ self) -> &'_ HeaderMap<'_> {
322        &self.headers
323    }
324
325    /// Obtain a mutable reference to the headers
326    pub fn headers_mut<'b>(&'b mut self) -> &'b mut HeaderMap<'a> {
327        &mut self.headers
328    }
329
330    /// Get the raw, transfer-encoded body
331    pub fn raw_body(&'_ self) -> SharedString<'_> {
332        self.bytes
333            .slice(self.body_offset..self.body_len.max(self.body_offset))
334    }
335
336    pub fn rfc2045_info(&self) -> Rfc2045Info {
337        Rfc2045Info::new(&self.headers)
338    }
339
340    /// Decode transfer decoding and return the body
341    pub fn body(&'_ self) -> Result<DecodedBody<'_>> {
342        let info = Rfc2045Info::new(&self.headers);
343
344        let bytes = match info.encoding {
345            ContentTransferEncoding::Base64 => {
346                let data = self.raw_body();
347                let bytes = data.as_bytes();
348                BASE64_RFC2045.decode(bytes).map_err(|err| {
349                    let b = bytes[err.position] as char;
350                    let region =
351                        &bytes[err.position.saturating_sub(8)..(err.position + 8).min(bytes.len())];
352                    let region = String::from_utf8_lossy(region);
353                    MailParsingError::BodyParse(format!(
354                        "base64 decode: {err:#} b={b:?} in {region}"
355                    ))
356                })?
357            }
358            ContentTransferEncoding::QuotedPrintable => quoted_printable::decode(
359                self.raw_body().as_bytes(),
360                quoted_printable::ParseMode::Robust,
361            )
362            .map_err(|err| {
363                MailParsingError::BodyParse(format!("quoted printable decode: {err:#}"))
364            })?,
365            ContentTransferEncoding::SevenBit
366            | ContentTransferEncoding::EightBit
367            | ContentTransferEncoding::Binary
368                if info.is_text =>
369            {
370                return Ok(DecodedBody::Text(self.raw_body()));
371            }
372            ContentTransferEncoding::SevenBit
373            | ContentTransferEncoding::EightBit
374            | ContentTransferEncoding::Binary => {
375                return Ok(DecodedBody::Binary(self.raw_body().as_bytes().to_vec()))
376            }
377        };
378
379        if info.is_text {
380            let (decoded, _malformed) = info.charset?.decode_without_bom_handling(&bytes);
381            Ok(DecodedBody::Text(decoded.to_string().into()))
382        } else {
383            Ok(DecodedBody::Binary(bytes))
384        }
385    }
386
387    /// Re-constitute the message.
388    /// Each element will be parsed out, and the parsed form used
389    /// to build a new message.
390    /// This has the side effect of "fixing" non-conforming elements,
391    /// but may come at the cost of "losing" the non-sensical or otherwise
392    /// out of spec elements in the rebuilt message
393    pub fn rebuild(&self) -> Result<Self> {
394        let info = Rfc2045Info::new(&self.headers);
395
396        let mut children = vec![];
397        for part in &self.parts {
398            children.push(part.rebuild()?);
399        }
400
401        let mut rebuilt = if children.is_empty() {
402            let body = self.body()?;
403            match body {
404                DecodedBody::Text(text) => {
405                    let ct = info
406                        .content_type
407                        .as_ref()
408                        .map(|ct| ct.value.as_str())
409                        .unwrap_or("text/plain");
410                    Self::new_text(ct, text.as_str())?
411                }
412                DecodedBody::Binary(data) => {
413                    let ct = info
414                        .content_type
415                        .as_ref()
416                        .map(|ct| ct.value.as_str())
417                        .unwrap_or("application/octet-stream");
418                    Self::new_binary(ct, &data, info.attachment_options.as_ref())?
419                }
420            }
421        } else {
422            let ct = info.content_type.ok_or_else(|| {
423                MailParsingError::BodyParse(
424                    "multipart message has no content-type information!?".to_string(),
425                )
426            })?;
427            Self::new_multipart(&ct.value, children, ct.get("boundary").as_deref())?
428        };
429
430        for hdr in self.headers.iter() {
431            // Skip rfc2045 associated headers; we already rebuilt
432            // those above
433            let name = hdr.get_name();
434            if name.eq_ignore_ascii_case("Content-Type")
435                || name.eq_ignore_ascii_case("Content-Transfer-Encoding")
436                || name.eq_ignore_ascii_case("Content-Disposition")
437                || name.eq_ignore_ascii_case("Content-ID")
438            {
439                continue;
440            }
441
442            if let Ok(hdr) = hdr.rebuild() {
443                rebuilt.headers_mut().push(hdr);
444            }
445        }
446
447        Ok(rebuilt)
448    }
449
450    /// Write the message content to the provided output stream
451    pub fn write_message<W: std::io::Write>(&self, out: &mut W) -> Result<()> {
452        let line_ending = if self
453            .conformance
454            .contains(MessageConformance::NON_CANONICAL_LINE_ENDINGS)
455        {
456            "\n"
457        } else {
458            "\r\n"
459        };
460
461        for hdr in self.headers.iter() {
462            hdr.write_header(out)
463                .map_err(|_| MailParsingError::WriteMessageIOError)?;
464        }
465        out.write_all(line_ending.as_bytes())
466            .map_err(|_| MailParsingError::WriteMessageIOError)?;
467
468        if self.parts.is_empty() {
469            out.write_all(self.raw_body().as_bytes())
470                .map_err(|_| MailParsingError::WriteMessageIOError)?;
471        } else {
472            let info = Rfc2045Info::new(&self.headers);
473            let ct = info.content_type.ok_or({
474                MailParsingError::WriteMessageWtf(
475                    "expected to have Content-Type when there are child parts",
476                )
477            })?;
478            let boundary = ct.get("boundary").ok_or({
479                MailParsingError::WriteMessageWtf("expected Content-Type to have a boundary")
480            })?;
481            out.write_all(self.intro.as_bytes())
482                .map_err(|_| MailParsingError::WriteMessageIOError)?;
483            for p in &self.parts {
484                write!(out, "--{boundary}{line_ending}")
485                    .map_err(|_| MailParsingError::WriteMessageIOError)?;
486                p.write_message(out)?;
487            }
488            write!(out, "--{boundary}--{line_ending}")
489                .map_err(|_| MailParsingError::WriteMessageIOError)?;
490            out.write_all(self.outro.as_bytes())
491                .map_err(|_| MailParsingError::WriteMessageIOError)?;
492        }
493        Ok(())
494    }
495
496    /// Convenience method wrapping write_message that returns
497    /// the formatted message as a standalone string
498    pub fn to_message_string(&self) -> String {
499        let mut out = vec![];
500        self.write_message(&mut out).unwrap();
501        String::from_utf8_lossy(&out).to_string()
502    }
503
504    pub fn replace_text_body(&mut self, content_type: &str, content: &str) -> Result<()> {
505        let mut new_part = Self::new_text(content_type, content)?;
506        self.bytes = new_part.bytes;
507        self.body_offset = new_part.body_offset;
508        self.body_len = new_part.body_len;
509        // Remove any rfc2047 headers that might reflect how the content
510        // is encoded. Note that we preserve Content-Disposition as that
511        // isn't related purely to the how the content is encoded
512        self.headers.remove_all_named("Content-Type");
513        self.headers.remove_all_named("Content-Transfer-Encoding");
514        // And add any from the new part
515        self.headers.append(&mut new_part.headers.headers);
516        Ok(())
517    }
518
519    pub fn replace_binary_body(&mut self, content_type: &str, content: &[u8]) -> Result<()> {
520        let mut new_part = Self::new_binary(content_type, content, None)?;
521        self.bytes = new_part.bytes;
522        self.body_offset = new_part.body_offset;
523        self.body_len = new_part.body_len;
524        // Remove any rfc2047 headers that might reflect how the content
525        // is encoded. Note that we preserve Content-Disposition as that
526        // isn't related purely to the how the content is encoded
527        self.headers.remove_all_named("Content-Type");
528        self.headers.remove_all_named("Content-Transfer-Encoding");
529        // And add any from the new part
530        self.headers.append(&mut new_part.headers.headers);
531        Ok(())
532    }
533
534    pub fn new_no_transfer_encoding(content_type: &str, bytes: &[u8]) -> Result<Self> {
535        if bytes.iter().any(|b| !b.is_ascii()) {
536            return Err(MailParsingError::EightBit);
537        }
538
539        let mut headers = HeaderMap::default();
540
541        let ct = MimeParameters::new(content_type);
542        headers.set_content_type(ct)?;
543
544        let bytes = String::from_utf8_lossy(bytes).to_string();
545        let body_len = bytes.len();
546
547        Ok(Self {
548            bytes: bytes.into(),
549            headers,
550            body_offset: 0,
551            body_len,
552            conformance: MessageConformance::default(),
553            parts: vec![],
554            intro: "".into(),
555            outro: "".into(),
556        })
557    }
558
559    /// Constructs a new part with textual utf8 content.
560    /// quoted-printable transfer encoding will be applied,
561    /// unless it is smaller to represent the text in base64
562    pub fn new_text(content_type: &str, content: &str) -> Result<Self> {
563        // We'll probably use qp, so speculatively do the work
564        let qp_encoded = quoted_printable::encode(content);
565
566        let (mut encoded, encoding) = if qp_encoded == content.as_bytes() {
567            (qp_encoded, None)
568        } else if qp_encoded.len() <= BASE64_RFC2045.encode_len(content.len()) {
569            (qp_encoded, Some("quoted-printable"))
570        } else {
571            // Turns out base64 will be smaller; perhaps the content
572            // is dominated by non-ASCII text?
573            (
574                BASE64_RFC2045.encode(content.as_bytes()).into_bytes(),
575                Some("base64"),
576            )
577        };
578
579        if !encoded.ends_with(b"\r\n") {
580            encoded.extend_from_slice(b"\r\n");
581        }
582        let mut headers = HeaderMap::default();
583
584        let mut ct = MimeParameters::new(content_type);
585        ct.set(
586            "charset",
587            if content.is_ascii() {
588                "us-ascii"
589            } else {
590                "utf-8"
591            },
592        );
593        headers.set_content_type(ct)?;
594
595        if let Some(encoding) = encoding {
596            headers.set_content_transfer_encoding(MimeParameters::new(encoding))?;
597        }
598
599        let body_len = encoded.len();
600        let bytes =
601            String::from_utf8(encoded).expect("transfer encoder to produce valid ASCII output");
602
603        Ok(Self {
604            bytes: bytes.into(),
605            headers,
606            body_offset: 0,
607            body_len,
608            conformance: MessageConformance::default(),
609            parts: vec![],
610            intro: "".into(),
611            outro: "".into(),
612        })
613    }
614
615    pub fn new_text_plain(content: &str) -> Result<Self> {
616        Self::new_text("text/plain", content)
617    }
618
619    pub fn new_html(content: &str) -> Result<Self> {
620        Self::new_text("text/html", content)
621    }
622
623    pub fn new_multipart(
624        content_type: &str,
625        parts: Vec<Self>,
626        boundary: Option<&str>,
627    ) -> Result<Self> {
628        let mut headers = HeaderMap::default();
629
630        let mut ct = MimeParameters::new(content_type);
631        match boundary {
632            Some(b) => {
633                ct.set("boundary", b);
634            }
635            None => {
636                // Generate a random boundary
637                let uuid = uuid::Uuid::new_v4();
638                let boundary = data_encoding::BASE64_NOPAD.encode(uuid.as_bytes());
639                ct.set("boundary", &boundary);
640            }
641        }
642        headers.set_content_type(ct)?;
643
644        Ok(Self {
645            bytes: "".into(),
646            headers,
647            body_offset: 0,
648            body_len: 0,
649            conformance: MessageConformance::default(),
650            parts,
651            intro: "".into(),
652            outro: "".into(),
653        })
654    }
655
656    pub fn new_binary(
657        content_type: &str,
658        content: &[u8],
659        options: Option<&AttachmentOptions>,
660    ) -> Result<Self> {
661        let mut encoded = BASE64_RFC2045.encode(content);
662        if !encoded.ends_with("\r\n") {
663            encoded.push_str("\r\n");
664        }
665        let mut headers = HeaderMap::default();
666
667        let mut ct = MimeParameters::new(content_type);
668
669        if let Some(opts) = options {
670            let mut cd = MimeParameters::new(if opts.inline { "inline" } else { "attachment" });
671            if let Some(name) = &opts.file_name {
672                cd.set("filename", name);
673                let encoding = if name.chars().any(|c| !c.is_ascii()) {
674                    MimeParameterEncoding::QuotedRfc2047
675                } else {
676                    MimeParameterEncoding::None
677                };
678                ct.set_with_encoding("name", name, encoding);
679            }
680            headers.set_content_disposition(cd)?;
681
682            if let Some(id) = &opts.content_id {
683                headers.set_content_id(MessageID(id.to_string()))?;
684            }
685        }
686
687        headers.set_content_type(ct)?;
688        headers.set_content_transfer_encoding(MimeParameters::new("base64"))?;
689
690        let body_len = encoded.len();
691
692        Ok(Self {
693            bytes: encoded.into(),
694            headers,
695            body_offset: 0,
696            body_len,
697            conformance: MessageConformance::default(),
698            parts: vec![],
699            intro: "".into(),
700            outro: "".into(),
701        })
702    }
703
704    /// Returns a SimplifiedStructure representation of the mime tree,
705    /// with the (probable) primary text/plain and text/html parts
706    /// pulled out, and the remaining parts recorded as a flat
707    /// attachments array
708    pub fn simplified_structure(&'a self) -> Result<SimplifiedStructure<'a>> {
709        let parts = self.simplified_structure_pointers()?;
710
711        let mut text = None;
712        let mut html = None;
713
714        let headers = &self
715            .resolve_ptr(parts.header_part)
716            .expect("header part to always be valid")
717            .headers;
718
719        if let Some(p) = parts.text_part.and_then(|p| self.resolve_ptr(p)) {
720            text = match p.body()? {
721                DecodedBody::Text(t) => Some(t),
722                DecodedBody::Binary(_) => {
723                    return Err(MailParsingError::BodyParse(
724                        "expected text/plain part to be text, but it is binary".to_string(),
725                    ))
726                }
727            };
728        }
729        if let Some(p) = parts.html_part.and_then(|p| self.resolve_ptr(p)) {
730            html = match p.body()? {
731                DecodedBody::Text(t) => Some(t),
732                DecodedBody::Binary(_) => {
733                    return Err(MailParsingError::BodyParse(
734                        "expected text/html part to be text, but it is binary".to_string(),
735                    ))
736                }
737            };
738        }
739
740        let mut attachments = vec![];
741        for ptr in parts.attachments {
742            attachments.push(self.resolve_ptr(ptr).expect("pointer to be valid").clone());
743        }
744
745        Ok(SimplifiedStructure {
746            text,
747            html,
748            headers,
749            attachments,
750        })
751    }
752
753    /// Resolve a PartPointer to the corresponding MimePart
754    pub fn resolve_ptr(&self, ptr: PartPointer) -> Option<&Self> {
755        let mut current = self;
756        let mut cursor = ptr.0.as_slice();
757
758        loop {
759            match cursor.first() {
760                Some(&idx) => {
761                    current = current.parts.get(idx as usize)?;
762                    cursor = &cursor[1..];
763                }
764                None => {
765                    // We have completed the walk
766                    return Some(current);
767                }
768            }
769        }
770    }
771
772    /// Resolve a PartPointer to the corresponding MimePart, for mutable access
773    pub fn resolve_ptr_mut(&mut self, ptr: PartPointer) -> Option<&mut Self> {
774        let mut current = self;
775        let mut cursor = ptr.0.as_slice();
776
777        loop {
778            match cursor.first() {
779                Some(&idx) => {
780                    current = current.parts.get_mut(idx as usize)?;
781                    cursor = &cursor[1..];
782                }
783                None => {
784                    // We have completed the walk
785                    return Some(current);
786                }
787            }
788        }
789    }
790
791    /// Returns a set of PartPointers that locate the (probable) primary
792    /// text/plain and text/html parts, and the remaining parts recorded
793    /// as a flat attachments array.  The resulting
794    /// PartPointers can be resolved to their actual instances for both
795    /// immutable and mutable operations via resolve_ptr and resolve_ptr_mut.
796    pub fn simplified_structure_pointers(&self) -> Result<SimplifiedStructurePointers> {
797        self.simplified_structure_pointers_impl(None)
798    }
799
800    fn simplified_structure_pointers_impl(
801        &self,
802        my_idx: Option<u8>,
803    ) -> Result<SimplifiedStructurePointers> {
804        let info = Rfc2045Info::new(&self.headers);
805        let is_inline = info
806            .attachment_options
807            .as_ref()
808            .map(|ao| ao.inline)
809            .unwrap_or(true);
810
811        if let Some(ct) = &info.content_type {
812            if is_inline {
813                if ct.value == "text/plain" {
814                    return Ok(SimplifiedStructurePointers {
815                        text_part: Some(PartPointer::root_or_nth(my_idx)),
816                        html_part: None,
817                        header_part: PartPointer::root_or_nth(my_idx),
818                        attachments: vec![],
819                    });
820                }
821                if ct.value == "text/html" {
822                    return Ok(SimplifiedStructurePointers {
823                        html_part: Some(PartPointer::root_or_nth(my_idx)),
824                        text_part: None,
825                        header_part: PartPointer::root_or_nth(my_idx),
826                        attachments: vec![],
827                    });
828                }
829            }
830
831            if ct.value.starts_with("multipart/") {
832                let mut text_part = None;
833                let mut html_part = None;
834                let mut attachments = vec![];
835
836                for (i, p) in self.parts.iter().enumerate() {
837                    let part_idx = i.try_into().map_err(|_| MailParsingError::TooManyParts)?;
838                    if let Ok(mut s) = p.simplified_structure_pointers_impl(Some(part_idx)) {
839                        if let Some(p) = s.text_part {
840                            if text_part.is_none() {
841                                text_part.replace(PartPointer::root_or_nth(my_idx).append(p));
842                            } else {
843                                attachments.push(p);
844                            }
845                        }
846                        if let Some(p) = s.html_part {
847                            if html_part.is_none() {
848                                html_part.replace(PartPointer::root_or_nth(my_idx).append(p));
849                            } else {
850                                attachments.push(p);
851                            }
852                        }
853                        attachments.append(&mut s.attachments);
854                    }
855                }
856
857                return Ok(SimplifiedStructurePointers {
858                    html_part,
859                    text_part,
860                    header_part: PartPointer::root_or_nth(my_idx),
861                    attachments,
862                });
863            }
864
865            return Ok(SimplifiedStructurePointers {
866                html_part: None,
867                text_part: None,
868                header_part: PartPointer::root_or_nth(my_idx),
869                attachments: vec![PartPointer::root_or_nth(my_idx)],
870            });
871        }
872
873        // Assume text/plain content-type
874        Ok(SimplifiedStructurePointers {
875            text_part: Some(PartPointer::root_or_nth(my_idx)),
876            html_part: None,
877            header_part: PartPointer::root_or_nth(my_idx),
878            attachments: vec![],
879        })
880    }
881}
882
883/// References the position of a MimePart by encoding the steps in
884/// a tree walking operation. The encoding of PartPointer is a
885/// sequence of integers that identify the index of a child part
886/// by its level within the mime tree, selecting the current node
887/// when no more indices remain. eg: `[]` indicates the
888/// root part, while `[0]` is the 0th child of the root.
889#[derive(Debug, Clone, PartialEq, Eq)]
890pub struct PartPointer(Vec<u8>);
891
892impl PartPointer {
893    /// Construct a PartPointer that references the root node
894    pub fn root() -> Self {
895        Self(vec![])
896    }
897
898    /// Construct a PartPointer that references either the nth
899    /// or the root node depending upon the passed parameter
900    pub fn root_or_nth(n: Option<u8>) -> Self {
901        match n {
902            Some(n) => Self::nth(n),
903            None => Self::root(),
904        }
905    }
906
907    /// Construct a PartPointer that references the nth child
908    pub fn nth(n: u8) -> Self {
909        Self(vec![n])
910    }
911
912    /// Join other onto self, consuming self and producing
913    /// a pointer that makes other relative to self
914    pub fn append(mut self, mut other: Self) -> Self {
915        self.0.append(&mut other.0);
916        Self(self.0)
917    }
918
919    pub fn id_string(&self) -> String {
920        let mut id = String::new();
921        for p in &self.0 {
922            if !id.is_empty() {
923                id.push('.');
924            }
925            id.push_str(&p.to_string());
926        }
927        id
928    }
929}
930
931#[derive(Debug, Clone)]
932pub struct SimplifiedStructurePointers {
933    /// The primary text/plain part
934    pub text_part: Option<PartPointer>,
935    /// The primary text/html part
936    pub html_part: Option<PartPointer>,
937    /// The "top level" set of headers for the message
938    pub header_part: PartPointer,
939    /// all other (terminal) parts are attachments
940    pub attachments: Vec<PartPointer>,
941}
942
943#[derive(Debug, Clone)]
944pub struct SimplifiedStructure<'a> {
945    pub text: Option<SharedString<'a>>,
946    pub html: Option<SharedString<'a>>,
947    pub headers: &'a HeaderMap<'a>,
948    pub attachments: Vec<MimePart<'a>>,
949}
950
951#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
952#[serde(deny_unknown_fields)]
953pub struct AttachmentOptions {
954    #[serde(default)]
955    pub file_name: Option<String>,
956    #[serde(default)]
957    pub inline: bool,
958    #[serde(default)]
959    pub content_id: Option<String>,
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum ContentTransferEncoding {
964    SevenBit,
965    EightBit,
966    Binary,
967    QuotedPrintable,
968    Base64,
969}
970
971impl FromStr for ContentTransferEncoding {
972    type Err = MailParsingError;
973
974    fn from_str(s: &str) -> Result<Self> {
975        if s.eq_ignore_ascii_case("7bit") {
976            Ok(Self::SevenBit)
977        } else if s.eq_ignore_ascii_case("8bit") {
978            Ok(Self::EightBit)
979        } else if s.eq_ignore_ascii_case("binary") {
980            Ok(Self::Binary)
981        } else if s.eq_ignore_ascii_case("quoted-printable") {
982            Ok(Self::QuotedPrintable)
983        } else if s.eq_ignore_ascii_case("base64") {
984            Ok(Self::Base64)
985        } else {
986            Err(MailParsingError::InvalidContentTransferEncoding(
987                s.to_string(),
988            ))
989        }
990    }
991}
992
993#[derive(Debug, PartialEq)]
994pub enum DecodedBody<'a> {
995    Text(SharedString<'a>),
996    Binary(Vec<u8>),
997}
998
999impl<'a> DecodedBody<'a> {
1000    pub fn to_string_lossy(&'a self) -> Cow<'a, str> {
1001        match self {
1002            Self::Text(s) => Cow::Borrowed(s),
1003            Self::Binary(b) => String::from_utf8_lossy(b),
1004        }
1005    }
1006}
1007
1008#[cfg(test)]
1009mod test {
1010    use super::*;
1011
1012    #[test]
1013    fn msg_parsing() {
1014        let message = concat!(
1015            "Subject: hello there\n",
1016            "From:  Someone <someone@example.com>\n",
1017            "\n",
1018            "I am the body"
1019        );
1020
1021        let part = MimePart::parse(message).unwrap();
1022        k9::assert_equal!(message, part.to_message_string());
1023        assert_eq!(part.raw_body(), "I am the body");
1024        k9::snapshot!(
1025            part.body(),
1026            r#"
1027Ok(
1028    Text(
1029        "I am the body",
1030    ),
1031)
1032"#
1033        );
1034
1035        k9::snapshot!(
1036            part.rebuild().unwrap().to_message_string(),
1037            r#"
1038Content-Type: text/plain;\r
1039\tcharset="us-ascii"\r
1040Subject: hello there\r
1041From: Someone <someone@example.com>\r
1042\r
1043I am the body\r
1044
1045"#
1046        );
1047    }
1048
1049    #[test]
1050    fn mime_bogus_body() {
1051        let message = concat!(
1052            "Subject: hello there\n",
1053            "From: Someone <someone@example.com>\n",
1054            "Mime-Version: 1.0\n",
1055            "Content-Type: text/plain\n",
1056            "Content-Transfer-Encoding: base64\n",
1057            "\n",
1058            "hello\n"
1059        );
1060
1061        let part = MimePart::parse(message).unwrap();
1062        assert_eq!(
1063            part.body().unwrap_err(),
1064            MailParsingError::BodyParse(
1065                "base64 decode: invalid length at 4 b='o' in hello\n".to_string()
1066            )
1067        );
1068    }
1069
1070    #[test]
1071    fn mime_encoded_body() {
1072        let message = concat!(
1073            "Subject: hello there\n",
1074            "From: Someone <someone@example.com>\n",
1075            "Mime-Version: 1.0\n",
1076            "Content-Type: text/plain\n",
1077            "Content-Transfer-Encoding: base64\n",
1078            "\n",
1079            "aGVsbG8K\n"
1080        );
1081
1082        let part = MimePart::parse(message).unwrap();
1083        k9::assert_equal!(message, part.to_message_string());
1084        assert_eq!(part.raw_body(), "aGVsbG8K\n");
1085        k9::snapshot!(
1086            part.body(),
1087            r#"
1088Ok(
1089    Text(
1090        "hello
1091",
1092    ),
1093)
1094"#
1095        );
1096
1097        k9::snapshot!(
1098            part.rebuild().unwrap().to_message_string(),
1099            r#"
1100Content-Type: text/plain;\r
1101\tcharset="us-ascii"\r
1102Content-Transfer-Encoding: quoted-printable\r
1103Subject: hello there\r
1104From: Someone <someone@example.com>\r
1105Mime-Version: 1.0\r
1106\r
1107hello=0A\r
1108
1109"#
1110        );
1111    }
1112
1113    #[test]
1114    fn mime_multipart_1() {
1115        let message = concat!(
1116            "Subject: This is a test email\n",
1117            "Content-Type: multipart/alternative; boundary=foobar\n",
1118            "Mime-Version: 1.0\n",
1119            "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\n",
1120            "\n",
1121            "--foobar\n",
1122            "Content-Type: text/plain; charset=utf-8\n",
1123            "Content-Transfer-Encoding: quoted-printable\n",
1124            "\n",
1125            "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\n",
1126            "--foobar\n",
1127            "Content-Type: text/html\n",
1128            "Content-Transfer-Encoding: base64\n",
1129            "\n",
1130            "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \n",
1131            "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \n",
1132            "--foobar--\n",
1133            "After the final boundary stuff gets ignored.\n"
1134        );
1135
1136        let part = MimePart::parse(message).unwrap();
1137
1138        k9::assert_equal!(message, part.to_message_string());
1139
1140        let children = part.child_parts();
1141        k9::assert_equal!(children.len(), 2);
1142
1143        k9::snapshot!(
1144            children[0].body(),
1145            r#"
1146Ok(
1147    Text(
1148        "This is the plaintext version, in utf-8. Proof by Euro: €\r
1149",
1150    ),
1151)
1152"#
1153        );
1154        k9::snapshot!(
1155            children[1].body(),
1156            r#"
1157Ok(
1158    Text(
1159        "<html><body>This is the <b>HTML</b> version, in us-ascii. Proof by Euro: &euro;</body></html>
1160",
1161    ),
1162)
1163"#
1164        );
1165    }
1166
1167    #[test]
1168    fn mutate_1() {
1169        let message = concat!(
1170            "Subject: This is a test email\r\n",
1171            "Content-Type: multipart/alternative; boundary=foobar\r\n",
1172            "Mime-Version: 1.0\r\n",
1173            "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1174            "\r\n",
1175            "--foobar\r\n",
1176            "Content-Type: text/plain; charset=utf-8\r\n",
1177            "Content-Transfer-Encoding: quoted-printable\r\n",
1178            "\r\n",
1179            "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r\n",
1180            "--foobar\r\n",
1181            "Content-Type: text/html\r\n",
1182            "Content-Transfer-Encoding: base64\r\n",
1183            "\r\n",
1184            "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r\n",
1185            "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r\n",
1186            "--foobar--\r\n",
1187            "After the final boundary stuff gets ignored.\r\n"
1188        );
1189
1190        let mut part = MimePart::parse(message).unwrap();
1191        k9::assert_equal!(message, part.to_message_string());
1192        fn munge(part: &mut MimePart) {
1193            let headers = part.headers_mut();
1194            headers.push(Header::with_name_value("X-Woot", "Hello"));
1195            headers.insert(0, Header::with_name_value("X-First", "at the top"));
1196            headers.retain(|hdr| !hdr.get_name().eq_ignore_ascii_case("date"));
1197        }
1198        munge(&mut part);
1199
1200        let re_encoded = part.to_message_string();
1201        k9::snapshot!(
1202            re_encoded,
1203            r#"
1204X-First: at the top\r
1205Subject: This is a test email\r
1206Content-Type: multipart/alternative; boundary=foobar\r
1207Mime-Version: 1.0\r
1208X-Woot: Hello\r
1209\r
1210--foobar\r
1211Content-Type: text/plain; charset=utf-8\r
1212Content-Transfer-Encoding: quoted-printable\r
1213\r
1214This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r
1215--foobar\r
1216Content-Type: text/html\r
1217Content-Transfer-Encoding: base64\r
1218\r
1219PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1220dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1221--foobar--\r
1222After the final boundary stuff gets ignored.\r
1223
1224"#
1225        );
1226
1227        eprintln!("part before mutate:\n{part:#?}");
1228
1229        part.child_parts_mut().retain(|part| {
1230            let ct = part.headers().content_type().unwrap().unwrap();
1231            ct.value == "text/html"
1232        });
1233
1234        eprintln!("part with html removed is:\n{part:#?}");
1235
1236        let re_encoded = part.to_message_string();
1237        k9::snapshot!(
1238            re_encoded,
1239            r#"
1240X-First: at the top\r
1241Subject: This is a test email\r
1242Content-Type: multipart/alternative; boundary=foobar\r
1243Mime-Version: 1.0\r
1244X-Woot: Hello\r
1245\r
1246--foobar\r
1247Content-Type: text/html\r
1248Content-Transfer-Encoding: base64\r
1249\r
1250PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1251dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1252--foobar--\r
1253After the final boundary stuff gets ignored.\r
1254
1255"#
1256        );
1257    }
1258
1259    #[test]
1260    fn replace_text_body() {
1261        let mut part = MimePart::new_text_plain("Hello 👻\r\n").unwrap();
1262        let encoded = part.to_message_string();
1263        k9::snapshot!(
1264            &encoded,
1265            r#"
1266Content-Type: text/plain;\r
1267\tcharset="utf-8"\r
1268Content-Transfer-Encoding: base64\r
1269\r
1270SGVsbG8g8J+Ruw0K\r
1271
1272"#
1273        );
1274
1275        part.replace_text_body("text/plain", "Hello 🚀\r\n")
1276            .unwrap();
1277        let encoded = part.to_message_string();
1278        k9::snapshot!(
1279            &encoded,
1280            r#"
1281Content-Type: text/plain;\r
1282\tcharset="utf-8"\r
1283Content-Transfer-Encoding: base64\r
1284\r
1285SGVsbG8g8J+agA0K\r
1286
1287"#
1288        );
1289    }
1290
1291    #[test]
1292    fn construct_1() {
1293        let input_text = "Well, hello there! This is the plaintext version, in utf-8. Here's a Euro: €, and here are some emoji 👻 🍉 💩 and this long should be long enough that we wrap it in the returned part, let's see how that turns out!\r\n";
1294
1295        let part = MimePart::new_text_plain(input_text).unwrap();
1296
1297        let encoded = part.to_message_string();
1298        k9::snapshot!(
1299            &encoded,
1300            r#"
1301Content-Type: text/plain;\r
1302\tcharset="utf-8"\r
1303Content-Transfer-Encoding: quoted-printable\r
1304\r
1305Well, hello there! This is the plaintext version, in utf-8. Here's a Euro: =\r
1306=E2=82=AC, and here are some emoji =F0=9F=91=BB =F0=9F=8D=89 =F0=9F=92=A9 a=\r
1307nd this long should be long enough that we wrap it in the returned part, le=\r
1308t's see how that turns out!\r
1309
1310"#
1311        );
1312
1313        let parsed_part = MimePart::parse(encoded.clone()).unwrap();
1314        k9::assert_equal!(encoded.as_str(), parsed_part.to_message_string().as_str());
1315        k9::assert_equal!(part.body().unwrap(), DecodedBody::Text(input_text.into()));
1316        k9::snapshot!(
1317            parsed_part.simplified_structure_pointers(),
1318            "
1319Ok(
1320    SimplifiedStructurePointers {
1321        text_part: Some(
1322            PartPointer(
1323                [],
1324            ),
1325        ),
1326        html_part: None,
1327        header_part: PartPointer(
1328            [],
1329        ),
1330        attachments: [],
1331    },
1332)
1333"
1334        );
1335    }
1336
1337    #[test]
1338    fn construct_2() {
1339        let msg = MimePart::new_multipart(
1340            "multipart/mixed",
1341            vec![
1342                MimePart::new_text_plain("plain text").unwrap(),
1343                MimePart::new_html("<b>rich</b> text").unwrap(),
1344                MimePart::new_binary(
1345                    "application/octet-stream",
1346                    &[0, 1, 2, 3],
1347                    Some(&AttachmentOptions {
1348                        file_name: Some("woot.bin".to_string()),
1349                        inline: false,
1350                        content_id: Some("woot.id".to_string()),
1351                    }),
1352                )
1353                .unwrap(),
1354            ],
1355            Some("my-boundary"),
1356        )
1357        .unwrap();
1358        k9::snapshot!(
1359            msg.to_message_string(),
1360            r#"
1361Content-Type: multipart/mixed;\r
1362\tboundary="my-boundary"\r
1363\r
1364--my-boundary\r
1365Content-Type: text/plain;\r
1366\tcharset="us-ascii"\r
1367\r
1368plain text\r
1369--my-boundary\r
1370Content-Type: text/html;\r
1371\tcharset="us-ascii"\r
1372\r
1373<b>rich</b> text\r
1374--my-boundary\r
1375Content-Disposition: attachment;\r
1376\tfilename="woot.bin"\r
1377Content-ID: <woot.id>\r
1378Content-Type: application/octet-stream;\r
1379\tname="woot.bin"\r
1380Content-Transfer-Encoding: base64\r
1381\r
1382AAECAw==\r
1383--my-boundary--\r
1384
1385"#
1386        );
1387
1388        k9::snapshot!(
1389            msg.simplified_structure_pointers(),
1390            "
1391Ok(
1392    SimplifiedStructurePointers {
1393        text_part: Some(
1394            PartPointer(
1395                [
1396                    0,
1397                ],
1398            ),
1399        ),
1400        html_part: Some(
1401            PartPointer(
1402                [
1403                    1,
1404                ],
1405            ),
1406        ),
1407        header_part: PartPointer(
1408            [],
1409        ),
1410        attachments: [
1411            PartPointer(
1412                [
1413                    2,
1414                ],
1415            ),
1416        ],
1417    },
1418)
1419"
1420        );
1421    }
1422
1423    #[test]
1424    fn attachment_name_order_prefers_content_disposition() {
1425        let message = concat!(
1426            "Content-Type: multipart/mixed;\r\n",
1427            "	boundary=\"woot\"\r\n",
1428            "\r\n",
1429            "--woot\r\n",
1430            "Content-Type: text/plain;\r\n",
1431            "	charset=\"us-ascii\"\r\n",
1432            "\r\n",
1433            "Hello, I am the main message content\r\n",
1434            "--woot\r\n",
1435            "Content-Disposition: attachment;\r\n",
1436            "	filename=cdname\r\n",
1437            "Content-Type: application/octet-stream;\r\n",
1438            "	name=ctname\r\n",
1439            "Content-Transfer-Encoding: base64\r\n",
1440            "\r\n",
1441            "u6o=\r\n",
1442            "--woot--\r\n"
1443        );
1444        let part = MimePart::parse(message).unwrap();
1445        let structure = part.simplified_structure().unwrap();
1446
1447        k9::assert_equal!(
1448            structure.attachments[0].rfc2045_info().attachment_options,
1449            Some(AttachmentOptions {
1450                content_id: None,
1451                inline: false,
1452                file_name: Some("cdname".to_string()),
1453            })
1454        );
1455    }
1456
1457    #[test]
1458    fn attachment_name_accepts_content_type_name() {
1459        let message = concat!(
1460            "Content-Type: multipart/mixed;\r\n",
1461            "	boundary=\"woot\"\r\n",
1462            "\r\n",
1463            "--woot\r\n",
1464            "Content-Type: text/plain;\r\n",
1465            "	charset=\"us-ascii\"\r\n",
1466            "\r\n",
1467            "Hello, I am the main message content\r\n",
1468            "--woot\r\n",
1469            "Content-Disposition: attachment\r\n",
1470            "Content-Type: application/octet-stream;\r\n",
1471            "	name=ctname\r\n",
1472            "Content-Transfer-Encoding: base64\r\n",
1473            "\r\n",
1474            "u6o=\r\n",
1475            "--woot--\r\n"
1476        );
1477        let part = MimePart::parse(message).unwrap();
1478        let structure = part.simplified_structure().unwrap();
1479
1480        k9::assert_equal!(
1481            structure.attachments[0].rfc2045_info().attachment_options,
1482            Some(AttachmentOptions {
1483                content_id: None,
1484                inline: false,
1485                file_name: Some("ctname".to_string()),
1486            })
1487        );
1488    }
1489
1490    #[test]
1491    fn funky_headers() {
1492        let message = concat!(
1493            "Subject\r\n",
1494            "Other:\r\n",
1495            "Content-Type: multipart/alternative; boundary=foobar\r\n",
1496            "Mime-Version: 1.0\r\n",
1497            "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1498            "\r\n",
1499            "The body.\r\n"
1500        );
1501
1502        let part = MimePart::parse(message).unwrap();
1503        assert!(part
1504            .conformance()
1505            .contains(MessageConformance::MISSING_COLON_VALUE));
1506    }
1507
1508    /// This is a regression test for an issue where we'd interpret the
1509    /// binary bytes as default windows-1252 codepage charset, and mangle them.
1510    /// The high byte is sufficient to trigger the offending code prior
1511    /// to the fix
1512    #[test]
1513    fn rebuild_binary() {
1514        let expect = &[0, 1, 2, 3, 0xbe, 4, 5];
1515        let part = MimePart::new_binary("applicat/octet-stream", expect, None).unwrap();
1516
1517        let rebuilt = part.rebuild().unwrap();
1518        let body = rebuilt.body().unwrap();
1519
1520        assert_eq!(body, DecodedBody::Binary(expect.to_vec()));
1521    }
1522}