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