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