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