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
20fn is_valid_boundary(boundary: &[u8]) -> bool {
29 !boundary.is_empty() && !boundary.iter().any(|b| b.is_ascii_whitespace())
30}
31
32const 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 bytes: SharedString<'a>,
50 headers: HeaderMap<'a>,
52 body_offset: usize,
54 body_len: usize,
55 conformance: MessageConformance,
56 parts: Vec<Self>,
57 intro: SharedString<'a>,
59 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 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 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 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 self.conformance |= MessageConformance::MIME_INVALID_BOUNDARY;
292 return Ok(());
293 }
294 };
295
296 let boundary = format!("\n--{boundary}");
297 let raw_body_start = self.body_offset.saturating_sub(1);
301 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 self.intro = raw_body.slice(body_start_in_raw..first_boundary_pos + 1);
319
320 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 + 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 + 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 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 pub fn conformance(&self) -> MessageConformance {
394 self.conformance
395 }
396
397 pub fn child_parts(&self) -> &[Self] {
399 &self.parts
400 }
401
402 pub fn child_parts_mut(&mut self) -> &mut Vec<Self> {
404 &mut self.parts
405 }
406
407 pub fn headers(&'_ self) -> &'_ HeaderMap<'_> {
409 &self.headers
410 }
411
412 pub fn headers_mut<'b>(&'b mut self) -> &'b mut HeaderMap<'a> {
414 &mut self.headers
415 }
416
417 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 pub fn body(&'_ self) -> Result<DecodedBody<'_>> {
429 let (body, _conformance) = self.extract_body(None)?;
430 Ok(body)
431 }
432
433 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 return Ok((
521 DecodedBody::Binary(bytes),
522 MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
523 ));
524 }
525 }
526
527 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 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 pub fn rebuild(&self, settings: Option<&CheckFixSettings>) -> Result<Self> {
558 let info = Rfc2045Info::new(&self.headers);
559
560 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 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 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 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 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 self.headers.remove_all_named("Content-Type");
744 self.headers.remove_all_named("Content-Transfer-Encoding");
745 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 self.headers.remove_all_named("Content-Type");
759 self.headers.remove_all_named("Content-Transfer-Encoding");
760 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 pub fn new_text(content_type: impl AsRef<[u8]>, content: impl AsRef<BStr>) -> Result<Self> {
794 let content = content.as_ref();
795 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 (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 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 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 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 return Some(current);
1011 }
1012 }
1013 }
1014 }
1015
1016 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 return Some(current);
1030 }
1031 }
1032 }
1033 }
1034
1035 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
1257pub struct PartPointer(Vec<u8>);
1258
1259impl PartPointer {
1260 pub fn root() -> Self {
1262 Self(vec![])
1263 }
1264
1265 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 pub fn nth(n: u8) -> Self {
1276 Self(vec![n])
1277 }
1278
1279 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 pub text_part: Option<PartPointer>,
1302 pub html_part: Option<PartPointer>,
1304 pub amp_html_part: Option<PartPointer>,
1306 pub header_part: PartPointer,
1308 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 k9::assert_equal!(cutoff.raw_body(), rebuilt_cutoff.raw_body());
1447 k9::assert_equal!(rebuilt_cutoff.headers().content_id().unwrap(), None);
1449 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: €</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 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 assert_eq!(
2579 msg.conformance(),
2580 MessageConformance::MISSING_DATE_HEADER
2581 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2582 | MessageConformance::MISSING_MIME_VERSION
2583 );
2584
2585 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 assert_eq!(
2615 msg.conformance(),
2616 MessageConformance::MISSING_DATE_HEADER
2617 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2618 | MessageConformance::MISSING_MIME_VERSION
2619 );
2620
2621 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 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 assert_eq!(
2662 msg.conformance(),
2663 MessageConformance::MISSING_DATE_HEADER
2664 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2665 | MessageConformance::MISSING_MIME_VERSION
2666 );
2667
2668 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 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 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 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 let [html_part, image_part] = &related_part.child_parts()[..] else {
2743 panic!("related part must have two children")
2744 };
2745
2746 assert_eq!(ct(html_part), "text/html");
2748 assert_eq!(ct(image_part), "image/png");
2749
2750 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]
2772 fn check_trailing_bits_test() {
2773 let decoded = BASE64_RFC2045.decode(b"aHRtbD4NCi==").unwrap();
2776 assert_eq!(decoded, b"html>\r\n");
2777 }
2778
2779 #[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 #[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}