1use crate::header::{HeaderParseResult, MessageConformance};
2use crate::headermap::HeaderMap;
3use crate::strings::IntoSharedString;
4use crate::{
5 has_lone_cr_or_lf, Header, MailParsingError, MessageID, MimeParameterEncoding, MimeParameters,
6 Result, SharedString,
7};
8use charset::Charset;
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11use std::str::FromStr;
12
13const BASE64_RFC2045: data_encoding::Encoding = data_encoding_macro::new_encoding! {
16 symbols: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
17 padding: '=',
18 ignore: " \r\n\t",
19 wrap_width: 76,
20 wrap_separator: "\r\n",
21};
22
23#[derive(Debug, Clone, PartialEq)]
24pub struct MimePart<'a> {
25 bytes: SharedString<'a>,
27 headers: HeaderMap<'a>,
29 body_offset: usize,
31 body_len: usize,
32 conformance: MessageConformance,
33 parts: Vec<Self>,
34 intro: SharedString<'a>,
36 outro: SharedString<'a>,
38}
39
40#[derive(PartialEq, Debug)]
41pub struct Rfc2045Info {
42 pub encoding: ContentTransferEncoding,
43 pub charset: Result<Charset>,
44 pub content_type: Option<MimeParameters>,
45 pub is_text: bool,
46 pub is_multipart: bool,
47 pub attachment_options: Option<AttachmentOptions>,
48 pub invalid_mime_headers: bool,
49}
50
51impl Rfc2045Info {
52 fn new(headers: &HeaderMap) -> Self {
55 let mut invalid_mime_headers = false;
56 let encoding = match headers.content_transfer_encoding() {
57 Ok(Some(cte)) => match ContentTransferEncoding::from_str(&cte.value) {
58 Ok(encoding) => encoding,
59 Err(_) => {
60 invalid_mime_headers = true;
61 ContentTransferEncoding::SevenBit
62 }
63 },
64 Ok(None) => ContentTransferEncoding::SevenBit,
65 Err(_) => {
66 invalid_mime_headers = true;
67 ContentTransferEncoding::SevenBit
68 }
69 };
70
71 let content_type = match headers.content_type() {
72 Ok(ct) => ct,
73 Err(_) => {
74 invalid_mime_headers = true;
75 None
76 }
77 };
78
79 let mut ct_name = None;
80 let charset = if let Some(ct) = &content_type {
81 ct_name = ct.get("name");
82 ct.get("charset")
83 } else {
84 None
85 };
86 let charset = charset.unwrap_or_else(|| "us-ascii".to_string());
87
88 let charset = Charset::for_label_no_replacement(charset.as_bytes())
89 .ok_or_else(|| MailParsingError::BodyParse(format!("unsupported charset {charset}")));
90
91 let (is_text, is_multipart) = if let Some(ct) = &content_type {
92 (ct.is_text(), ct.is_multipart())
93 } else {
94 (true, false)
95 };
96
97 let mut inline = false;
98 let mut cd_file_name = None;
99
100 match headers.content_disposition() {
101 Ok(Some(cd)) => {
102 inline = cd.value == "inline";
103 cd_file_name = cd.get("filename");
104 }
105 Ok(None) => {}
106 Err(_) => {
107 invalid_mime_headers = true;
108 }
109 };
110
111 let content_id = match headers.content_id() {
112 Ok(cid) => cid.map(|cid| cid.0),
113 Err(_) => {
114 invalid_mime_headers = true;
115 None
116 }
117 };
118
119 let file_name = match (cd_file_name, ct_name) {
120 (Some(name), _) | (None, Some(name)) => Some(name),
121 (None, None) => None,
122 };
123
124 let attachment_options = if inline || file_name.is_some() || content_id.is_some() {
125 Some(AttachmentOptions {
126 file_name,
127 inline,
128 content_id,
129 })
130 } else {
131 None
132 };
133
134 Self {
135 encoding,
136 charset,
137 content_type,
138 is_text,
139 is_multipart,
140 attachment_options,
141 invalid_mime_headers,
142 }
143 }
144
145 pub fn content_type(&self) -> Option<&str> {
146 self.content_type
147 .as_ref()
148 .map(|params| params.value.as_str())
149 }
150}
151
152impl<'a> MimePart<'a> {
153 pub fn parse<S>(bytes: S) -> Result<Self>
155 where
156 S: IntoSharedString<'a>,
157 {
158 let (bytes, base_conformance) = bytes.into_shared_string();
159 Self::parse_impl(bytes, base_conformance, true)
160 }
161
162 pub fn to_owned(&self) -> MimePart<'static> {
164 MimePart {
165 bytes: self.bytes.to_owned(),
166 headers: self.headers.to_owned(),
167 body_offset: self.body_offset,
168 body_len: self.body_len,
169 conformance: self.conformance,
170 parts: self.parts.iter().map(|p| p.to_owned()).collect(),
171 intro: self.intro.to_owned(),
172 outro: self.outro.to_owned(),
173 }
174 }
175
176 fn parse_impl(
177 bytes: SharedString<'a>,
178 base_conformance: MessageConformance,
179 is_top_level: bool,
180 ) -> Result<Self> {
181 let HeaderParseResult {
182 headers,
183 body_offset,
184 overall_conformance: mut conformance,
185 } = Header::parse_headers(bytes.clone())?;
186
187 conformance |= base_conformance;
188
189 let body_len = bytes.len();
190
191 if !bytes.is_ascii() {
192 conformance.set(MessageConformance::NEEDS_TRANSFER_ENCODING, true);
193 }
194 {
195 let mut prev = 0;
196 for idx in memchr::memchr_iter(b'\n', bytes.as_bytes()) {
197 if idx - prev > 78 {
198 conformance.set(MessageConformance::LINE_TOO_LONG, true);
199 break;
200 }
201 prev = idx;
202 }
203 }
204 conformance.set(
205 MessageConformance::NON_CANONICAL_LINE_ENDINGS,
206 has_lone_cr_or_lf(bytes.as_bytes()),
207 );
208
209 if is_top_level {
210 conformance.set(
211 MessageConformance::MISSING_DATE_HEADER,
212 !matches!(headers.date(), Ok(Some(_))),
213 );
214 conformance.set(
215 MessageConformance::MISSING_MESSAGE_ID_HEADER,
216 !matches!(headers.message_id(), Ok(Some(_))),
217 );
218 conformance.set(
219 MessageConformance::MISSING_MIME_VERSION,
220 match headers.mime_version() {
221 Ok(Some(v)) => v.as_str() != "1.0",
222 _ => true,
223 },
224 );
225 }
226
227 let mut part = Self {
228 bytes,
229 headers,
230 body_offset,
231 body_len,
232 conformance,
233 parts: vec![],
234 intro: SharedString::Borrowed(""),
235 outro: SharedString::Borrowed(""),
236 };
237
238 part.recursive_parse()?;
239
240 Ok(part)
241 }
242
243 fn recursive_parse(&mut self) -> Result<()> {
244 let info = Rfc2045Info::new(&self.headers);
245 if info.invalid_mime_headers {
246 self.conformance |= MessageConformance::INVALID_MIME_HEADERS;
247 }
248 if let Some((boundary, true)) = info
249 .content_type
250 .as_ref()
251 .and_then(|ct| ct.get("boundary").map(|b| (b, info.is_multipart)))
252 {
253 let boundary = format!("\n--{boundary}");
254 let raw_body = self
255 .bytes
256 .slice(self.body_offset.saturating_sub(1)..self.bytes.len());
257
258 let mut iter = memchr::memmem::find_iter(raw_body.as_bytes(), &boundary);
259 if let Some(first_boundary_pos) = iter.next() {
260 self.intro = raw_body.slice(0..first_boundary_pos);
261
262 self.body_len = 0;
265
266 let mut boundary_end = first_boundary_pos + boundary.len();
267
268 while let Some(part_start) =
269 memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
270 .map(|p| p + boundary_end + 1)
271 {
272 let part_end = iter
273 .next()
274 .map(|p| {
275 p + 1
278 })
279 .unwrap_or(raw_body.len());
280
281 let child = Self::parse_impl(
282 raw_body.slice(part_start..part_end),
283 MessageConformance::default(),
284 false,
285 )?;
286 self.conformance |= child.conformance;
287 self.parts.push(child);
288
289 boundary_end = part_end -
290 1 + boundary.len();
292
293 if boundary_end + 2 > raw_body.len() {
294 break;
295 }
296 if &raw_body.as_bytes()[boundary_end..boundary_end + 2] == b"--" {
297 if let Some(after_boundary) =
298 memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
299 .map(|p| p + boundary_end + 1)
300 {
301 self.outro = raw_body.slice(after_boundary..raw_body.len());
302 }
303 break;
304 }
305 }
306 }
307 }
308
309 Ok(())
310 }
311
312 pub fn conformance(&self) -> MessageConformance {
313 self.conformance
314 }
315
316 pub fn child_parts(&self) -> &[Self] {
318 &self.parts
319 }
320
321 pub fn child_parts_mut(&mut self) -> &mut Vec<Self> {
323 &mut self.parts
324 }
325
326 pub fn headers(&'_ self) -> &'_ HeaderMap<'_> {
328 &self.headers
329 }
330
331 pub fn headers_mut<'b>(&'b mut self) -> &'b mut HeaderMap<'a> {
333 &mut self.headers
334 }
335
336 pub fn raw_body(&'_ self) -> SharedString<'_> {
338 self.bytes
339 .slice(self.body_offset..self.body_len.max(self.body_offset))
340 }
341
342 pub fn rfc2045_info(&self) -> Rfc2045Info {
343 Rfc2045Info::new(&self.headers)
344 }
345
346 pub fn body(&'_ self) -> Result<DecodedBody<'_>> {
348 let info = Rfc2045Info::new(&self.headers);
349
350 let bytes = match info.encoding {
351 ContentTransferEncoding::Base64 => {
352 let data = self.raw_body();
353 let bytes = data.as_bytes();
354 BASE64_RFC2045.decode(bytes).map_err(|err| {
355 let b = bytes[err.position] as char;
356 let region =
357 &bytes[err.position.saturating_sub(8)..(err.position + 8).min(bytes.len())];
358 let region = String::from_utf8_lossy(region);
359 MailParsingError::BodyParse(format!(
360 "base64 decode: {err:#} b={b:?} in {region}"
361 ))
362 })?
363 }
364 ContentTransferEncoding::QuotedPrintable => quoted_printable::decode(
365 self.raw_body().as_bytes(),
366 quoted_printable::ParseMode::Robust,
367 )
368 .map_err(|err| {
369 MailParsingError::BodyParse(format!("quoted printable decode: {err:#}"))
370 })?,
371 ContentTransferEncoding::SevenBit
372 | ContentTransferEncoding::EightBit
373 | ContentTransferEncoding::Binary
374 if info.is_text =>
375 {
376 return Ok(DecodedBody::Text(self.raw_body()));
377 }
378 ContentTransferEncoding::SevenBit
379 | ContentTransferEncoding::EightBit
380 | ContentTransferEncoding::Binary => {
381 return Ok(DecodedBody::Binary(self.raw_body().as_bytes().to_vec()))
382 }
383 };
384
385 if info.is_text {
386 let (decoded, _malformed) = info.charset?.decode_without_bom_handling(&bytes);
387 Ok(DecodedBody::Text(decoded.to_string().into()))
388 } else {
389 Ok(DecodedBody::Binary(bytes))
390 }
391 }
392
393 pub fn rebuild(&self) -> Result<Self> {
400 let info = Rfc2045Info::new(&self.headers);
401
402 let mut children = vec![];
403 for part in &self.parts {
404 children.push(part.rebuild()?);
405 }
406
407 let mut rebuilt = if children.is_empty() {
408 let body = self.body()?;
409 match body {
410 DecodedBody::Text(text) => {
411 let ct = info
412 .content_type
413 .as_ref()
414 .map(|ct| ct.value.as_str())
415 .unwrap_or("text/plain");
416 Self::new_text(ct, text.as_str())?
417 }
418 DecodedBody::Binary(data) => {
419 let ct = info
420 .content_type
421 .as_ref()
422 .map(|ct| ct.value.as_str())
423 .unwrap_or("application/octet-stream");
424 Self::new_binary(ct, &data, info.attachment_options.as_ref())?
425 }
426 }
427 } else {
428 let ct = info.content_type.ok_or_else(|| {
429 MailParsingError::BodyParse(
430 "multipart message has no content-type information!?".to_string(),
431 )
432 })?;
433 Self::new_multipart(&ct.value, children, ct.get("boundary").as_deref())?
434 };
435
436 for hdr in self.headers.iter() {
437 let name = hdr.get_name();
438 if name.eq_ignore_ascii_case("Content-ID") {
439 continue;
440 }
441
442 if name.eq_ignore_ascii_case("Content-Type") {
445 if let Ok(params) = hdr.as_content_type() {
446 let Some(mut dest) = rebuilt.headers_mut().content_type()? else {
447 continue;
448 };
449
450 for (k, v) in params.parameter_map() {
451 if dest.get(&k).is_none() {
452 dest.set(&k, &v);
453 }
454 }
455
456 rebuilt.headers_mut().set_content_type(dest)?;
457 }
458 continue;
459 }
460 if name.eq_ignore_ascii_case("Content-Transfer-Encoding") {
461 if let Ok(params) = hdr.as_content_transfer_encoding() {
462 let Some(mut dest) = rebuilt.headers_mut().content_transfer_encoding()? else {
463 continue;
464 };
465
466 for (k, v) in params.parameter_map() {
467 if dest.get(&k).is_none() {
468 dest.set(&k, &v);
469 }
470 }
471
472 rebuilt.headers_mut().set_content_transfer_encoding(dest)?;
473 }
474 continue;
475 }
476 if name.eq_ignore_ascii_case("Content-Disposition") {
477 if let Ok(params) = hdr.as_content_disposition() {
478 let Some(mut dest) = rebuilt.headers_mut().content_disposition()? else {
479 continue;
480 };
481
482 for (k, v) in params.parameter_map() {
483 if dest.get(&k).is_none() {
484 dest.set(&k, &v);
485 }
486 }
487
488 rebuilt.headers_mut().set_content_disposition(dest)?;
489 }
490 continue;
491 }
492
493 if let Ok(hdr) = hdr.rebuild() {
494 rebuilt.headers_mut().push(hdr);
495 }
496 }
497
498 Ok(rebuilt)
499 }
500
501 pub fn write_message<W: std::io::Write>(&self, out: &mut W) -> Result<()> {
503 let line_ending = if self
504 .conformance
505 .contains(MessageConformance::NON_CANONICAL_LINE_ENDINGS)
506 {
507 "\n"
508 } else {
509 "\r\n"
510 };
511
512 for hdr in self.headers.iter() {
513 hdr.write_header(out)
514 .map_err(|_| MailParsingError::WriteMessageIOError)?;
515 }
516 out.write_all(line_ending.as_bytes())
517 .map_err(|_| MailParsingError::WriteMessageIOError)?;
518
519 if self.parts.is_empty() {
520 out.write_all(self.raw_body().as_bytes())
521 .map_err(|_| MailParsingError::WriteMessageIOError)?;
522 } else {
523 let info = Rfc2045Info::new(&self.headers);
524 let ct = info.content_type.ok_or({
525 MailParsingError::WriteMessageWtf(
526 "expected to have Content-Type when there are child parts",
527 )
528 })?;
529 let boundary = ct.get("boundary").ok_or({
530 MailParsingError::WriteMessageWtf("expected Content-Type to have a boundary")
531 })?;
532 out.write_all(self.intro.as_bytes())
533 .map_err(|_| MailParsingError::WriteMessageIOError)?;
534 for p in &self.parts {
535 write!(out, "--{boundary}{line_ending}")
536 .map_err(|_| MailParsingError::WriteMessageIOError)?;
537 p.write_message(out)?;
538 }
539 write!(out, "--{boundary}--{line_ending}")
540 .map_err(|_| MailParsingError::WriteMessageIOError)?;
541 out.write_all(self.outro.as_bytes())
542 .map_err(|_| MailParsingError::WriteMessageIOError)?;
543 }
544 Ok(())
545 }
546
547 pub fn to_message_string(&self) -> String {
550 let mut out = vec![];
551 self.write_message(&mut out).unwrap();
552 String::from_utf8_lossy(&out).to_string()
553 }
554
555 pub fn replace_text_body(&mut self, content_type: &str, content: &str) -> Result<()> {
556 let mut new_part = Self::new_text(content_type, content)?;
557 self.bytes = new_part.bytes;
558 self.body_offset = new_part.body_offset;
559 self.body_len = new_part.body_len;
560 self.headers.remove_all_named("Content-Type");
564 self.headers.remove_all_named("Content-Transfer-Encoding");
565 self.headers.append(&mut new_part.headers.headers);
567 Ok(())
568 }
569
570 pub fn replace_binary_body(&mut self, content_type: &str, content: &[u8]) -> Result<()> {
571 let mut new_part = Self::new_binary(content_type, content, None)?;
572 self.bytes = new_part.bytes;
573 self.body_offset = new_part.body_offset;
574 self.body_len = new_part.body_len;
575 self.headers.remove_all_named("Content-Type");
579 self.headers.remove_all_named("Content-Transfer-Encoding");
580 self.headers.append(&mut new_part.headers.headers);
582 Ok(())
583 }
584
585 pub fn new_no_transfer_encoding(content_type: &str, bytes: &[u8]) -> Result<Self> {
586 if bytes.iter().any(|b| !b.is_ascii()) {
587 return Err(MailParsingError::EightBit);
588 }
589
590 let mut headers = HeaderMap::default();
591
592 let ct = MimeParameters::new(content_type);
593 headers.set_content_type(ct)?;
594
595 let bytes = String::from_utf8_lossy(bytes).to_string();
596 let body_len = bytes.len();
597
598 Ok(Self {
599 bytes: bytes.into(),
600 headers,
601 body_offset: 0,
602 body_len,
603 conformance: MessageConformance::default(),
604 parts: vec![],
605 intro: "".into(),
606 outro: "".into(),
607 })
608 }
609
610 pub fn new_text(content_type: &str, content: &str) -> Result<Self> {
614 let qp_encoded = quoted_printable::encode(content);
616
617 let (mut encoded, encoding) = if qp_encoded == content.as_bytes() {
618 (qp_encoded, None)
619 } else if qp_encoded.len() <= BASE64_RFC2045.encode_len(content.len()) {
620 (qp_encoded, Some("quoted-printable"))
621 } else {
622 (
625 BASE64_RFC2045.encode(content.as_bytes()).into_bytes(),
626 Some("base64"),
627 )
628 };
629
630 if !encoded.ends_with(b"\r\n") {
631 encoded.extend_from_slice(b"\r\n");
632 }
633 let mut headers = HeaderMap::default();
634
635 let mut ct = MimeParameters::new(content_type);
636 ct.set(
637 "charset",
638 if content.is_ascii() {
639 "us-ascii"
640 } else {
641 "utf-8"
642 },
643 );
644 headers.set_content_type(ct)?;
645
646 if let Some(encoding) = encoding {
647 headers.set_content_transfer_encoding(MimeParameters::new(encoding))?;
648 }
649
650 let body_len = encoded.len();
651 let bytes =
652 String::from_utf8(encoded).expect("transfer encoder to produce valid ASCII output");
653
654 Ok(Self {
655 bytes: bytes.into(),
656 headers,
657 body_offset: 0,
658 body_len,
659 conformance: MessageConformance::default(),
660 parts: vec![],
661 intro: "".into(),
662 outro: "".into(),
663 })
664 }
665
666 pub fn new_text_plain(content: &str) -> Result<Self> {
667 Self::new_text("text/plain", content)
668 }
669
670 pub fn new_html(content: &str) -> Result<Self> {
671 Self::new_text("text/html", content)
672 }
673
674 pub fn new_multipart(
675 content_type: &str,
676 parts: Vec<Self>,
677 boundary: Option<&str>,
678 ) -> Result<Self> {
679 let mut headers = HeaderMap::default();
680
681 let mut ct = MimeParameters::new(content_type);
682 match boundary {
683 Some(b) => {
684 ct.set("boundary", b);
685 }
686 None => {
687 let uuid = uuid::Uuid::new_v4();
689 let boundary = data_encoding::BASE64_NOPAD.encode(uuid.as_bytes());
690 ct.set("boundary", &boundary);
691 }
692 }
693 headers.set_content_type(ct)?;
694
695 Ok(Self {
696 bytes: "".into(),
697 headers,
698 body_offset: 0,
699 body_len: 0,
700 conformance: MessageConformance::default(),
701 parts,
702 intro: "".into(),
703 outro: "".into(),
704 })
705 }
706
707 pub fn new_binary(
708 content_type: &str,
709 content: &[u8],
710 options: Option<&AttachmentOptions>,
711 ) -> Result<Self> {
712 let mut encoded = BASE64_RFC2045.encode(content);
713 if !encoded.ends_with("\r\n") {
714 encoded.push_str("\r\n");
715 }
716 let mut headers = HeaderMap::default();
717
718 let mut ct = MimeParameters::new(content_type);
719
720 if let Some(opts) = options {
721 let mut cd = MimeParameters::new(if opts.inline { "inline" } else { "attachment" });
722 if let Some(name) = &opts.file_name {
723 cd.set("filename", name);
724 let encoding = if name.chars().any(|c| !c.is_ascii()) {
725 MimeParameterEncoding::QuotedRfc2047
726 } else {
727 MimeParameterEncoding::None
728 };
729 ct.set_with_encoding("name", name, encoding);
730 }
731 headers.set_content_disposition(cd)?;
732
733 if let Some(id) = &opts.content_id {
734 headers.set_content_id(MessageID(id.to_string()))?;
735 }
736 }
737
738 headers.set_content_type(ct)?;
739 headers.set_content_transfer_encoding(MimeParameters::new("base64"))?;
740
741 let body_len = encoded.len();
742
743 Ok(Self {
744 bytes: encoded.into(),
745 headers,
746 body_offset: 0,
747 body_len,
748 conformance: MessageConformance::default(),
749 parts: vec![],
750 intro: "".into(),
751 outro: "".into(),
752 })
753 }
754
755 pub fn simplified_structure(&'a self) -> Result<SimplifiedStructure<'a>> {
760 let parts = self.simplified_structure_pointers()?;
761
762 let mut text = None;
763 let mut html = None;
764
765 let headers = &self
766 .resolve_ptr(parts.header_part)
767 .expect("header part to always be valid")
768 .headers;
769
770 if let Some(p) = parts.text_part.and_then(|p| self.resolve_ptr(p)) {
771 text = match p.body()? {
772 DecodedBody::Text(t) => Some(t),
773 DecodedBody::Binary(_) => {
774 return Err(MailParsingError::BodyParse(
775 "expected text/plain part to be text, but it is binary".to_string(),
776 ))
777 }
778 };
779 }
780 if let Some(p) = parts.html_part.and_then(|p| self.resolve_ptr(p)) {
781 html = match p.body()? {
782 DecodedBody::Text(t) => Some(t),
783 DecodedBody::Binary(_) => {
784 return Err(MailParsingError::BodyParse(
785 "expected text/html part to be text, but it is binary".to_string(),
786 ))
787 }
788 };
789 }
790
791 let mut attachments = vec![];
792 for ptr in parts.attachments {
793 attachments.push(self.resolve_ptr(ptr).expect("pointer to be valid").clone());
794 }
795
796 Ok(SimplifiedStructure {
797 text,
798 html,
799 headers,
800 attachments,
801 })
802 }
803
804 pub fn resolve_ptr(&self, ptr: PartPointer) -> Option<&Self> {
806 let mut current = self;
807 let mut cursor = ptr.0.as_slice();
808
809 loop {
810 match cursor.first() {
811 Some(&idx) => {
812 current = current.parts.get(idx as usize)?;
813 cursor = &cursor[1..];
814 }
815 None => {
816 return Some(current);
818 }
819 }
820 }
821 }
822
823 pub fn resolve_ptr_mut(&mut self, ptr: PartPointer) -> Option<&mut Self> {
825 let mut current = self;
826 let mut cursor = ptr.0.as_slice();
827
828 loop {
829 match cursor.first() {
830 Some(&idx) => {
831 current = current.parts.get_mut(idx as usize)?;
832 cursor = &cursor[1..];
833 }
834 None => {
835 return Some(current);
837 }
838 }
839 }
840 }
841
842 pub fn simplified_structure_pointers(&self) -> Result<SimplifiedStructurePointers> {
848 self.simplified_structure_pointers_impl(None)
849 }
850
851 fn simplified_structure_pointers_impl(
852 &self,
853 my_idx: Option<u8>,
854 ) -> Result<SimplifiedStructurePointers> {
855 let info = Rfc2045Info::new(&self.headers);
856 let is_inline = info
857 .attachment_options
858 .as_ref()
859 .map(|ao| ao.inline)
860 .unwrap_or(true);
861
862 if let Some(ct) = &info.content_type {
863 if is_inline {
864 if ct.value == "text/plain" {
865 return Ok(SimplifiedStructurePointers {
866 text_part: Some(PartPointer::root_or_nth(my_idx)),
867 html_part: None,
868 header_part: PartPointer::root_or_nth(my_idx),
869 attachments: vec![],
870 });
871 }
872 if ct.value == "text/html" {
873 return Ok(SimplifiedStructurePointers {
874 html_part: Some(PartPointer::root_or_nth(my_idx)),
875 text_part: None,
876 header_part: PartPointer::root_or_nth(my_idx),
877 attachments: vec![],
878 });
879 }
880 }
881
882 if ct.value.starts_with("multipart/") {
883 let mut text_part = None;
884 let mut html_part = None;
885 let mut attachments = vec![];
886
887 for (i, p) in self.parts.iter().enumerate() {
888 let part_idx = i.try_into().map_err(|_| MailParsingError::TooManyParts)?;
889 if let Ok(mut s) = p.simplified_structure_pointers_impl(Some(part_idx)) {
890 if let Some(p) = s.text_part {
891 if text_part.is_none() {
892 text_part.replace(PartPointer::root_or_nth(my_idx).append(p));
893 } else {
894 attachments.push(p);
895 }
896 }
897 if let Some(p) = s.html_part {
898 if html_part.is_none() {
899 html_part.replace(PartPointer::root_or_nth(my_idx).append(p));
900 } else {
901 attachments.push(p);
902 }
903 }
904 attachments.append(&mut s.attachments);
905 }
906 }
907
908 return Ok(SimplifiedStructurePointers {
909 html_part,
910 text_part,
911 header_part: PartPointer::root_or_nth(my_idx),
912 attachments,
913 });
914 }
915
916 return Ok(SimplifiedStructurePointers {
917 html_part: None,
918 text_part: None,
919 header_part: PartPointer::root_or_nth(my_idx),
920 attachments: vec![PartPointer::root_or_nth(my_idx)],
921 });
922 }
923
924 Ok(SimplifiedStructurePointers {
926 text_part: Some(PartPointer::root_or_nth(my_idx)),
927 html_part: None,
928 header_part: PartPointer::root_or_nth(my_idx),
929 attachments: vec![],
930 })
931 }
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
941pub struct PartPointer(Vec<u8>);
942
943impl PartPointer {
944 pub fn root() -> Self {
946 Self(vec![])
947 }
948
949 pub fn root_or_nth(n: Option<u8>) -> Self {
952 match n {
953 Some(n) => Self::nth(n),
954 None => Self::root(),
955 }
956 }
957
958 pub fn nth(n: u8) -> Self {
960 Self(vec![n])
961 }
962
963 pub fn append(mut self, mut other: Self) -> Self {
966 self.0.append(&mut other.0);
967 Self(self.0)
968 }
969
970 pub fn id_string(&self) -> String {
971 let mut id = String::new();
972 for p in &self.0 {
973 if !id.is_empty() {
974 id.push('.');
975 }
976 id.push_str(&p.to_string());
977 }
978 id
979 }
980}
981
982#[derive(Debug, Clone)]
983pub struct SimplifiedStructurePointers {
984 pub text_part: Option<PartPointer>,
986 pub html_part: Option<PartPointer>,
988 pub header_part: PartPointer,
990 pub attachments: Vec<PartPointer>,
992}
993
994#[derive(Debug, Clone)]
995pub struct SimplifiedStructure<'a> {
996 pub text: Option<SharedString<'a>>,
997 pub html: Option<SharedString<'a>>,
998 pub headers: &'a HeaderMap<'a>,
999 pub attachments: Vec<MimePart<'a>>,
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1003#[serde(deny_unknown_fields)]
1004pub struct AttachmentOptions {
1005 #[serde(default)]
1006 pub file_name: Option<String>,
1007 #[serde(default)]
1008 pub inline: bool,
1009 #[serde(default)]
1010 pub content_id: Option<String>,
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014pub enum ContentTransferEncoding {
1015 SevenBit,
1016 EightBit,
1017 Binary,
1018 QuotedPrintable,
1019 Base64,
1020}
1021
1022impl FromStr for ContentTransferEncoding {
1023 type Err = MailParsingError;
1024
1025 fn from_str(s: &str) -> Result<Self> {
1026 if s.eq_ignore_ascii_case("7bit") {
1027 Ok(Self::SevenBit)
1028 } else if s.eq_ignore_ascii_case("8bit") {
1029 Ok(Self::EightBit)
1030 } else if s.eq_ignore_ascii_case("binary") {
1031 Ok(Self::Binary)
1032 } else if s.eq_ignore_ascii_case("quoted-printable") {
1033 Ok(Self::QuotedPrintable)
1034 } else if s.eq_ignore_ascii_case("base64") {
1035 Ok(Self::Base64)
1036 } else {
1037 Err(MailParsingError::InvalidContentTransferEncoding(
1038 s.to_string(),
1039 ))
1040 }
1041 }
1042}
1043
1044#[derive(Debug, PartialEq)]
1045pub enum DecodedBody<'a> {
1046 Text(SharedString<'a>),
1047 Binary(Vec<u8>),
1048}
1049
1050impl<'a> DecodedBody<'a> {
1051 pub fn to_string_lossy(&'a self) -> Cow<'a, str> {
1052 match self {
1053 Self::Text(s) => Cow::Borrowed(s),
1054 Self::Binary(b) => String::from_utf8_lossy(b),
1055 }
1056 }
1057}
1058
1059#[cfg(test)]
1060mod test {
1061 use super::*;
1062
1063 #[test]
1064 fn msg_parsing() {
1065 let message = concat!(
1066 "Subject: hello there\n",
1067 "From: Someone <someone@example.com>\n",
1068 "\n",
1069 "I am the body"
1070 );
1071
1072 let part = MimePart::parse(message).unwrap();
1073 k9::assert_equal!(message, part.to_message_string());
1074 assert_eq!(part.raw_body(), "I am the body");
1075 k9::snapshot!(
1076 part.body(),
1077 r#"
1078Ok(
1079 Text(
1080 "I am the body",
1081 ),
1082)
1083"#
1084 );
1085
1086 k9::snapshot!(
1087 part.rebuild().unwrap().to_message_string(),
1088 r#"
1089Content-Type: text/plain;\r
1090\tcharset="us-ascii"\r
1091Subject: hello there\r
1092From: Someone <someone@example.com>\r
1093\r
1094I am the body\r
1095
1096"#
1097 );
1098 }
1099
1100 #[test]
1101 fn mime_bogus_body() {
1102 let message = concat!(
1103 "Subject: hello there\n",
1104 "From: Someone <someone@example.com>\n",
1105 "Mime-Version: 1.0\n",
1106 "Content-Type: text/plain\n",
1107 "Content-Transfer-Encoding: base64\n",
1108 "\n",
1109 "hello\n"
1110 );
1111
1112 let part = MimePart::parse(message).unwrap();
1113 assert_eq!(
1114 part.body().unwrap_err(),
1115 MailParsingError::BodyParse(
1116 "base64 decode: invalid length at 4 b='o' in hello\n".to_string()
1117 )
1118 );
1119 }
1120
1121 #[test]
1122 fn mime_encoded_body() {
1123 let message = concat!(
1124 "Subject: hello there\n",
1125 "From: Someone <someone@example.com>\n",
1126 "Mime-Version: 1.0\n",
1127 "Content-Type: text/plain\n",
1128 "Content-Transfer-Encoding: base64\n",
1129 "\n",
1130 "aGVsbG8K\n"
1131 );
1132
1133 let part = MimePart::parse(message).unwrap();
1134 k9::assert_equal!(message, part.to_message_string());
1135 assert_eq!(part.raw_body(), "aGVsbG8K\n");
1136 k9::snapshot!(
1137 part.body(),
1138 r#"
1139Ok(
1140 Text(
1141 "hello
1142",
1143 ),
1144)
1145"#
1146 );
1147
1148 k9::snapshot!(
1149 part.rebuild().unwrap().to_message_string(),
1150 r#"
1151Content-Type: text/plain;\r
1152\tcharset="us-ascii"\r
1153Content-Transfer-Encoding: quoted-printable\r
1154Subject: hello there\r
1155From: Someone <someone@example.com>\r
1156Mime-Version: 1.0\r
1157\r
1158hello=0A\r
1159
1160"#
1161 );
1162 }
1163
1164 #[test]
1165 fn mime_multipart_1() {
1166 let message = concat!(
1167 "Subject: This is a test email\n",
1168 "Content-Type: multipart/alternative; boundary=foobar\n",
1169 "Mime-Version: 1.0\n",
1170 "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\n",
1171 "\n",
1172 "--foobar\n",
1173 "Content-Type: text/plain; charset=utf-8\n",
1174 "Content-Transfer-Encoding: quoted-printable\n",
1175 "\n",
1176 "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\n",
1177 "--foobar\n",
1178 "Content-Type: text/html\n",
1179 "Content-Transfer-Encoding: base64\n",
1180 "\n",
1181 "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \n",
1182 "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \n",
1183 "--foobar--\n",
1184 "After the final boundary stuff gets ignored.\n"
1185 );
1186
1187 let part = MimePart::parse(message).unwrap();
1188
1189 k9::assert_equal!(message, part.to_message_string());
1190
1191 let children = part.child_parts();
1192 k9::assert_equal!(children.len(), 2);
1193
1194 k9::snapshot!(
1195 children[0].body(),
1196 r#"
1197Ok(
1198 Text(
1199 "This is the plaintext version, in utf-8. Proof by Euro: €\r
1200",
1201 ),
1202)
1203"#
1204 );
1205 k9::snapshot!(
1206 children[1].body(),
1207 r#"
1208Ok(
1209 Text(
1210 "<html><body>This is the <b>HTML</b> version, in us-ascii. Proof by Euro: €</body></html>
1211",
1212 ),
1213)
1214"#
1215 );
1216 }
1217
1218 #[test]
1219 fn mutate_1() {
1220 let message = concat!(
1221 "Subject: This is a test email\r\n",
1222 "Content-Type: multipart/alternative; boundary=foobar\r\n",
1223 "Mime-Version: 1.0\r\n",
1224 "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1225 "\r\n",
1226 "--foobar\r\n",
1227 "Content-Type: text/plain; charset=utf-8\r\n",
1228 "Content-Transfer-Encoding: quoted-printable\r\n",
1229 "\r\n",
1230 "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r\n",
1231 "--foobar\r\n",
1232 "Content-Type: text/html\r\n",
1233 "Content-Transfer-Encoding: base64\r\n",
1234 "\r\n",
1235 "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r\n",
1236 "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r\n",
1237 "--foobar--\r\n",
1238 "After the final boundary stuff gets ignored.\r\n"
1239 );
1240
1241 let mut part = MimePart::parse(message).unwrap();
1242 k9::assert_equal!(message, part.to_message_string());
1243 fn munge(part: &mut MimePart) {
1244 let headers = part.headers_mut();
1245 headers.push(Header::with_name_value("X-Woot", "Hello"));
1246 headers.insert(0, Header::with_name_value("X-First", "at the top"));
1247 headers.retain(|hdr| !hdr.get_name().eq_ignore_ascii_case("date"));
1248 }
1249 munge(&mut part);
1250
1251 let re_encoded = part.to_message_string();
1252 k9::snapshot!(
1253 re_encoded,
1254 r#"
1255X-First: at the top\r
1256Subject: This is a test email\r
1257Content-Type: multipart/alternative; boundary=foobar\r
1258Mime-Version: 1.0\r
1259X-Woot: Hello\r
1260\r
1261--foobar\r
1262Content-Type: text/plain; charset=utf-8\r
1263Content-Transfer-Encoding: quoted-printable\r
1264\r
1265This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r
1266--foobar\r
1267Content-Type: text/html\r
1268Content-Transfer-Encoding: base64\r
1269\r
1270PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1271dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1272--foobar--\r
1273After the final boundary stuff gets ignored.\r
1274
1275"#
1276 );
1277
1278 eprintln!("part before mutate:\n{part:#?}");
1279
1280 part.child_parts_mut().retain(|part| {
1281 let ct = part.headers().content_type().unwrap().unwrap();
1282 ct.value == "text/html"
1283 });
1284
1285 eprintln!("part with html removed is:\n{part:#?}");
1286
1287 let re_encoded = part.to_message_string();
1288 k9::snapshot!(
1289 re_encoded,
1290 r#"
1291X-First: at the top\r
1292Subject: This is a test email\r
1293Content-Type: multipart/alternative; boundary=foobar\r
1294Mime-Version: 1.0\r
1295X-Woot: Hello\r
1296\r
1297--foobar\r
1298Content-Type: text/html\r
1299Content-Transfer-Encoding: base64\r
1300\r
1301PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1302dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1303--foobar--\r
1304After the final boundary stuff gets ignored.\r
1305
1306"#
1307 );
1308 }
1309
1310 #[test]
1311 fn replace_text_body() {
1312 let mut part = MimePart::new_text_plain("Hello 👻\r\n").unwrap();
1313 let encoded = part.to_message_string();
1314 k9::snapshot!(
1315 &encoded,
1316 r#"
1317Content-Type: text/plain;\r
1318\tcharset="utf-8"\r
1319Content-Transfer-Encoding: base64\r
1320\r
1321SGVsbG8g8J+Ruw0K\r
1322
1323"#
1324 );
1325
1326 part.replace_text_body("text/plain", "Hello 🚀\r\n")
1327 .unwrap();
1328 let encoded = part.to_message_string();
1329 k9::snapshot!(
1330 &encoded,
1331 r#"
1332Content-Type: text/plain;\r
1333\tcharset="utf-8"\r
1334Content-Transfer-Encoding: base64\r
1335\r
1336SGVsbG8g8J+agA0K\r
1337
1338"#
1339 );
1340 }
1341
1342 #[test]
1343 fn construct_1() {
1344 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";
1345
1346 let part = MimePart::new_text_plain(input_text).unwrap();
1347
1348 let encoded = part.to_message_string();
1349 k9::snapshot!(
1350 &encoded,
1351 r#"
1352Content-Type: text/plain;\r
1353\tcharset="utf-8"\r
1354Content-Transfer-Encoding: quoted-printable\r
1355\r
1356Well, hello there! This is the plaintext version, in utf-8. Here's a Euro: =\r
1357=E2=82=AC, and here are some emoji =F0=9F=91=BB =F0=9F=8D=89 =F0=9F=92=A9 a=\r
1358nd this long should be long enough that we wrap it in the returned part, le=\r
1359t's see how that turns out!\r
1360
1361"#
1362 );
1363
1364 let parsed_part = MimePart::parse(encoded.clone()).unwrap();
1365 k9::assert_equal!(encoded.as_str(), parsed_part.to_message_string().as_str());
1366 k9::assert_equal!(part.body().unwrap(), DecodedBody::Text(input_text.into()));
1367 k9::snapshot!(
1368 parsed_part.simplified_structure_pointers(),
1369 "
1370Ok(
1371 SimplifiedStructurePointers {
1372 text_part: Some(
1373 PartPointer(
1374 [],
1375 ),
1376 ),
1377 html_part: None,
1378 header_part: PartPointer(
1379 [],
1380 ),
1381 attachments: [],
1382 },
1383)
1384"
1385 );
1386 }
1387
1388 #[test]
1389 fn construct_2() {
1390 let msg = MimePart::new_multipart(
1391 "multipart/mixed",
1392 vec![
1393 MimePart::new_text_plain("plain text").unwrap(),
1394 MimePart::new_html("<b>rich</b> text").unwrap(),
1395 MimePart::new_binary(
1396 "application/octet-stream",
1397 &[0, 1, 2, 3],
1398 Some(&AttachmentOptions {
1399 file_name: Some("woot.bin".to_string()),
1400 inline: false,
1401 content_id: Some("woot.id".to_string()),
1402 }),
1403 )
1404 .unwrap(),
1405 ],
1406 Some("my-boundary"),
1407 )
1408 .unwrap();
1409 k9::snapshot!(
1410 msg.to_message_string(),
1411 r#"
1412Content-Type: multipart/mixed;\r
1413\tboundary="my-boundary"\r
1414\r
1415--my-boundary\r
1416Content-Type: text/plain;\r
1417\tcharset="us-ascii"\r
1418\r
1419plain text\r
1420--my-boundary\r
1421Content-Type: text/html;\r
1422\tcharset="us-ascii"\r
1423\r
1424<b>rich</b> text\r
1425--my-boundary\r
1426Content-Disposition: attachment;\r
1427\tfilename="woot.bin"\r
1428Content-ID: <woot.id>\r
1429Content-Type: application/octet-stream;\r
1430\tname="woot.bin"\r
1431Content-Transfer-Encoding: base64\r
1432\r
1433AAECAw==\r
1434--my-boundary--\r
1435
1436"#
1437 );
1438
1439 k9::snapshot!(
1440 msg.simplified_structure_pointers(),
1441 "
1442Ok(
1443 SimplifiedStructurePointers {
1444 text_part: Some(
1445 PartPointer(
1446 [
1447 0,
1448 ],
1449 ),
1450 ),
1451 html_part: Some(
1452 PartPointer(
1453 [
1454 1,
1455 ],
1456 ),
1457 ),
1458 header_part: PartPointer(
1459 [],
1460 ),
1461 attachments: [
1462 PartPointer(
1463 [
1464 2,
1465 ],
1466 ),
1467 ],
1468 },
1469)
1470"
1471 );
1472 }
1473
1474 #[test]
1475 fn attachment_name_order_prefers_content_disposition() {
1476 let message = concat!(
1477 "Content-Type: multipart/mixed;\r\n",
1478 " boundary=\"woot\"\r\n",
1479 "\r\n",
1480 "--woot\r\n",
1481 "Content-Type: text/plain;\r\n",
1482 " charset=\"us-ascii\"\r\n",
1483 "\r\n",
1484 "Hello, I am the main message content\r\n",
1485 "--woot\r\n",
1486 "Content-Disposition: attachment;\r\n",
1487 " filename=cdname\r\n",
1488 "Content-Type: application/octet-stream;\r\n",
1489 " name=ctname\r\n",
1490 "Content-Transfer-Encoding: base64\r\n",
1491 "\r\n",
1492 "u6o=\r\n",
1493 "--woot--\r\n"
1494 );
1495 let part = MimePart::parse(message).unwrap();
1496 let structure = part.simplified_structure().unwrap();
1497
1498 k9::assert_equal!(
1499 structure.attachments[0].rfc2045_info().attachment_options,
1500 Some(AttachmentOptions {
1501 content_id: None,
1502 inline: false,
1503 file_name: Some("cdname".to_string()),
1504 })
1505 );
1506 }
1507
1508 #[test]
1509 fn attachment_name_accepts_content_type_name() {
1510 let message = concat!(
1511 "Content-Type: multipart/mixed;\r\n",
1512 " boundary=\"woot\"\r\n",
1513 "\r\n",
1514 "--woot\r\n",
1515 "Content-Type: text/plain;\r\n",
1516 " charset=\"us-ascii\"\r\n",
1517 "\r\n",
1518 "Hello, I am the main message content\r\n",
1519 "--woot\r\n",
1520 "Content-Disposition: attachment\r\n",
1521 "Content-Type: application/octet-stream;\r\n",
1522 " name=ctname\r\n",
1523 "Content-Transfer-Encoding: base64\r\n",
1524 "\r\n",
1525 "u6o=\r\n",
1526 "--woot--\r\n"
1527 );
1528 let part = MimePart::parse(message).unwrap();
1529 let structure = part.simplified_structure().unwrap();
1530
1531 k9::assert_equal!(
1532 structure.attachments[0].rfc2045_info().attachment_options,
1533 Some(AttachmentOptions {
1534 content_id: None,
1535 inline: false,
1536 file_name: Some("ctname".to_string()),
1537 })
1538 );
1539 }
1540
1541 #[test]
1542 fn funky_headers() {
1543 let message = concat!(
1544 "Subject\r\n",
1545 "Other:\r\n",
1546 "Content-Type: multipart/alternative; boundary=foobar\r\n",
1547 "Mime-Version: 1.0\r\n",
1548 "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1549 "\r\n",
1550 "The body.\r\n"
1551 );
1552
1553 let part = MimePart::parse(message).unwrap();
1554 assert!(part
1555 .conformance()
1556 .contains(MessageConformance::MISSING_COLON_VALUE));
1557 }
1558
1559 #[test]
1564 fn rebuild_binary() {
1565 let expect = &[0, 1, 2, 3, 0xbe, 4, 5];
1566 let part = MimePart::new_binary("applicat/octet-stream", expect, None).unwrap();
1567
1568 let rebuilt = part.rebuild().unwrap();
1569 let body = rebuilt.body().unwrap();
1570
1571 assert_eq!(body, DecodedBody::Binary(expect.to_vec()));
1572 }
1573
1574 #[test]
1577 fn rebuild_invitation() {
1578 let message = concat!(
1579 "Subject: Test for events 2\r\n",
1580 "Content-Type: multipart/mixed;\r\n",
1581 " boundary=8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1582 "\r\n",
1583 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1584 "Content-Type: multipart/alternative;\r\n",
1585 " boundary=a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1586 "\r\n",
1587 "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1588 "Content-Transfer-Encoding: quoted-printable\r\n",
1589 "Content-Type: text/plain; charset=UTF-8\r\n",
1590 "\r\n",
1591 "This is a test for calendar event invitation\r\n",
1592 "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1593 "Content-Transfer-Encoding: quoted-printable\r\n",
1594 "Content-Type: text/html; charset=UTF-8\r\n",
1595 "\r\n",
1596 "<p>This is a test for calendar event invitation</p>\r\n",
1597 "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f--\r\n",
1598 "\r\n",
1599 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1600 "Content-Disposition: inline; name=\"Invitation.ics\"\r\n",
1601 "Content-Type: text/calendar; method=REQUEST; name=\"Invitation.ics\"\r\n",
1602 "\r\n",
1603 "Invitation\r\n",
1604 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1605 "Content-Disposition: attachment; filename=\"event.ics\"\r\n",
1606 "Content-Type: application/ics\r\n",
1607 "\r\n",
1608 "Event\r\n",
1609 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3--\r\n",
1610 "\r\n"
1611 );
1612
1613 let part = MimePart::parse(message).unwrap();
1614 let rebuilt = part.rebuild().unwrap();
1615
1616 k9::snapshot!(
1617 rebuilt.to_message_string(),
1618 r#"
1619Content-Type: multipart/mixed;\r
1620\tboundary="8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3"\r
1621Subject: Test for events 2\r
1622\r
1623--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1624Content-Type: multipart/alternative;\r
1625\tboundary="a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f"\r
1626\r
1627--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r
1628Content-Type: text/plain;\r
1629\tcharset="us-ascii"\r
1630\r
1631This is a test for calendar event invitation\r
1632--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r
1633Content-Type: text/html;\r
1634\tcharset="us-ascii"\r
1635\r
1636<p>This is a test for calendar event invitation</p>\r
1637--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f--\r
1638--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1639Content-Type: text/calendar;\r
1640\tcharset="us-ascii";\r
1641\tmethod="REQUEST";\r
1642\tname="Invitation.ics"\r
1643\r
1644Invitation\r
1645--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1646Content-Disposition: attachment;\r
1647\tfilename="event.ics"\r
1648Content-Type: application/ics;\r
1649\tname="event.ics"\r
1650Content-Transfer-Encoding: base64\r
1651\r
1652RXZlbnQNCg==\r
1653--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3--\r
1654
1655"#
1656 );
1657 }
1658}