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_normalizer_rs::entity::NormalizerSettings;
9use charset_normalizer_rs::Encoding;
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use std::borrow::Cow;
13use std::str::FromStr;
14use std::sync::Arc;
15
16/// Define our own because data_encoding::BASE64_MIME, despite its name,
17/// is not RFC2045 compliant, and will not ignore spaces
18const BASE64_RFC2045: data_encoding::Encoding = data_encoding_macro::new_encoding! {
19    symbols: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
20    padding: '=',
21    ignore: " \r\n\t",
22    wrap_width: 76,
23    wrap_separator: "\r\n",
24};
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct MimePart<'a> {
28    /// The bytes that comprise this part, from its beginning to its end
29    bytes: SharedString<'a>,
30    /// The parsed headers from the start of bytes
31    headers: HeaderMap<'a>,
32    /// The index into bytes of the first non-header byte.
33    body_offset: usize,
34    body_len: usize,
35    conformance: MessageConformance,
36    parts: Vec<Self>,
37    /// For multipart, the content the precedes the first boundary
38    intro: SharedString<'a>,
39    /// For multipart, the content the follows the last boundary
40    outro: SharedString<'a>,
41}
42
43#[derive(PartialEq, Debug)]
44pub struct Rfc2045Info {
45    pub encoding: ContentTransferEncoding,
46    pub charset: Result<&'static Encoding>,
47    pub content_type: Option<MimeParameters>,
48    pub is_text: bool,
49    pub is_multipart: bool,
50    pub attachment_options: Option<AttachmentOptions>,
51    pub invalid_mime_headers: bool,
52}
53
54impl Rfc2045Info {
55    // This must be infallible so that a basic mime structure can be parsed
56    // even if the mime headers are a bit borked
57    fn new(headers: &HeaderMap) -> Self {
58        let mut invalid_mime_headers = false;
59        let encoding = match headers.content_transfer_encoding() {
60            Ok(Some(cte)) => match ContentTransferEncoding::from_str(&cte.value) {
61                Ok(encoding) => encoding,
62                Err(_) => {
63                    invalid_mime_headers = true;
64                    ContentTransferEncoding::SevenBit
65                }
66            },
67            Ok(None) => ContentTransferEncoding::SevenBit,
68            Err(_) => {
69                invalid_mime_headers = true;
70                ContentTransferEncoding::SevenBit
71            }
72        };
73
74        let content_type = match headers.content_type() {
75            Ok(ct) => ct,
76            Err(_) => {
77                invalid_mime_headers = true;
78                None
79            }
80        };
81
82        let mut ct_name = None;
83        let charset = if let Some(ct) = &content_type {
84            ct_name = ct.get("name");
85            ct.get("charset")
86        } else {
87            None
88        };
89        let charset = charset.unwrap_or_else(|| "us-ascii".to_string());
90
91        let charset = Encoding::by_name(&*charset)
92            .ok_or_else(|| MailParsingError::BodyParse(format!("unsupported charset {charset}")));
93
94        let (is_text, is_multipart) = if let Some(ct) = &content_type {
95            (ct.is_text(), ct.is_multipart())
96        } else {
97            (true, false)
98        };
99
100        let mut inline = false;
101        let mut cd_file_name = None;
102
103        match headers.content_disposition() {
104            Ok(Some(cd)) => {
105                inline = cd.value == "inline";
106                cd_file_name = cd.get("filename");
107            }
108            Ok(None) => {}
109            Err(_) => {
110                invalid_mime_headers = true;
111            }
112        };
113
114        let content_id = match headers.content_id() {
115            Ok(cid) => cid.map(|cid| cid.0),
116            Err(_) => {
117                invalid_mime_headers = true;
118                None
119            }
120        };
121
122        let file_name = match (cd_file_name, ct_name) {
123            (Some(name), _) | (None, Some(name)) => Some(name),
124            (None, None) => None,
125        };
126
127        let attachment_options = if inline || file_name.is_some() || content_id.is_some() {
128            Some(AttachmentOptions {
129                file_name,
130                inline,
131                content_id,
132            })
133        } else {
134            None
135        };
136
137        Self {
138            encoding,
139            charset,
140            content_type,
141            is_text,
142            is_multipart,
143            attachment_options,
144            invalid_mime_headers,
145        }
146    }
147
148    pub fn content_type(&self) -> Option<&str> {
149        self.content_type
150            .as_ref()
151            .map(|params| params.value.as_str())
152    }
153}
154
155impl<'a> MimePart<'a> {
156    /// Parse some data into a tree of MimeParts
157    pub fn parse<S>(bytes: S) -> Result<Self>
158    where
159        S: IntoSharedString<'a>,
160    {
161        let (bytes, base_conformance) = bytes.into_shared_string();
162        Self::parse_impl(bytes, base_conformance, true)
163    }
164
165    /// Obtain a version of self that has a static lifetime
166    pub fn to_owned(&self) -> MimePart<'static> {
167        MimePart {
168            bytes: self.bytes.to_owned(),
169            headers: self.headers.to_owned(),
170            body_offset: self.body_offset,
171            body_len: self.body_len,
172            conformance: self.conformance,
173            parts: self.parts.iter().map(|p| p.to_owned()).collect(),
174            intro: self.intro.to_owned(),
175            outro: self.outro.to_owned(),
176        }
177    }
178
179    fn parse_impl(
180        bytes: SharedString<'a>,
181        base_conformance: MessageConformance,
182        is_top_level: bool,
183    ) -> Result<Self> {
184        let HeaderParseResult {
185            headers,
186            body_offset,
187            overall_conformance: mut conformance,
188        } = Header::parse_headers(bytes.clone())?;
189
190        conformance |= base_conformance;
191
192        let body_len = bytes.len();
193
194        if !bytes.is_ascii() {
195            conformance.set(MessageConformance::NEEDS_TRANSFER_ENCODING, true);
196        }
197        {
198            let mut prev = 0;
199            for idx in memchr::memchr_iter(b'\n', bytes.as_bytes()) {
200                if idx - prev > 78 {
201                    conformance.set(MessageConformance::LINE_TOO_LONG, true);
202                    break;
203                }
204                prev = idx;
205            }
206        }
207        conformance.set(
208            MessageConformance::NON_CANONICAL_LINE_ENDINGS,
209            has_lone_cr_or_lf(bytes.as_bytes()),
210        );
211
212        if is_top_level {
213            conformance.set(
214                MessageConformance::MISSING_DATE_HEADER,
215                !matches!(headers.date(), Ok(Some(_))),
216            );
217            conformance.set(
218                MessageConformance::MISSING_MESSAGE_ID_HEADER,
219                !matches!(headers.message_id(), Ok(Some(_))),
220            );
221            conformance.set(
222                MessageConformance::MISSING_MIME_VERSION,
223                match headers.mime_version() {
224                    Ok(Some(v)) => v.as_str() != "1.0",
225                    _ => true,
226                },
227            );
228        }
229
230        let mut part = Self {
231            bytes,
232            headers,
233            body_offset,
234            body_len,
235            conformance,
236            parts: vec![],
237            intro: SharedString::Borrowed(""),
238            outro: SharedString::Borrowed(""),
239        };
240
241        part.recursive_parse()?;
242
243        Ok(part)
244    }
245
246    fn recursive_parse(&mut self) -> Result<()> {
247        let info = Rfc2045Info::new(&self.headers);
248        if info.invalid_mime_headers {
249            self.conformance |= MessageConformance::INVALID_MIME_HEADERS;
250        }
251        if let Some((boundary, true)) = info
252            .content_type
253            .as_ref()
254            .and_then(|ct| ct.get("boundary").map(|b| (b, info.is_multipart)))
255        {
256            let boundary = format!("\n--{boundary}");
257            let raw_body = self
258                .bytes
259                .slice(self.body_offset.saturating_sub(1)..self.bytes.len());
260
261            let mut iter = memchr::memmem::find_iter(raw_body.as_bytes(), &boundary);
262            if let Some(first_boundary_pos) = iter.next() {
263                self.intro = raw_body.slice(0..first_boundary_pos);
264
265                // When we create parts, we ignore the original body span in
266                // favor of what we're parsing out here now
267                self.body_len = 0;
268
269                let mut boundary_end = first_boundary_pos + boundary.len();
270
271                while let Some(part_start) =
272                    memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
273                        .map(|p| p + boundary_end + 1)
274                {
275                    let part_end = iter
276                        .next()
277                        .map(|p| {
278                            // P is the newline; we want to include it in the raw
279                            // bytes for this part, so look beyond it
280                            p + 1
281                        })
282                        .unwrap_or(raw_body.len());
283
284                    let child = Self::parse_impl(
285                        raw_body.slice(part_start..part_end),
286                        MessageConformance::default(),
287                        false,
288                    )?;
289                    self.conformance |= child.conformance;
290                    self.parts.push(child);
291
292                    boundary_end = part_end -
293                        1 /* newline we adjusted for when assigning part_end */
294                        + boundary.len();
295
296                    if boundary_end + 2 > raw_body.len() {
297                        break;
298                    }
299                    if &raw_body.as_bytes()[boundary_end..boundary_end + 2] == b"--" {
300                        if let Some(after_boundary) =
301                            memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
302                                .map(|p| p + boundary_end + 1)
303                        {
304                            self.outro = raw_body.slice(after_boundary..raw_body.len());
305                        }
306                        break;
307                    }
308                }
309            }
310        }
311
312        Ok(())
313    }
314
315    /// Recursively performs deeper conformance checks on the message.
316    /// At this time that includes attempting to decode any text parts
317    /// into UTF-8 to see if they are correctly annotated, but it may
318    /// include more checks in the future.
319    /// The results of the deep checks are combined with any conformance
320    /// issues detected during parsing, and returned.
321    pub fn deep_conformance_check(&self) -> MessageConformance {
322        if self.parts.is_empty() {
323            match self.extract_body(None) {
324                Ok((_, conformance)) => conformance,
325                Err(_) => self.conformance | MessageConformance::NEEDS_TRANSFER_ENCODING,
326            }
327        } else {
328            let mut conformance = self.conformance;
329            for p in &self.parts {
330                conformance |= p.deep_conformance_check();
331            }
332            conformance
333        }
334    }
335
336    /// Returns the conformance flags determined during parsing
337    pub fn conformance(&self) -> MessageConformance {
338        self.conformance
339    }
340
341    /// Obtain a reference to the child parts
342    pub fn child_parts(&self) -> &[Self] {
343        &self.parts
344    }
345
346    /// Obtain a mutable reference to the child parts
347    pub fn child_parts_mut(&mut self) -> &mut Vec<Self> {
348        &mut self.parts
349    }
350
351    /// Obtains a reference to the headers
352    pub fn headers(&'_ self) -> &'_ HeaderMap<'_> {
353        &self.headers
354    }
355
356    /// Obtain a mutable reference to the headers
357    pub fn headers_mut<'b>(&'b mut self) -> &'b mut HeaderMap<'a> {
358        &mut self.headers
359    }
360
361    /// Get the raw, transfer-encoded body
362    pub fn raw_body(&'_ self) -> SharedString<'_> {
363        self.bytes
364            .slice(self.body_offset..self.body_len.max(self.body_offset))
365    }
366
367    pub fn rfc2045_info(&self) -> Rfc2045Info {
368        Rfc2045Info::new(&self.headers)
369    }
370
371    /// Decode transfer decoding and return the body
372    pub fn body(&'_ self) -> Result<DecodedBody<'_>> {
373        let (body, _conformance) = self.extract_body(None)?;
374        Ok(body)
375    }
376
377    fn extract_body(
378        &'_ self,
379        options: Option<&CheckFixSettings>,
380    ) -> Result<(DecodedBody<'_>, MessageConformance)> {
381        let info = Rfc2045Info::new(&self.headers);
382
383        let bytes = match info.encoding {
384            ContentTransferEncoding::Base64 => {
385                let data = self.raw_body();
386                let bytes = data.as_bytes();
387                BASE64_RFC2045.decode(bytes).map_err(|err| {
388                    let b = bytes[err.position] as char;
389                    let region =
390                        &bytes[err.position.saturating_sub(8)..(err.position + 8).min(bytes.len())];
391                    let region = String::from_utf8_lossy(region);
392                    MailParsingError::BodyParse(format!(
393                        "base64 decode: {err:#} b={b:?} in {region}"
394                    ))
395                })?
396            }
397            ContentTransferEncoding::QuotedPrintable => quoted_printable::decode(
398                self.raw_body().as_bytes(),
399                quoted_printable::ParseMode::Robust,
400            )
401            .map_err(|err| {
402                MailParsingError::BodyParse(format!("quoted printable decode: {err:#}"))
403            })?,
404            ContentTransferEncoding::SevenBit
405            | ContentTransferEncoding::EightBit
406            | ContentTransferEncoding::Binary => self.raw_body().as_bytes().to_vec(),
407        };
408
409        if info.is_text {
410            let charset = info.charset?;
411
412            match charset.decode_simple(&bytes) {
413                Ok(decoded) => Ok((
414                    DecodedBody::Text(decoded.to_string().into()),
415                    self.conformance,
416                )),
417                Err(_err) => {
418                    if let Some(settings) = options {
419                        if settings.detect_encoding {
420                            let norm_settings = NormalizerSettings {
421                                include_encodings: settings.include_encodings.clone(),
422                                exclude_encodings: settings.exclude_encodings.clone(),
423                                ..Default::default()
424                            };
425
426                            if let Ok(guess) =
427                                charset_normalizer_rs::from_bytes(&*bytes, Some(norm_settings))
428                            {
429                                if let Some(decoded) =
430                                    guess.get_best().and_then(|best| best.decoded_payload())
431                                {
432                                    return Ok((
433                                        DecodedBody::Text(decoded.to_string().into()),
434                                        MessageConformance::NEEDS_TRANSFER_ENCODING
435                                            | self.conformance,
436                                    ));
437                                }
438                            }
439
440                            // No charset was detected.  This is a strong indicator
441                            // that the content is actually binary, according to
442                            // the docs of the detector, but we know that it should
443                            // be text.  Regardless, we can't represent it as UTF-8
444                            // here.
445                            // We'll return it as a binary part and let the caller
446                            // decide if that is an issue
447                            return Ok((
448                                DecodedBody::Binary(bytes),
449                                MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
450                            ));
451                        }
452                    }
453
454                    // We don't know what the charset is, just that this should
455                    // be some kind of text.  For the sake of compatibility with
456                    // international email, let's try it as UTF-8, and if that
457                    // sticks, we'll use it.
458                    if let Ok(decoded) = std::str::from_utf8(&bytes) {
459                        return Ok((
460                            DecodedBody::Text(decoded.to_string().into()),
461                            MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
462                        ));
463                    }
464
465                    // Who knows what it is? Return it as binary and leave the
466                    // final decision on what to do with it to our caller.
467                    Ok((
468                        DecodedBody::Binary(bytes),
469                        MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
470                    ))
471                }
472            }
473        } else {
474            Ok((DecodedBody::Binary(bytes), self.conformance))
475        }
476    }
477
478    /// Re-constitute the message.
479    /// Each element will be parsed out, and the parsed form used
480    /// to build a new message.
481    /// This has the side effect of "fixing" non-conforming elements,
482    /// but may come at the cost of "losing" the non-sensical or otherwise
483    /// out of spec elements in the rebuilt message
484    pub fn rebuild(&self, settings: Option<&CheckFixSettings>) -> Result<Self> {
485        let info = Rfc2045Info::new(&self.headers);
486
487        let mut children = vec![];
488        for part in &self.parts {
489            children.push(part.rebuild(settings)?);
490        }
491
492        let mut rebuilt = if children.is_empty() {
493            let (body, _conformance) = self.extract_body(settings)?;
494            match body {
495                DecodedBody::Text(text) => {
496                    let ct = info
497                        .content_type
498                        .as_ref()
499                        .map(|ct| ct.value.as_str())
500                        .unwrap_or("text/plain");
501                    Self::new_text(ct, text.as_str())?
502                }
503                DecodedBody::Binary(data) => {
504                    let ct = info
505                        .content_type
506                        .as_ref()
507                        .map(|ct| ct.value.as_str())
508                        .unwrap_or("application/octet-stream");
509                    Self::new_binary(ct, &data, info.attachment_options.as_ref())?
510                }
511            }
512        } else {
513            let ct = info.content_type.ok_or_else(|| {
514                MailParsingError::BodyParse(
515                    "multipart message has no content-type information!?".to_string(),
516                )
517            })?;
518            Self::new_multipart(&ct.value, children, ct.get("boundary").as_deref())?
519        };
520
521        for hdr in self.headers.iter() {
522            let name = hdr.get_name();
523            if name.eq_ignore_ascii_case("Content-ID") {
524                continue;
525            }
526
527            // Merge in any MimeParameters that we might otherwise have lost
528            // in the rebuild
529            if name.eq_ignore_ascii_case("Content-Type") {
530                if let Ok(params) = hdr.as_content_type() {
531                    let Some(mut dest) = rebuilt.headers_mut().content_type()? else {
532                        continue;
533                    };
534
535                    for (k, v) in params.parameter_map() {
536                        if dest.get(&k).is_none() {
537                            dest.set(&k, &v);
538                        }
539                    }
540
541                    rebuilt.headers_mut().set_content_type(dest)?;
542                }
543                continue;
544            }
545            if name.eq_ignore_ascii_case("Content-Transfer-Encoding") {
546                if let Ok(params) = hdr.as_content_transfer_encoding() {
547                    let Some(mut dest) = rebuilt.headers_mut().content_transfer_encoding()? else {
548                        continue;
549                    };
550
551                    for (k, v) in params.parameter_map() {
552                        if dest.get(&k).is_none() {
553                            dest.set(&k, &v);
554                        }
555                    }
556
557                    rebuilt.headers_mut().set_content_transfer_encoding(dest)?;
558                }
559                continue;
560            }
561            if name.eq_ignore_ascii_case("Content-Disposition") {
562                if let Ok(params) = hdr.as_content_disposition() {
563                    let Some(mut dest) = rebuilt.headers_mut().content_disposition()? else {
564                        continue;
565                    };
566
567                    for (k, v) in params.parameter_map() {
568                        if dest.get(&k).is_none() {
569                            dest.set(&k, &v);
570                        }
571                    }
572
573                    rebuilt.headers_mut().set_content_disposition(dest)?;
574                }
575                continue;
576            }
577
578            if let Ok(hdr) = hdr.rebuild() {
579                rebuilt.headers_mut().push(hdr);
580            }
581        }
582
583        Ok(rebuilt)
584    }
585
586    /// Write the message content to the provided output stream
587    pub fn write_message<W: std::io::Write>(&self, out: &mut W) -> Result<()> {
588        let line_ending = if self
589            .conformance
590            .contains(MessageConformance::NON_CANONICAL_LINE_ENDINGS)
591        {
592            "\n"
593        } else {
594            "\r\n"
595        };
596
597        for hdr in self.headers.iter() {
598            hdr.write_header(out)
599                .map_err(|_| MailParsingError::WriteMessageIOError)?;
600        }
601        out.write_all(line_ending.as_bytes())
602            .map_err(|_| MailParsingError::WriteMessageIOError)?;
603
604        if self.parts.is_empty() {
605            out.write_all(self.raw_body().as_bytes())
606                .map_err(|_| MailParsingError::WriteMessageIOError)?;
607        } else {
608            let info = Rfc2045Info::new(&self.headers);
609            let ct = info.content_type.ok_or({
610                MailParsingError::WriteMessageWtf(
611                    "expected to have Content-Type when there are child parts",
612                )
613            })?;
614            let boundary = ct.get("boundary").ok_or({
615                MailParsingError::WriteMessageWtf("expected Content-Type to have a boundary")
616            })?;
617            out.write_all(self.intro.as_bytes())
618                .map_err(|_| MailParsingError::WriteMessageIOError)?;
619            for p in &self.parts {
620                write!(out, "--{boundary}{line_ending}")
621                    .map_err(|_| MailParsingError::WriteMessageIOError)?;
622                p.write_message(out)?;
623            }
624            write!(out, "--{boundary}--{line_ending}")
625                .map_err(|_| MailParsingError::WriteMessageIOError)?;
626            out.write_all(self.outro.as_bytes())
627                .map_err(|_| MailParsingError::WriteMessageIOError)?;
628        }
629        Ok(())
630    }
631
632    /// Convenience method wrapping write_message that returns
633    /// the formatted message as a standalone string
634    pub fn to_message_string(&self) -> String {
635        let mut out = vec![];
636        self.write_message(&mut out).unwrap();
637        String::from_utf8_lossy(&out).to_string()
638    }
639
640    pub fn replace_text_body(&mut self, content_type: &str, content: &str) -> Result<()> {
641        let mut new_part = Self::new_text(content_type, content)?;
642        self.bytes = new_part.bytes;
643        self.body_offset = new_part.body_offset;
644        self.body_len = new_part.body_len;
645        // Remove any rfc2047 headers that might reflect how the content
646        // is encoded. Note that we preserve Content-Disposition as that
647        // isn't related purely to the how the content is encoded
648        self.headers.remove_all_named("Content-Type");
649        self.headers.remove_all_named("Content-Transfer-Encoding");
650        // And add any from the new part
651        self.headers.append(&mut new_part.headers.headers);
652        Ok(())
653    }
654
655    pub fn replace_binary_body(&mut self, content_type: &str, content: &[u8]) -> Result<()> {
656        let mut new_part = Self::new_binary(content_type, content, None)?;
657        self.bytes = new_part.bytes;
658        self.body_offset = new_part.body_offset;
659        self.body_len = new_part.body_len;
660        // Remove any rfc2047 headers that might reflect how the content
661        // is encoded. Note that we preserve Content-Disposition as that
662        // isn't related purely to the how the content is encoded
663        self.headers.remove_all_named("Content-Type");
664        self.headers.remove_all_named("Content-Transfer-Encoding");
665        // And add any from the new part
666        self.headers.append(&mut new_part.headers.headers);
667        Ok(())
668    }
669
670    pub fn new_no_transfer_encoding(content_type: &str, bytes: &[u8]) -> Result<Self> {
671        if bytes.iter().any(|b| !b.is_ascii()) {
672            return Err(MailParsingError::EightBit);
673        }
674
675        let mut headers = HeaderMap::default();
676
677        let ct = MimeParameters::new(content_type);
678        headers.set_content_type(ct)?;
679
680        let bytes = String::from_utf8_lossy(bytes).to_string();
681        let body_len = bytes.len();
682
683        Ok(Self {
684            bytes: bytes.into(),
685            headers,
686            body_offset: 0,
687            body_len,
688            conformance: MessageConformance::default(),
689            parts: vec![],
690            intro: "".into(),
691            outro: "".into(),
692        })
693    }
694
695    /// Constructs a new part with textual utf8 content.
696    /// quoted-printable transfer encoding will be applied,
697    /// unless it is smaller to represent the text in base64
698    pub fn new_text(content_type: &str, content: &str) -> Result<Self> {
699        // We'll probably use qp, so speculatively do the work
700        let qp_encoded = quoted_printable::encode(content);
701
702        let (mut encoded, encoding) = if qp_encoded == content.as_bytes() {
703            (qp_encoded, None)
704        } else if qp_encoded.len() <= BASE64_RFC2045.encode_len(content.len()) {
705            (qp_encoded, Some("quoted-printable"))
706        } else {
707            // Turns out base64 will be smaller; perhaps the content
708            // is dominated by non-ASCII text?
709            (
710                BASE64_RFC2045.encode(content.as_bytes()).into_bytes(),
711                Some("base64"),
712            )
713        };
714
715        if !encoded.ends_with(b"\r\n") {
716            encoded.extend_from_slice(b"\r\n");
717        }
718        let mut headers = HeaderMap::default();
719
720        let mut ct = MimeParameters::new(content_type);
721        ct.set(
722            "charset",
723            if content.is_ascii() {
724                "us-ascii"
725            } else {
726                "utf-8"
727            },
728        );
729        headers.set_content_type(ct)?;
730
731        if let Some(encoding) = encoding {
732            headers.set_content_transfer_encoding(MimeParameters::new(encoding))?;
733        }
734
735        let body_len = encoded.len();
736        let bytes =
737            String::from_utf8(encoded).expect("transfer encoder to produce valid ASCII output");
738
739        Ok(Self {
740            bytes: bytes.into(),
741            headers,
742            body_offset: 0,
743            body_len,
744            conformance: MessageConformance::default(),
745            parts: vec![],
746            intro: "".into(),
747            outro: "".into(),
748        })
749    }
750
751    pub fn new_text_plain(content: &str) -> Result<Self> {
752        Self::new_text("text/plain", content)
753    }
754
755    pub fn new_html(content: &str) -> Result<Self> {
756        Self::new_text("text/html", content)
757    }
758
759    pub fn new_multipart(
760        content_type: &str,
761        parts: Vec<Self>,
762        boundary: Option<&str>,
763    ) -> Result<Self> {
764        let mut headers = HeaderMap::default();
765
766        let mut ct = MimeParameters::new(content_type);
767        match boundary {
768            Some(b) => {
769                ct.set("boundary", b);
770            }
771            None => {
772                // Generate a random boundary
773                let uuid = uuid::Uuid::new_v4();
774                let boundary = data_encoding::BASE64_NOPAD.encode(uuid.as_bytes());
775                ct.set("boundary", &boundary);
776            }
777        }
778        headers.set_content_type(ct)?;
779
780        Ok(Self {
781            bytes: "".into(),
782            headers,
783            body_offset: 0,
784            body_len: 0,
785            conformance: MessageConformance::default(),
786            parts,
787            intro: "".into(),
788            outro: "".into(),
789        })
790    }
791
792    pub fn new_binary(
793        content_type: &str,
794        content: &[u8],
795        options: Option<&AttachmentOptions>,
796    ) -> Result<Self> {
797        let mut encoded = BASE64_RFC2045.encode(content);
798        if !encoded.ends_with("\r\n") {
799            encoded.push_str("\r\n");
800        }
801        let mut headers = HeaderMap::default();
802
803        let mut ct = MimeParameters::new(content_type);
804
805        if let Some(opts) = options {
806            let mut cd = MimeParameters::new(if opts.inline { "inline" } else { "attachment" });
807            if let Some(name) = &opts.file_name {
808                cd.set("filename", name);
809                let encoding = if name.chars().any(|c| !c.is_ascii()) {
810                    MimeParameterEncoding::QuotedRfc2047
811                } else {
812                    MimeParameterEncoding::None
813                };
814                ct.set_with_encoding("name", name, encoding);
815            }
816            headers.set_content_disposition(cd)?;
817
818            if let Some(id) = &opts.content_id {
819                headers.set_content_id(MessageID(id.to_string()))?;
820            }
821        }
822
823        headers.set_content_type(ct)?;
824        headers.set_content_transfer_encoding(MimeParameters::new("base64"))?;
825
826        let body_len = encoded.len();
827
828        Ok(Self {
829            bytes: encoded.into(),
830            headers,
831            body_offset: 0,
832            body_len,
833            conformance: MessageConformance::default(),
834            parts: vec![],
835            intro: "".into(),
836            outro: "".into(),
837        })
838    }
839
840    /// Returns a SimplifiedStructure representation of the mime tree,
841    /// with the (probable) primary text/plain and text/html parts
842    /// pulled out, and the remaining parts recorded as a flat
843    /// attachments array
844    pub fn simplified_structure(&'a self) -> Result<SimplifiedStructure<'a>> {
845        let parts = self.simplified_structure_pointers()?;
846
847        let mut text = None;
848        let mut html = None;
849        let mut amp_html = None;
850
851        let headers = &self
852            .resolve_ptr(parts.header_part)
853            .expect("header part to always be valid")
854            .headers;
855
856        if let Some(p) = parts.text_part.and_then(|p| self.resolve_ptr(p)) {
857            text = match p.body()? {
858                DecodedBody::Text(t) => Some(t),
859                DecodedBody::Binary(_) => {
860                    return Err(MailParsingError::BodyParse(
861                        "expected text/plain part to be text, but it is binary".to_string(),
862                    ))
863                }
864            };
865        }
866        if let Some(p) = parts.html_part.and_then(|p| self.resolve_ptr(p)) {
867            html = match p.body()? {
868                DecodedBody::Text(t) => Some(t),
869                DecodedBody::Binary(_) => {
870                    return Err(MailParsingError::BodyParse(
871                        "expected text/html part to be text, but it is binary".to_string(),
872                    ))
873                }
874            };
875        }
876        if let Some(p) = parts.amp_html_part.and_then(|p| self.resolve_ptr(p)) {
877            amp_html = match p.body()? {
878                DecodedBody::Text(t) => Some(t),
879                DecodedBody::Binary(_) => {
880                    return Err(MailParsingError::BodyParse(
881                        "expected text/x-amp-html part to be text, but it is binary".to_string(),
882                    ))
883                }
884            };
885        }
886
887        let mut attachments = vec![];
888        for ptr in parts.attachments {
889            attachments.push(self.resolve_ptr(ptr).expect("pointer to be valid").clone());
890        }
891
892        Ok(SimplifiedStructure {
893            text,
894            html,
895            amp_html,
896            headers,
897            attachments,
898        })
899    }
900
901    /// Resolve a PartPointer to the corresponding MimePart
902    pub fn resolve_ptr(&self, ptr: PartPointer) -> Option<&Self> {
903        let mut current = self;
904        let mut cursor = ptr.0.as_slice();
905
906        loop {
907            match cursor.first() {
908                Some(&idx) => {
909                    current = current.parts.get(idx as usize)?;
910                    cursor = &cursor[1..];
911                }
912                None => {
913                    // We have completed the walk
914                    return Some(current);
915                }
916            }
917        }
918    }
919
920    /// Resolve a PartPointer to the corresponding MimePart, for mutable access
921    pub fn resolve_ptr_mut(&mut self, ptr: PartPointer) -> Option<&mut Self> {
922        let mut current = self;
923        let mut cursor = ptr.0.as_slice();
924
925        loop {
926            match cursor.first() {
927                Some(&idx) => {
928                    current = current.parts.get_mut(idx as usize)?;
929                    cursor = &cursor[1..];
930                }
931                None => {
932                    // We have completed the walk
933                    return Some(current);
934                }
935            }
936        }
937    }
938
939    /// Returns a set of PartPointers that locate the (probable) primary
940    /// text/plain and text/html parts, and the remaining parts recorded
941    /// as a flat attachments array.  The resulting
942    /// PartPointers can be resolved to their actual instances for both
943    /// immutable and mutable operations via resolve_ptr and resolve_ptr_mut.
944    pub fn simplified_structure_pointers(&self) -> Result<SimplifiedStructurePointers> {
945        self.simplified_structure_pointers_impl(None)
946    }
947
948    fn simplified_structure_pointers_impl(
949        &self,
950        my_idx: Option<u8>,
951    ) -> Result<SimplifiedStructurePointers> {
952        let info = Rfc2045Info::new(&self.headers);
953        let is_inline = info
954            .attachment_options
955            .as_ref()
956            .map(|ao| ao.inline)
957            .unwrap_or(true);
958
959        if let Some(ct) = &info.content_type {
960            if is_inline {
961                if ct.value == "text/plain" {
962                    return Ok(SimplifiedStructurePointers {
963                        amp_html_part: None,
964                        text_part: Some(PartPointer::root_or_nth(my_idx)),
965                        html_part: None,
966                        header_part: PartPointer::root_or_nth(my_idx),
967                        attachments: vec![],
968                    });
969                }
970                if ct.value == "text/html" {
971                    return Ok(SimplifiedStructurePointers {
972                        amp_html_part: None,
973                        html_part: Some(PartPointer::root_or_nth(my_idx)),
974                        text_part: None,
975                        header_part: PartPointer::root_or_nth(my_idx),
976                        attachments: vec![],
977                    });
978                }
979                if ct.value == "text/x-amp-html" {
980                    return Ok(SimplifiedStructurePointers {
981                        amp_html_part: Some(PartPointer::root_or_nth(my_idx)),
982                        html_part: None,
983                        text_part: None,
984                        header_part: PartPointer::root_or_nth(my_idx),
985                        attachments: vec![],
986                    });
987                }
988            }
989
990            if ct.value.starts_with("multipart/") {
991                let mut text_part = None;
992                let mut html_part = None;
993                let mut amp_html_part = None;
994                let mut attachments = vec![];
995
996                for (i, p) in self.parts.iter().enumerate() {
997                    let part_idx = i.try_into().map_err(|_| MailParsingError::TooManyParts)?;
998                    if let Ok(mut s) = p.simplified_structure_pointers_impl(Some(part_idx)) {
999                        if let Some(p) = s.text_part {
1000                            if text_part.is_none() {
1001                                text_part.replace(PartPointer::root_or_nth(my_idx).append(p));
1002                            } else {
1003                                attachments.push(p);
1004                            }
1005                        }
1006                        if let Some(p) = s.html_part {
1007                            if html_part.is_none() {
1008                                html_part.replace(PartPointer::root_or_nth(my_idx).append(p));
1009                            } else {
1010                                attachments.push(p);
1011                            }
1012                        }
1013                        if let Some(p) = s.amp_html_part {
1014                            if amp_html_part.is_none() {
1015                                amp_html_part.replace(PartPointer::root_or_nth(my_idx).append(p));
1016                            } else {
1017                                attachments.push(p);
1018                            }
1019                        }
1020                        attachments.append(&mut s.attachments);
1021                    }
1022                }
1023
1024                return Ok(SimplifiedStructurePointers {
1025                    amp_html_part,
1026                    html_part,
1027                    text_part,
1028                    header_part: PartPointer::root_or_nth(my_idx),
1029                    attachments,
1030                });
1031            }
1032
1033            return Ok(SimplifiedStructurePointers {
1034                html_part: None,
1035                text_part: None,
1036                amp_html_part: None,
1037                header_part: PartPointer::root_or_nth(my_idx),
1038                attachments: vec![PartPointer::root_or_nth(my_idx)],
1039            });
1040        }
1041
1042        // Assume text/plain content-type
1043        Ok(SimplifiedStructurePointers {
1044            text_part: Some(PartPointer::root_or_nth(my_idx)),
1045            html_part: None,
1046            amp_html_part: None,
1047            header_part: PartPointer::root_or_nth(my_idx),
1048            attachments: vec![],
1049        })
1050    }
1051
1052    pub fn check_fix_conformance(
1053        &self,
1054        check: MessageConformance,
1055        fix: MessageConformance,
1056        settings: CheckFixSettings,
1057    ) -> Result<Option<Self>> {
1058        let mut msg = self.clone();
1059        let conformance = msg.deep_conformance_check();
1060
1061        // Don't raise errors for things that we're going to fix anyway
1062        let check = check - fix;
1063
1064        if check.intersects(conformance) {
1065            let problems = check.intersection(conformance);
1066            return Err(MailParsingError::ConformanceIssues(problems));
1067        }
1068
1069        if !fix.intersects(conformance) {
1070            return Ok(None);
1071        }
1072
1073        let to_fix = fix.intersection(conformance);
1074
1075        let missing_headers_only = to_fix
1076            .difference(
1077                MessageConformance::MISSING_DATE_HEADER
1078                    | MessageConformance::MISSING_MIME_VERSION
1079                    | MessageConformance::MISSING_MESSAGE_ID_HEADER,
1080            )
1081            .is_empty();
1082
1083        if !missing_headers_only {
1084            if to_fix.contains(MessageConformance::NEEDS_TRANSFER_ENCODING) {
1085                // Something is 8-bit. If we're lucky, it's simply UTF-8,
1086                // but it could be some other "legacy" charset encoding.
1087                // If we've been asked to detect an encoding, try that now,
1088                // and re-parse the message with the re-coded input.
1089                // Otherwise, we'll attempt a lossy conversion to UTF-8
1090                // and the resulting message will likely include unicode
1091                // replacement characters.
1092
1093                if settings.detect_encoding {
1094                    if let Some(data_bytes) = &settings.data_bytes {
1095                        let norm_settings = NormalizerSettings {
1096                            include_encodings: settings.include_encodings.clone(),
1097                            exclude_encodings: settings.exclude_encodings.clone(),
1098                            ..Default::default()
1099                        };
1100
1101                        let guess =
1102                            charset_normalizer_rs::from_bytes(&*data_bytes, Some(norm_settings))
1103                                .map_err(|err| MailParsingError::CharsetDetectionFailed(err))?;
1104                        if let Some(best) = guess.get_best() {
1105                            if let Some(decoded) = best.decoded_payload() {
1106                                msg = MimePart::parse(decoded.to_string())?;
1107                            }
1108                        }
1109                    }
1110                }
1111            }
1112
1113            msg = msg.rebuild(Some(&settings))?;
1114        }
1115
1116        if to_fix.contains(MessageConformance::MISSING_DATE_HEADER) {
1117            msg.headers_mut().set_date(Utc::now())?;
1118        }
1119
1120        if to_fix.contains(MessageConformance::MISSING_MIME_VERSION) {
1121            msg.headers_mut().set_mime_version("1.0")?;
1122        }
1123
1124        if to_fix.contains(MessageConformance::MISSING_MESSAGE_ID_HEADER) {
1125            if let Some(message_id) = &settings.message_id {
1126                msg.headers_mut()
1127                    .set_message_id(MessageID(message_id.clone()))?;
1128            }
1129        }
1130
1131        Ok(Some(msg))
1132    }
1133}
1134
1135#[derive(Default, Debug, Clone, Deserialize)]
1136pub struct CheckFixSettings {
1137    #[serde(default)]
1138    pub detect_encoding: bool,
1139    #[serde(default)]
1140    pub include_encodings: Vec<String>,
1141    #[serde(default)]
1142    pub exclude_encodings: Vec<String>,
1143    #[serde(default)]
1144    pub message_id: Option<String>,
1145    #[serde(skip)]
1146    pub data_bytes: Option<Arc<Box<[u8]>>>,
1147}
1148
1149/// References the position of a MimePart by encoding the steps in
1150/// a tree walking operation. The encoding of PartPointer is a
1151/// sequence of integers that identify the index of a child part
1152/// by its level within the mime tree, selecting the current node
1153/// when no more indices remain. eg: `[]` indicates the
1154/// root part, while `[0]` is the 0th child of the root.
1155#[derive(Debug, Clone, PartialEq, Eq)]
1156pub struct PartPointer(Vec<u8>);
1157
1158impl PartPointer {
1159    /// Construct a PartPointer that references the root node
1160    pub fn root() -> Self {
1161        Self(vec![])
1162    }
1163
1164    /// Construct a PartPointer that references either the nth
1165    /// or the root node depending upon the passed parameter
1166    pub fn root_or_nth(n: Option<u8>) -> Self {
1167        match n {
1168            Some(n) => Self::nth(n),
1169            None => Self::root(),
1170        }
1171    }
1172
1173    /// Construct a PartPointer that references the nth child
1174    pub fn nth(n: u8) -> Self {
1175        Self(vec![n])
1176    }
1177
1178    /// Join other onto self, consuming self and producing
1179    /// a pointer that makes other relative to self
1180    pub fn append(mut self, mut other: Self) -> Self {
1181        self.0.append(&mut other.0);
1182        Self(self.0)
1183    }
1184
1185    pub fn id_string(&self) -> String {
1186        let mut id = String::new();
1187        for p in &self.0 {
1188            if !id.is_empty() {
1189                id.push('.');
1190            }
1191            id.push_str(&p.to_string());
1192        }
1193        id
1194    }
1195}
1196
1197#[derive(Debug, Clone)]
1198pub struct SimplifiedStructurePointers {
1199    /// The primary text/plain part
1200    pub text_part: Option<PartPointer>,
1201    /// The primary text/html part
1202    pub html_part: Option<PartPointer>,
1203    /// The primary text/x-amp-html part
1204    pub amp_html_part: Option<PartPointer>,
1205    /// The "top level" set of headers for the message
1206    pub header_part: PartPointer,
1207    /// all other (terminal) parts are attachments
1208    pub attachments: Vec<PartPointer>,
1209}
1210
1211#[derive(Debug, Clone)]
1212pub struct SimplifiedStructure<'a> {
1213    pub text: Option<SharedString<'a>>,
1214    pub html: Option<SharedString<'a>>,
1215    pub amp_html: Option<SharedString<'a>>,
1216    pub headers: &'a HeaderMap<'a>,
1217    pub attachments: Vec<MimePart<'a>>,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1221#[serde(deny_unknown_fields)]
1222pub struct AttachmentOptions {
1223    #[serde(default)]
1224    pub file_name: Option<String>,
1225    #[serde(default)]
1226    pub inline: bool,
1227    #[serde(default)]
1228    pub content_id: Option<String>,
1229}
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1232pub enum ContentTransferEncoding {
1233    SevenBit,
1234    EightBit,
1235    Binary,
1236    QuotedPrintable,
1237    Base64,
1238}
1239
1240impl FromStr for ContentTransferEncoding {
1241    type Err = MailParsingError;
1242
1243    fn from_str(s: &str) -> Result<Self> {
1244        if s.eq_ignore_ascii_case("7bit") {
1245            Ok(Self::SevenBit)
1246        } else if s.eq_ignore_ascii_case("8bit") {
1247            Ok(Self::EightBit)
1248        } else if s.eq_ignore_ascii_case("binary") {
1249            Ok(Self::Binary)
1250        } else if s.eq_ignore_ascii_case("quoted-printable") {
1251            Ok(Self::QuotedPrintable)
1252        } else if s.eq_ignore_ascii_case("base64") {
1253            Ok(Self::Base64)
1254        } else {
1255            Err(MailParsingError::InvalidContentTransferEncoding(
1256                s.to_string(),
1257            ))
1258        }
1259    }
1260}
1261
1262#[derive(Debug, PartialEq)]
1263pub enum DecodedBody<'a> {
1264    Text(SharedString<'a>),
1265    Binary(Vec<u8>),
1266}
1267
1268impl<'a> DecodedBody<'a> {
1269    pub fn to_string_lossy(&'a self) -> Cow<'a, str> {
1270        match self {
1271            Self::Text(s) => Cow::Borrowed(s),
1272            Self::Binary(b) => String::from_utf8_lossy(b),
1273        }
1274    }
1275}
1276
1277#[cfg(test)]
1278mod test {
1279    use super::*;
1280
1281    #[test]
1282    fn msg_parsing() {
1283        let message = concat!(
1284            "Subject: hello there\n",
1285            "From:  Someone <someone@example.com>\n",
1286            "\n",
1287            "I am the body"
1288        );
1289
1290        let part = MimePart::parse(message).unwrap();
1291        k9::assert_equal!(message, part.to_message_string());
1292        assert_eq!(part.raw_body(), "I am the body");
1293        k9::snapshot!(
1294            part.body(),
1295            r#"
1296Ok(
1297    Text(
1298        "I am the body",
1299    ),
1300)
1301"#
1302        );
1303
1304        k9::snapshot!(
1305            part.rebuild(None).unwrap().to_message_string(),
1306            r#"
1307Content-Type: text/plain;\r
1308\tcharset="us-ascii"\r
1309Subject: hello there\r
1310From: Someone <someone@example.com>\r
1311\r
1312I am the body\r
1313
1314"#
1315        );
1316    }
1317
1318    #[test]
1319    fn mime_bogus_body() {
1320        let message = concat!(
1321            "Subject: hello there\n",
1322            "From: Someone <someone@example.com>\n",
1323            "Mime-Version: 1.0\n",
1324            "Content-Type: text/plain\n",
1325            "Content-Transfer-Encoding: base64\n",
1326            "\n",
1327            "hello\n"
1328        );
1329
1330        let part = MimePart::parse(message).unwrap();
1331        assert_eq!(
1332            part.body().unwrap_err(),
1333            MailParsingError::BodyParse(
1334                "base64 decode: invalid length at 4 b='o' in hello\n".to_string()
1335            )
1336        );
1337    }
1338
1339    #[test]
1340    fn mime_encoded_body() {
1341        let message = concat!(
1342            "Subject: hello there\n",
1343            "From: Someone <someone@example.com>\n",
1344            "Mime-Version: 1.0\n",
1345            "Content-Type: text/plain\n",
1346            "Content-Transfer-Encoding: base64\n",
1347            "\n",
1348            "aGVsbG8K\n"
1349        );
1350
1351        let part = MimePart::parse(message).unwrap();
1352        k9::assert_equal!(message, part.to_message_string());
1353        assert_eq!(part.raw_body(), "aGVsbG8K\n");
1354        k9::snapshot!(
1355            part.body(),
1356            r#"
1357Ok(
1358    Text(
1359        "hello
1360",
1361    ),
1362)
1363"#
1364        );
1365
1366        k9::snapshot!(
1367            part.rebuild(None).unwrap().to_message_string(),
1368            r#"
1369Content-Type: text/plain;\r
1370\tcharset="us-ascii"\r
1371Content-Transfer-Encoding: quoted-printable\r
1372Subject: hello there\r
1373From: Someone <someone@example.com>\r
1374Mime-Version: 1.0\r
1375\r
1376hello=0A\r
1377
1378"#
1379        );
1380    }
1381
1382    #[test]
1383    fn mime_multipart_1() {
1384        let message = concat!(
1385            "Subject: This is a test email\n",
1386            "Content-Type: multipart/alternative; boundary=foobar\n",
1387            "Mime-Version: 1.0\n",
1388            "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\n",
1389            "\n",
1390            "--foobar\n",
1391            "Content-Type: text/plain; charset=utf-8\n",
1392            "Content-Transfer-Encoding: quoted-printable\n",
1393            "\n",
1394            "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\n",
1395            "--foobar\n",
1396            "Content-Type: text/html\n",
1397            "Content-Transfer-Encoding: base64\n",
1398            "\n",
1399            "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \n",
1400            "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \n",
1401            "--foobar--\n",
1402            "After the final boundary stuff gets ignored.\n"
1403        );
1404
1405        let part = MimePart::parse(message).unwrap();
1406
1407        k9::assert_equal!(message, part.to_message_string());
1408
1409        let children = part.child_parts();
1410        k9::assert_equal!(children.len(), 2);
1411
1412        k9::snapshot!(
1413            children[0].body(),
1414            r#"
1415Ok(
1416    Text(
1417        "This is the plaintext version, in utf-8. Proof by Euro: €\r
1418",
1419    ),
1420)
1421"#
1422        );
1423        k9::snapshot!(
1424            children[1].body(),
1425            r#"
1426Ok(
1427    Text(
1428        "<html><body>This is the <b>HTML</b> version, in us-ascii. Proof by Euro: &euro;</body></html>
1429",
1430    ),
1431)
1432"#
1433        );
1434    }
1435
1436    #[test]
1437    fn mutate_1() {
1438        let message = concat!(
1439            "Subject: This is a test email\r\n",
1440            "Content-Type: multipart/alternative; boundary=foobar\r\n",
1441            "Mime-Version: 1.0\r\n",
1442            "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1443            "\r\n",
1444            "--foobar\r\n",
1445            "Content-Type: text/plain; charset=utf-8\r\n",
1446            "Content-Transfer-Encoding: quoted-printable\r\n",
1447            "\r\n",
1448            "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r\n",
1449            "--foobar\r\n",
1450            "Content-Type: text/html\r\n",
1451            "Content-Transfer-Encoding: base64\r\n",
1452            "\r\n",
1453            "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r\n",
1454            "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r\n",
1455            "--foobar--\r\n",
1456            "After the final boundary stuff gets ignored.\r\n"
1457        );
1458
1459        let mut part = MimePart::parse(message).unwrap();
1460        k9::assert_equal!(message, part.to_message_string());
1461        fn munge(part: &mut MimePart) {
1462            let headers = part.headers_mut();
1463            headers.push(Header::with_name_value("X-Woot", "Hello"));
1464            headers.insert(0, Header::with_name_value("X-First", "at the top"));
1465            headers.retain(|hdr| !hdr.get_name().eq_ignore_ascii_case("date"));
1466        }
1467        munge(&mut part);
1468
1469        let re_encoded = part.to_message_string();
1470        k9::snapshot!(
1471            re_encoded,
1472            r#"
1473X-First: at the top\r
1474Subject: This is a test email\r
1475Content-Type: multipart/alternative; boundary=foobar\r
1476Mime-Version: 1.0\r
1477X-Woot: Hello\r
1478\r
1479--foobar\r
1480Content-Type: text/plain; charset=utf-8\r
1481Content-Transfer-Encoding: quoted-printable\r
1482\r
1483This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r
1484--foobar\r
1485Content-Type: text/html\r
1486Content-Transfer-Encoding: base64\r
1487\r
1488PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1489dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1490--foobar--\r
1491After the final boundary stuff gets ignored.\r
1492
1493"#
1494        );
1495
1496        eprintln!("part before mutate:\n{part:#?}");
1497
1498        part.child_parts_mut().retain(|part| {
1499            let ct = part.headers().content_type().unwrap().unwrap();
1500            ct.value == "text/html"
1501        });
1502
1503        eprintln!("part with html removed is:\n{part:#?}");
1504
1505        let re_encoded = part.to_message_string();
1506        k9::snapshot!(
1507            re_encoded,
1508            r#"
1509X-First: at the top\r
1510Subject: This is a test email\r
1511Content-Type: multipart/alternative; boundary=foobar\r
1512Mime-Version: 1.0\r
1513X-Woot: Hello\r
1514\r
1515--foobar\r
1516Content-Type: text/html\r
1517Content-Transfer-Encoding: base64\r
1518\r
1519PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1520dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1521--foobar--\r
1522After the final boundary stuff gets ignored.\r
1523
1524"#
1525        );
1526    }
1527
1528    #[test]
1529    fn replace_text_body() {
1530        let mut part = MimePart::new_text_plain("Hello 👻\r\n").unwrap();
1531        let encoded = part.to_message_string();
1532        k9::snapshot!(
1533            &encoded,
1534            r#"
1535Content-Type: text/plain;\r
1536\tcharset="utf-8"\r
1537Content-Transfer-Encoding: base64\r
1538\r
1539SGVsbG8g8J+Ruw0K\r
1540
1541"#
1542        );
1543
1544        part.replace_text_body("text/plain", "Hello 🚀\r\n")
1545            .unwrap();
1546        let encoded = part.to_message_string();
1547        k9::snapshot!(
1548            &encoded,
1549            r#"
1550Content-Type: text/plain;\r
1551\tcharset="utf-8"\r
1552Content-Transfer-Encoding: base64\r
1553\r
1554SGVsbG8g8J+agA0K\r
1555
1556"#
1557        );
1558    }
1559
1560    #[test]
1561    fn construct_1() {
1562        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";
1563
1564        let part = MimePart::new_text_plain(input_text).unwrap();
1565
1566        let encoded = part.to_message_string();
1567        k9::snapshot!(
1568            &encoded,
1569            r#"
1570Content-Type: text/plain;\r
1571\tcharset="utf-8"\r
1572Content-Transfer-Encoding: quoted-printable\r
1573\r
1574Well, hello there! This is the plaintext version, in utf-8. Here's a Euro: =\r
1575=E2=82=AC, and here are some emoji =F0=9F=91=BB =F0=9F=8D=89 =F0=9F=92=A9 a=\r
1576nd this long should be long enough that we wrap it in the returned part, le=\r
1577t's see how that turns out!\r
1578
1579"#
1580        );
1581
1582        let parsed_part = MimePart::parse(encoded.clone()).unwrap();
1583        k9::assert_equal!(encoded.as_str(), parsed_part.to_message_string().as_str());
1584        k9::assert_equal!(part.body().unwrap(), DecodedBody::Text(input_text.into()));
1585        k9::snapshot!(
1586            parsed_part.simplified_structure_pointers(),
1587            "
1588Ok(
1589    SimplifiedStructurePointers {
1590        text_part: Some(
1591            PartPointer(
1592                [],
1593            ),
1594        ),
1595        html_part: None,
1596        amp_html_part: None,
1597        header_part: PartPointer(
1598            [],
1599        ),
1600        attachments: [],
1601    },
1602)
1603"
1604        );
1605    }
1606
1607    #[test]
1608    fn construct_2() {
1609        let msg = MimePart::new_multipart(
1610            "multipart/mixed",
1611            vec![
1612                MimePart::new_text_plain("plain text").unwrap(),
1613                MimePart::new_html("<b>rich</b> text").unwrap(),
1614                MimePart::new_binary(
1615                    "application/octet-stream",
1616                    &[0, 1, 2, 3],
1617                    Some(&AttachmentOptions {
1618                        file_name: Some("woot.bin".to_string()),
1619                        inline: false,
1620                        content_id: Some("woot.id".to_string()),
1621                    }),
1622                )
1623                .unwrap(),
1624            ],
1625            Some("my-boundary"),
1626        )
1627        .unwrap();
1628        k9::snapshot!(
1629            msg.to_message_string(),
1630            r#"
1631Content-Type: multipart/mixed;\r
1632\tboundary="my-boundary"\r
1633\r
1634--my-boundary\r
1635Content-Type: text/plain;\r
1636\tcharset="us-ascii"\r
1637\r
1638plain text\r
1639--my-boundary\r
1640Content-Type: text/html;\r
1641\tcharset="us-ascii"\r
1642\r
1643<b>rich</b> text\r
1644--my-boundary\r
1645Content-Disposition: attachment;\r
1646\tfilename="woot.bin"\r
1647Content-ID: <woot.id>\r
1648Content-Type: application/octet-stream;\r
1649\tname="woot.bin"\r
1650Content-Transfer-Encoding: base64\r
1651\r
1652AAECAw==\r
1653--my-boundary--\r
1654
1655"#
1656        );
1657
1658        k9::snapshot!(
1659            msg.simplified_structure_pointers(),
1660            "
1661Ok(
1662    SimplifiedStructurePointers {
1663        text_part: Some(
1664            PartPointer(
1665                [
1666                    0,
1667                ],
1668            ),
1669        ),
1670        html_part: Some(
1671            PartPointer(
1672                [
1673                    1,
1674                ],
1675            ),
1676        ),
1677        amp_html_part: None,
1678        header_part: PartPointer(
1679            [],
1680        ),
1681        attachments: [
1682            PartPointer(
1683                [
1684                    2,
1685                ],
1686            ),
1687        ],
1688    },
1689)
1690"
1691        );
1692    }
1693
1694    #[test]
1695    fn attachment_name_order_prefers_content_disposition() {
1696        let message = concat!(
1697            "Content-Type: multipart/mixed;\r\n",
1698            "	boundary=\"woot\"\r\n",
1699            "\r\n",
1700            "--woot\r\n",
1701            "Content-Type: text/plain;\r\n",
1702            "	charset=\"us-ascii\"\r\n",
1703            "\r\n",
1704            "Hello, I am the main message content\r\n",
1705            "--woot\r\n",
1706            "Content-Disposition: attachment;\r\n",
1707            "	filename=cdname\r\n",
1708            "Content-Type: application/octet-stream;\r\n",
1709            "	name=ctname\r\n",
1710            "Content-Transfer-Encoding: base64\r\n",
1711            "\r\n",
1712            "u6o=\r\n",
1713            "--woot--\r\n"
1714        );
1715        let part = MimePart::parse(message).unwrap();
1716        let structure = part.simplified_structure().unwrap();
1717
1718        k9::assert_equal!(
1719            structure.attachments[0].rfc2045_info().attachment_options,
1720            Some(AttachmentOptions {
1721                content_id: None,
1722                inline: false,
1723                file_name: Some("cdname".to_string()),
1724            })
1725        );
1726    }
1727
1728    #[test]
1729    fn attachment_name_accepts_content_type_name() {
1730        let message = concat!(
1731            "Content-Type: multipart/mixed;\r\n",
1732            "	boundary=\"woot\"\r\n",
1733            "\r\n",
1734            "--woot\r\n",
1735            "Content-Type: text/plain;\r\n",
1736            "	charset=\"us-ascii\"\r\n",
1737            "\r\n",
1738            "Hello, I am the main message content\r\n",
1739            "--woot\r\n",
1740            "Content-Disposition: attachment\r\n",
1741            "Content-Type: application/octet-stream;\r\n",
1742            "	name=ctname\r\n",
1743            "Content-Transfer-Encoding: base64\r\n",
1744            "\r\n",
1745            "u6o=\r\n",
1746            "--woot--\r\n"
1747        );
1748        let part = MimePart::parse(message).unwrap();
1749        let structure = part.simplified_structure().unwrap();
1750
1751        k9::assert_equal!(
1752            structure.attachments[0].rfc2045_info().attachment_options,
1753            Some(AttachmentOptions {
1754                content_id: None,
1755                inline: false,
1756                file_name: Some("ctname".to_string()),
1757            })
1758        );
1759    }
1760
1761    #[test]
1762    fn funky_headers() {
1763        let message = concat!(
1764            "Subject\r\n",
1765            "Other:\r\n",
1766            "Content-Type: multipart/alternative; boundary=foobar\r\n",
1767            "Mime-Version: 1.0\r\n",
1768            "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1769            "\r\n",
1770            "The body.\r\n"
1771        );
1772
1773        let part = MimePart::parse(message).unwrap();
1774        assert!(part
1775            .conformance()
1776            .contains(MessageConformance::MISSING_COLON_VALUE));
1777    }
1778
1779    /// This is a regression test for an issue where we'd interpret the
1780    /// binary bytes as default windows-1252 codepage charset, and mangle them.
1781    /// The high byte is sufficient to trigger the offending code prior
1782    /// to the fix
1783    #[test]
1784    fn rebuild_binary() {
1785        let expect = &[0, 1, 2, 3, 0xbe, 4, 5];
1786        let part = MimePart::new_binary("applicat/octet-stream", expect, None).unwrap();
1787
1788        let rebuilt = part.rebuild(None).unwrap();
1789        let body = rebuilt.body().unwrap();
1790
1791        assert_eq!(body, DecodedBody::Binary(expect.to_vec()));
1792    }
1793
1794    /// Validate that we don't lose supplemental mime parameters like:
1795    /// `Content-Type: text/calendar; method=REQUEST`
1796    #[test]
1797    fn rebuild_invitation() {
1798        let message = concat!(
1799            "Subject: Test for events 2\r\n",
1800            "Content-Type: multipart/mixed;\r\n",
1801            " boundary=8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1802            "\r\n",
1803            "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1804            "Content-Type: multipart/alternative;\r\n",
1805            " boundary=a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1806            "\r\n",
1807            "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1808            "Content-Transfer-Encoding: quoted-printable\r\n",
1809            "Content-Type: text/plain; charset=UTF-8\r\n",
1810            "\r\n",
1811            "This is a test for calendar event invitation\r\n",
1812            "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1813            "Content-Transfer-Encoding: quoted-printable\r\n",
1814            "Content-Type: text/html; charset=UTF-8\r\n",
1815            "\r\n",
1816            "<p>This is a test for calendar event invitation</p>\r\n",
1817            "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f--\r\n",
1818            "\r\n",
1819            "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1820            "Content-Disposition: inline; name=\"Invitation.ics\"\r\n",
1821            "Content-Type: text/calendar; method=REQUEST; name=\"Invitation.ics\"\r\n",
1822            "\r\n",
1823            "Invitation\r\n",
1824            "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1825            "Content-Disposition: attachment; filename=\"event.ics\"\r\n",
1826            "Content-Type: application/ics\r\n",
1827            "\r\n",
1828            "Event\r\n",
1829            "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3--\r\n",
1830            "\r\n"
1831        );
1832
1833        let part = MimePart::parse(message).unwrap();
1834        let rebuilt = part.rebuild(None).unwrap();
1835
1836        k9::snapshot!(
1837            rebuilt.to_message_string(),
1838            r#"
1839Content-Type: multipart/mixed;\r
1840\tboundary="8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3"\r
1841Subject: Test for events 2\r
1842\r
1843--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1844Content-Type: multipart/alternative;\r
1845\tboundary="a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f"\r
1846\r
1847--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r
1848Content-Type: text/plain;\r
1849\tcharset="us-ascii"\r
1850\r
1851This is a test for calendar event invitation\r
1852--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r
1853Content-Type: text/html;\r
1854\tcharset="us-ascii"\r
1855\r
1856<p>This is a test for calendar event invitation</p>\r
1857--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f--\r
1858--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1859Content-Type: text/calendar;\r
1860\tcharset="us-ascii";\r
1861\tmethod="REQUEST";\r
1862\tname="Invitation.ics"\r
1863\r
1864Invitation\r
1865--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1866Content-Disposition: attachment;\r
1867\tfilename="event.ics"\r
1868Content-Type: application/ics;\r
1869\tname="event.ics"\r
1870Content-Transfer-Encoding: base64\r
1871\r
1872RXZlbnQNCg==\r
1873--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3--\r
1874
1875"#
1876        );
1877    }
1878
1879    #[test]
1880    fn check_conformance_angle_msg_id() {
1881        const DOUBLE_ANGLE_ONLY: &str = "Subject: hello\r
1882Message-ID: <<1234@example.com>>\r
1883\r
1884Hello";
1885        let msg = MimePart::parse(DOUBLE_ANGLE_ONLY).unwrap();
1886        k9::snapshot!(
1887            msg.check_fix_conformance(
1888                MessageConformance::MISSING_MESSAGE_ID_HEADER,
1889                MessageConformance::empty(),
1890                CheckFixSettings::default(),
1891            )
1892            .unwrap_err()
1893            .to_string(),
1894            "Message has conformance issues: MISSING_MESSAGE_ID_HEADER"
1895        );
1896
1897        let rebuilt = msg
1898            .check_fix_conformance(
1899                MessageConformance::MISSING_MESSAGE_ID_HEADER,
1900                MessageConformance::MISSING_MESSAGE_ID_HEADER,
1901                CheckFixSettings {
1902                    message_id: Some("id@example.com".to_string()),
1903                    ..Default::default()
1904                },
1905            )
1906            .unwrap()
1907            .unwrap()
1908            .to_message_string();
1909
1910        k9::snapshot!(
1911            rebuilt,
1912            r#"
1913Subject: hello\r
1914Message-ID: <id@example.com>\r
1915\r
1916Hello
1917"#
1918        );
1919
1920        const DOUBLE_ANGLE_AND_LONG_LINE: &str = "Subject: hello\r
1921Message-ID: <<1234@example.com>>\r
1922\r
1923Hello this is a really long line Hello this is a really long line \
1924Hello this is a really long line Hello this is a really long line \
1925Hello this is a really long line Hello this is a really long line \
1926Hello this is a really long line Hello this is a really long line \
1927Hello this is a really long line Hello this is a really long line \
1928Hello this is a really long line Hello this is a really long line \
1929Hello this is a really long line Hello this is a really long line
1930";
1931        let msg = MimePart::parse(DOUBLE_ANGLE_AND_LONG_LINE).unwrap();
1932        let rebuilt = msg
1933            .check_fix_conformance(
1934                MessageConformance::MISSING_COLON_VALUE,
1935                MessageConformance::MISSING_MESSAGE_ID_HEADER | MessageConformance::LINE_TOO_LONG,
1936                CheckFixSettings {
1937                    message_id: Some("id@example.com".to_string()),
1938                    ..Default::default()
1939                },
1940            )
1941            .unwrap()
1942            .unwrap()
1943            .to_message_string();
1944
1945        k9::snapshot!(
1946            rebuilt,
1947            r#"
1948Content-Type: text/plain;\r
1949\tcharset="us-ascii"\r
1950Content-Transfer-Encoding: quoted-printable\r
1951Subject: hello\r
1952Message-ID: <id@example.com>\r
1953\r
1954Hello this is a really long line Hello this is a really long line Hello thi=\r
1955s is a really long line Hello this is a really long line Hello this is a re=\r
1956ally long line Hello this is a really long line Hello this is a really long=\r
1957 line Hello this is a really long line Hello this is a really long line Hel=\r
1958lo this is a really long line Hello this is a really long line Hello this i=\r
1959s a really long line Hello this is a really long line Hello this is a reall=\r
1960y long line=0A\r
1961
1962"#
1963        );
1964    }
1965
1966    #[test]
1967    fn check_conformance() {
1968        const MULTI_HEADER_CONTENT: &str =
1969        "X-Hello: there\r\nX-Header: value\r\nSubject: Hello\r\nX-Header: another value\r\nFrom :Someone@somewhere\r\n\r\nBody";
1970
1971        let msg = MimePart::parse(MULTI_HEADER_CONTENT).unwrap();
1972        let rebuilt = msg
1973            .check_fix_conformance(
1974                MessageConformance::default(),
1975                MessageConformance::MISSING_MIME_VERSION,
1976                CheckFixSettings::default(),
1977            )
1978            .unwrap()
1979            .unwrap()
1980            .to_message_string();
1981        k9::snapshot!(
1982            rebuilt,
1983            r#"
1984X-Hello: there\r
1985X-Header: value\r
1986Subject: Hello\r
1987X-Header: another value\r
1988From :Someone@somewhere\r
1989Mime-Version: 1.0\r
1990\r
1991Body
1992"#
1993        );
1994
1995        let msg = MimePart::parse(MULTI_HEADER_CONTENT).unwrap();
1996        let rebuilt = msg
1997            .check_fix_conformance(
1998                MessageConformance::default(),
1999                MessageConformance::MISSING_MIME_VERSION | MessageConformance::NAME_ENDS_WITH_SPACE,
2000                CheckFixSettings::default(),
2001            )
2002            .unwrap()
2003            .unwrap()
2004            .to_message_string();
2005        k9::snapshot!(
2006            rebuilt,
2007            r#"
2008Content-Type: text/plain;\r
2009\tcharset="us-ascii"\r
2010X-Hello: there\r
2011X-Header: value\r
2012Subject: Hello\r
2013X-Header: another value\r
2014From: <Someone@somewhere>\r
2015Mime-Version: 1.0\r
2016\r
2017Body\r
2018
2019"#
2020        );
2021    }
2022
2023    #[test]
2024    fn check_fix_latin_input() {
2025        const POUNDS: &[u8] = b"Subject: \xa3\r\n\r\nGBP\r\n";
2026        let msg = MimePart::parse(POUNDS).unwrap();
2027        assert_eq!(
2028            msg.conformance(),
2029            MessageConformance::NEEDS_TRANSFER_ENCODING
2030                | MessageConformance::MISSING_DATE_HEADER
2031                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2032                | MessageConformance::MISSING_MIME_VERSION
2033        );
2034        let rebuilt = msg
2035            .check_fix_conformance(
2036                MessageConformance::default(),
2037                MessageConformance::NEEDS_TRANSFER_ENCODING,
2038                CheckFixSettings {
2039                    detect_encoding: true,
2040                    include_encodings: vec!["iso-8859-1".to_string()],
2041                    data_bytes: Some(Arc::new(POUNDS.into())),
2042                    ..Default::default()
2043                },
2044            )
2045            .unwrap()
2046            .unwrap();
2047
2048        let subject = rebuilt.headers.subject().unwrap().unwrap();
2049        assert_eq!(subject, "£");
2050    }
2051
2052    // The issue here is that the message is text/plain with no explicit
2053    // charset, and is thus implicitly us-ascii.  But the part is actually
2054    // utf-8 content inside base64. Since the transfer encoding is 7-bit
2055    // it doesn't get flagged as improper encoding during the initial
2056    // parse.
2057    // We want to ensure that it is found during check-fix, and is corrected.
2058    #[test]
2059    fn check_fix_utf8_inside_transfer_encoding() {
2060        const CONTENT: &str = "Subject: hello\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n2KrYs9iqDQoNCg==\r\n";
2061
2062        let msg = MimePart::parse(CONTENT).unwrap();
2063
2064        // Initial parse cannot see that the content is actually utf-8,
2065        // which conflicts with the implicit us-ascii charset for a text/ part.
2066        assert_eq!(
2067            msg.conformance(),
2068            MessageConformance::MISSING_DATE_HEADER
2069                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2070                | MessageConformance::MISSING_MIME_VERSION
2071        );
2072
2073        // Deep check flags the invalid charset and sets NEEDS_TRANSFER_ENCODING
2074        assert_eq!(
2075            msg.deep_conformance_check(),
2076            MessageConformance::NEEDS_TRANSFER_ENCODING
2077                | MessageConformance::MISSING_DATE_HEADER
2078                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2079                | MessageConformance::MISSING_MIME_VERSION
2080        );
2081        let rebuilt = msg
2082            .check_fix_conformance(
2083                MessageConformance::default(),
2084                MessageConformance::NEEDS_TRANSFER_ENCODING,
2085                CheckFixSettings::default(),
2086            )
2087            .unwrap()
2088            .unwrap();
2089
2090        eprintln!("{rebuilt:?}");
2091        assert_eq!(rebuilt.body().unwrap().to_string_lossy().trim(), "تست");
2092    }
2093
2094    #[test]
2095    fn check_fix_latin1_inside_transfer_encoding() {
2096        const CONTENT: &str = "Subject: hello\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\nVGhlIGNvc3QgaXMgozQyLjAwCg==\r\n";
2097
2098        let msg = MimePart::parse(CONTENT).unwrap();
2099
2100        // Initial parse cannot see that the content is actually utf-8,
2101        // which conflicts with the implicit us-ascii charset for a text/ part.
2102        assert_eq!(
2103            msg.conformance(),
2104            MessageConformance::MISSING_DATE_HEADER
2105                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2106                | MessageConformance::MISSING_MIME_VERSION
2107        );
2108
2109        // Deep check flags the invalid charset and sets NEEDS_TRANSFER_ENCODING
2110        assert_eq!(
2111            msg.deep_conformance_check(),
2112            MessageConformance::NEEDS_TRANSFER_ENCODING
2113                | MessageConformance::MISSING_DATE_HEADER
2114                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2115                | MessageConformance::MISSING_MIME_VERSION
2116        );
2117        let rebuilt = msg
2118            .check_fix_conformance(
2119                MessageConformance::default(),
2120                MessageConformance::NEEDS_TRANSFER_ENCODING,
2121                CheckFixSettings {
2122                    detect_encoding: true,
2123                    include_encodings: vec!["iso-8859-1".to_string()],
2124                    ..Default::default()
2125                },
2126            )
2127            .unwrap()
2128            .unwrap();
2129
2130        eprintln!("{rebuilt:?}");
2131        assert_eq!(
2132            rebuilt.body().unwrap().to_string_lossy().trim(),
2133            "The cost is £42.00"
2134        );
2135    }
2136
2137    #[test]
2138    fn check_fix_unknown_inside_transfer_encoding() {
2139        // `owo=` is 0xa3 (a UK Sterling/Pound sign in latin-1.
2140        // The length of the data passed to the charset detector
2141        // is insufficient for it to decide the charset, so we
2142        // should not expect to see a valid text part emitted.
2143        const CONTENT: &str = "Subject: hello\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\nowo=\r\n";
2144
2145        let msg = MimePart::parse(CONTENT).unwrap();
2146
2147        // Initial parse cannot see that the content is actually utf-8,
2148        // which conflicts with the implicit us-ascii charset for a text/ part.
2149        assert_eq!(
2150            msg.conformance(),
2151            MessageConformance::MISSING_DATE_HEADER
2152                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2153                | MessageConformance::MISSING_MIME_VERSION
2154        );
2155
2156        // Deep check flags the invalid charset and sets NEEDS_TRANSFER_ENCODING
2157        assert_eq!(
2158            msg.deep_conformance_check(),
2159            MessageConformance::NEEDS_TRANSFER_ENCODING
2160                | MessageConformance::MISSING_DATE_HEADER
2161                | MessageConformance::MISSING_MESSAGE_ID_HEADER
2162                | MessageConformance::MISSING_MIME_VERSION
2163        );
2164        let rebuilt = msg
2165            .check_fix_conformance(
2166                MessageConformance::default(),
2167                MessageConformance::NEEDS_TRANSFER_ENCODING,
2168                CheckFixSettings {
2169                    detect_encoding: true,
2170                    include_encodings: vec!["iso-8859-1".to_string()],
2171                    ..Default::default()
2172                },
2173            )
2174            .unwrap()
2175            .unwrap();
2176
2177        eprintln!("{rebuilt:?}");
2178        assert_eq!(rebuilt.body().unwrap().to_string_lossy().trim(), "�");
2179    }
2180}