mailparsing/
mimepart.rs

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