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