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 BASE64_RFC2045: data_encoding::Encoding = data_encoding_macro::new_encoding! {
24 symbols: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",
25 padding: '=',
26 ignore: " \r\n\t",
27 wrap_width: 76,
28 wrap_separator: "\r\n",
29 check_trailing_bits: false,
30};
31
32#[derive(Debug, Clone, PartialEq)]
33pub struct MimePart<'a> {
34 bytes: SharedString<'a>,
36 headers: HeaderMap<'a>,
38 body_offset: usize,
40 body_len: usize,
41 conformance: MessageConformance,
42 parts: Vec<Self>,
43 intro: SharedString<'a>,
45 outro: SharedString<'a>,
47}
48
49#[derive(PartialEq, Debug)]
50pub struct Rfc2045Info {
51 pub encoding: ContentTransferEncoding,
52 pub charset: Result<&'static Encoding>,
53 pub content_type: Option<MimeParameters>,
54 pub is_text: bool,
55 pub is_multipart: bool,
56 pub attachment_options: Option<AttachmentOptions>,
57 pub invalid_mime_headers: bool,
58}
59
60impl Rfc2045Info {
61 fn new(headers: &HeaderMap) -> Self {
64 let mut invalid_mime_headers = false;
65 let encoding = match headers.content_transfer_encoding() {
66 Ok(Some(cte)) => match cte
67 .value
68 .to_str()
69 .map_err(|_| ())
70 .and_then(|s| ContentTransferEncoding::from_str(s).map_err(|_| ()))
71 {
72 Ok(encoding) => encoding,
73 Err(_) => {
74 invalid_mime_headers = true;
75 ContentTransferEncoding::SevenBit
76 }
77 },
78 Ok(None) => ContentTransferEncoding::SevenBit,
79 Err(_) => {
80 invalid_mime_headers = true;
81 ContentTransferEncoding::SevenBit
82 }
83 };
84
85 let content_type = match headers.content_type() {
86 Ok(ct) => ct,
87 Err(_) => {
88 invalid_mime_headers = true;
89 None
90 }
91 };
92
93 let mut ct_name = None;
94 let charset = if let Some(ct) = &content_type {
95 ct_name = ct.get("name");
96 ct.get("charset")
97 } else {
98 None
99 };
100 let charset = charset.unwrap_or_else(|| "us-ascii".into());
101
102 let charset = match charset.to_str() {
103 Ok(charset) => Encoding::by_name(&*charset).ok_or_else(|| {
104 MailParsingError::BodyParse(format!("unsupported charset {charset}"))
105 }),
106 Err(_) => Err(MailParsingError::BodyParse(format!(
107 "non-ascii charset name {charset}"
108 ))),
109 };
110
111 let (is_text, is_multipart) = if let Some(ct) = &content_type {
112 (ct.is_text(), ct.is_multipart())
113 } else {
114 (true, false)
115 };
116
117 let mut inline = false;
118 let mut cd_file_name = None;
119
120 match headers.content_disposition() {
121 Ok(Some(cd)) => {
122 inline = cd.value == "inline";
123 cd_file_name = cd.get("filename");
124 }
125 Ok(None) => {}
126 Err(_) => {
127 invalid_mime_headers = true;
128 }
129 };
130
131 let content_id = match headers.content_id() {
132 Ok(cid) => cid.map(|cid| cid.0),
133 Err(_) => {
134 invalid_mime_headers = true;
135 None
136 }
137 };
138
139 let file_name = match (cd_file_name, ct_name) {
140 (Some(name), _) | (None, Some(name)) => Some(name),
141 (None, None) => None,
142 };
143
144 let attachment_options = if inline || file_name.is_some() || content_id.is_some() {
145 Some(AttachmentOptions {
146 file_name,
147 inline,
148 content_id,
149 })
150 } else {
151 None
152 };
153
154 Self {
155 encoding,
156 charset,
157 content_type,
158 is_text,
159 is_multipart,
160 attachment_options,
161 invalid_mime_headers,
162 }
163 }
164
165 pub fn content_type(&self) -> Option<&str> {
166 self.content_type
167 .as_ref()
168 .and_then(|params| params.value.to_str().ok())
169 }
170}
171
172impl<'a> MimePart<'a> {
173 pub fn parse<S>(bytes: S) -> Result<Self>
175 where
176 S: IntoSharedString<'a>,
177 {
178 let (bytes, base_conformance) = bytes.into_shared_string();
179 Self::parse_impl(bytes, base_conformance, true)
180 }
181
182 pub fn to_owned(&self) -> MimePart<'static> {
184 MimePart {
185 bytes: self.bytes.to_owned(),
186 headers: self.headers.to_owned(),
187 body_offset: self.body_offset,
188 body_len: self.body_len,
189 conformance: self.conformance,
190 parts: self.parts.iter().map(|p| p.to_owned()).collect(),
191 intro: self.intro.to_owned(),
192 outro: self.outro.to_owned(),
193 }
194 }
195
196 fn parse_impl(
197 bytes: SharedString<'a>,
198 base_conformance: MessageConformance,
199 is_top_level: bool,
200 ) -> Result<Self> {
201 let HeaderParseResult {
202 headers,
203 body_offset,
204 overall_conformance: mut conformance,
205 } = Header::parse_headers(bytes.clone())?;
206
207 conformance |= base_conformance;
208
209 let body_len = bytes.len();
210
211 if !bytes.as_bytes().is_ascii() {
212 conformance.set(MessageConformance::NEEDS_TRANSFER_ENCODING, true);
213 }
214 {
215 let mut prev = 0;
216 for idx in memchr::memchr_iter(b'\n', bytes.as_bytes()) {
217 if idx - prev > 78 {
218 conformance.set(MessageConformance::LINE_TOO_LONG, true);
219 break;
220 }
221 prev = idx;
222 }
223 }
224 conformance.set(
225 MessageConformance::NON_CANONICAL_LINE_ENDINGS,
226 has_lone_cr_or_lf(bytes.as_bytes()),
227 );
228
229 if is_top_level {
230 conformance.set(
231 MessageConformance::MISSING_DATE_HEADER,
232 !matches!(headers.date(), Ok(Some(_))),
233 );
234 conformance.set(
235 MessageConformance::MISSING_MESSAGE_ID_HEADER,
236 !matches!(headers.message_id(), Ok(Some(_))),
237 );
238 conformance.set(
239 MessageConformance::MISSING_MIME_VERSION,
240 match headers.mime_version() {
241 Ok(Some(v)) => v != "1.0",
242 _ => true,
243 },
244 );
245 }
246
247 let mut part = Self {
248 bytes,
249 headers,
250 body_offset,
251 body_len,
252 conformance,
253 parts: vec![],
254 intro: SharedString::Borrowed(b""),
255 outro: SharedString::Borrowed(b""),
256 };
257
258 part.recursive_parse()?;
259
260 Ok(part)
261 }
262
263 fn recursive_parse(&mut self) -> Result<()> {
264 let info = Rfc2045Info::new(&self.headers);
265 if info.invalid_mime_headers {
266 self.conformance |= MessageConformance::INVALID_MIME_HEADERS;
267 }
268 if let Some((boundary, true)) = info
269 .content_type
270 .as_ref()
271 .and_then(|ct| ct.get("boundary").map(|b| (b, info.is_multipart)))
272 {
273 let boundary = format!("\n--{boundary}");
274 let raw_body = self
275 .bytes
276 .slice(self.body_offset.saturating_sub(1)..self.bytes.len());
277
278 let mut iter = memchr::memmem::find_iter(raw_body.as_bytes(), &boundary);
279 if let Some(first_boundary_pos) = iter.next() {
280 self.intro = raw_body.slice(0..first_boundary_pos);
281
282 self.body_len = 0;
285
286 let mut boundary_end = first_boundary_pos + boundary.len();
287
288 while let Some(part_start) =
289 memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
290 .map(|p| p + boundary_end + 1)
291 {
292 let part_end = iter
293 .next()
294 .map(|p| {
295 p + 1
298 })
299 .unwrap_or(raw_body.len());
300
301 let child = Self::parse_impl(
302 raw_body.slice(part_start..part_end),
303 MessageConformance::default(),
304 false,
305 )?;
306 self.conformance |= child.conformance;
307 self.parts.push(child);
308
309 boundary_end = part_end -
310 1 + boundary.len();
312
313 if boundary_end + 2 > raw_body.len() {
314 break;
315 }
316 if &raw_body.as_bytes()[boundary_end..boundary_end + 2] == b"--" {
317 if let Some(after_boundary) =
318 memchr::memchr(b'\n', &raw_body.as_bytes()[boundary_end..])
319 .map(|p| p + boundary_end + 1)
320 {
321 self.outro = raw_body.slice(after_boundary..raw_body.len());
322 }
323 break;
324 }
325 }
326 }
327 }
328
329 Ok(())
330 }
331
332 pub fn deep_conformance_check(&self) -> MessageConformance {
339 if self.parts.is_empty() {
340 match self.extract_body(None) {
341 Ok((_, conformance)) => conformance,
342 Err(_) => self.conformance | MessageConformance::NEEDS_TRANSFER_ENCODING,
343 }
344 } else {
345 let mut conformance = self.conformance;
346 for p in &self.parts {
347 conformance |= p.deep_conformance_check();
348 }
349 conformance
350 }
351 }
352
353 pub fn conformance(&self) -> MessageConformance {
355 self.conformance
356 }
357
358 pub fn child_parts(&self) -> &[Self] {
360 &self.parts
361 }
362
363 pub fn child_parts_mut(&mut self) -> &mut Vec<Self> {
365 &mut self.parts
366 }
367
368 pub fn headers(&'_ self) -> &'_ HeaderMap<'_> {
370 &self.headers
371 }
372
373 pub fn headers_mut<'b>(&'b mut self) -> &'b mut HeaderMap<'a> {
375 &mut self.headers
376 }
377
378 pub fn raw_body(&'_ self) -> SharedString<'_> {
380 self.bytes
381 .slice(self.body_offset..self.body_len.max(self.body_offset))
382 }
383
384 pub fn rfc2045_info(&self) -> Rfc2045Info {
385 Rfc2045Info::new(&self.headers)
386 }
387
388 pub fn body(&'_ self) -> Result<DecodedBody<'_>> {
390 let (body, _conformance) = self.extract_body(None)?;
391 Ok(body)
392 }
393
394 fn extract_body(
395 &'_ self,
396 options: Option<&CheckFixSettings>,
397 ) -> Result<(DecodedBody<'_>, MessageConformance)> {
398 let info = Rfc2045Info::new(&self.headers);
399
400 let bytes = match info.encoding {
401 ContentTransferEncoding::Base64 => {
402 let data = self.raw_body();
403 let bytes = data.as_bytes();
404 BASE64_RFC2045.decode(bytes).map_err(|err| {
405 let b = bytes[err.position] as char;
406 let region =
407 &bytes[err.position.saturating_sub(8)..(err.position + 8).min(bytes.len())];
408 let region = String::from_utf8_lossy(region);
409 MailParsingError::BodyParse(format!(
410 "base64 decode: {err:#} b={b:?} in {region}"
411 ))
412 })?
413 }
414 ContentTransferEncoding::QuotedPrintable => quoted_printable::decode(
415 self.raw_body().as_bytes(),
416 quoted_printable::ParseMode::Robust,
417 )
418 .map_err(|err| {
419 MailParsingError::BodyParse(format!("quoted printable decode: {err:#}"))
420 })?,
421 ContentTransferEncoding::SevenBit
422 | ContentTransferEncoding::EightBit
423 | ContentTransferEncoding::Binary => self.raw_body().as_bytes().to_vec(),
424 };
425
426 if info.is_text {
427 let charset = info.charset?;
428
429 match charset.decode_simple(&bytes) {
430 Ok(decoded) => Ok((
431 DecodedBody::Text(decoded.to_string().into()),
432 self.conformance,
433 )),
434 Err(_err) => {
435 if let Some(settings) = options {
436 if settings.detect_encoding {
437 let norm_settings = NormalizerSettings {
438 include_encodings: settings.include_encodings.clone(),
439 exclude_encodings: settings.exclude_encodings.clone(),
440 ..Default::default()
441 };
442
443 if let Ok(guess) =
444 charset_normalizer_rs::from_bytes(&*bytes, Some(norm_settings))
445 {
446 if let Some(decoded) =
447 guess.get_best().and_then(|best| best.decoded_payload())
448 {
449 return Ok((
450 DecodedBody::Text(decoded.to_string().into()),
451 MessageConformance::NEEDS_TRANSFER_ENCODING
452 | self.conformance,
453 ));
454 }
455 }
456
457 return Ok((
465 DecodedBody::Binary(bytes),
466 MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
467 ));
468 }
469 }
470
471 if let Ok(decoded) = std::str::from_utf8(&bytes) {
476 return Ok((
477 DecodedBody::Text(decoded.to_string().into()),
478 MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
479 ));
480 }
481
482 Ok((
485 DecodedBody::Binary(bytes),
486 MessageConformance::NEEDS_TRANSFER_ENCODING | self.conformance,
487 ))
488 }
489 }
490 } else {
491 Ok((DecodedBody::Binary(bytes), self.conformance))
492 }
493 }
494
495 pub fn rebuild(&self, settings: Option<&CheckFixSettings>) -> Result<Self> {
502 let info = Rfc2045Info::new(&self.headers);
503
504 let mut children = vec![];
505 for part in &self.parts {
506 children.push(part.rebuild(settings)?);
507 }
508
509 let mut rebuilt = if children.is_empty() {
510 let (body, _conformance) = self.extract_body(settings)?;
511 match body {
512 DecodedBody::Text(text) => {
513 let ct = info
514 .content_type
515 .as_ref()
516 .map(|ct| ct.value.as_bstr())
517 .unwrap_or_else(|| BStr::new("text/plain"));
518 Self::new_text(ct, text.as_bytes())?
519 }
520 DecodedBody::Binary(data) => {
521 let ct = info
522 .content_type
523 .as_ref()
524 .map(|ct| ct.value.as_bstr())
525 .unwrap_or_else(|| BStr::new("application/octet-stream"));
526 Self::new_binary(ct, &data, info.attachment_options.as_ref())?
527 }
528 }
529 } else {
530 let ct = info.content_type.ok_or_else(|| {
531 MailParsingError::BodyParse(
532 "multipart message has no content-type information!?".to_string(),
533 )
534 })?;
535 Self::new_multipart(
536 &ct.value,
537 children,
538 ct.get("boundary").as_deref().map(|b| b.as_bytes()),
539 )?
540 };
541
542 for hdr in self.headers.iter() {
543 let name = hdr.get_name();
544 if name.eq_ignore_ascii_case(b"Content-ID") {
545 continue;
546 }
547
548 if name.eq_ignore_ascii_case(b"Content-Type") {
551 if let Ok(params) = hdr.as_content_type() {
552 let Some(mut dest) = rebuilt.headers_mut().content_type()? else {
553 continue;
554 };
555
556 for (k, v) in params.parameter_map() {
557 if dest.get(&k).is_none() {
558 dest.set(&k, &v);
559 }
560 }
561
562 rebuilt.headers_mut().set_content_type(dest)?;
563 }
564 continue;
565 }
566 if name.eq_ignore_ascii_case(b"Content-Transfer-Encoding") {
567 if let Ok(params) = hdr.as_content_transfer_encoding() {
568 let Some(mut dest) = rebuilt.headers_mut().content_transfer_encoding()? else {
569 continue;
570 };
571
572 for (k, v) in params.parameter_map() {
573 if dest.get(&k).is_none() {
574 dest.set(&k, &v);
575 }
576 }
577
578 rebuilt.headers_mut().set_content_transfer_encoding(dest)?;
579 }
580 continue;
581 }
582 if name.eq_ignore_ascii_case(b"Content-Disposition") {
583 if let Ok(params) = hdr.as_content_disposition() {
584 let Some(mut dest) = rebuilt.headers_mut().content_disposition()? else {
585 continue;
586 };
587
588 for (k, v) in params.parameter_map() {
589 if dest.get(&k).is_none() {
590 dest.set(&k, &v);
591 }
592 }
593
594 rebuilt.headers_mut().set_content_disposition(dest)?;
595 }
596 continue;
597 }
598
599 if let Ok(hdr) = hdr.rebuild() {
600 rebuilt.headers_mut().push(hdr);
601 }
602 }
603
604 Ok(rebuilt)
605 }
606
607 pub fn write_message<W: std::io::Write>(&self, out: &mut W) -> Result<()> {
609 let line_ending = if self
610 .conformance
611 .contains(MessageConformance::NON_CANONICAL_LINE_ENDINGS)
612 {
613 "\n"
614 } else {
615 "\r\n"
616 };
617
618 for hdr in self.headers.iter() {
619 hdr.write_header(out)
620 .map_err(|_| MailParsingError::WriteMessageIOError)?;
621 }
622 out.write_all(line_ending.as_bytes())
623 .map_err(|_| MailParsingError::WriteMessageIOError)?;
624
625 if self.parts.is_empty() {
626 out.write_all(self.raw_body().as_bytes())
627 .map_err(|_| MailParsingError::WriteMessageIOError)?;
628 } else {
629 let info = Rfc2045Info::new(&self.headers);
630 let ct = info.content_type.ok_or({
631 MailParsingError::WriteMessageWtf(
632 "expected to have Content-Type when there are child parts",
633 )
634 })?;
635 let boundary = ct.get("boundary").ok_or({
636 MailParsingError::WriteMessageWtf("expected Content-Type to have a boundary")
637 })?;
638 out.write_all(self.intro.as_bytes())
639 .map_err(|_| MailParsingError::WriteMessageIOError)?;
640 for p in &self.parts {
641 write!(out, "--{boundary}{line_ending}")
642 .map_err(|_| MailParsingError::WriteMessageIOError)?;
643 p.write_message(out)?;
644 }
645 write!(out, "--{boundary}--{line_ending}")
646 .map_err(|_| MailParsingError::WriteMessageIOError)?;
647 out.write_all(self.outro.as_bytes())
648 .map_err(|_| MailParsingError::WriteMessageIOError)?;
649 }
650 Ok(())
651 }
652
653 pub fn to_message_bytes(&self) -> Vec<u8> {
656 let mut out = vec![];
657 self.write_message(&mut out).unwrap();
658 out
659 }
660
661 pub fn replace_text_body(
662 &mut self,
663 content_type: impl AsRef<[u8]>,
664 content: impl AsRef<BStr>,
665 ) -> Result<()> {
666 let mut new_part = Self::new_text(content_type, content)?;
667 self.bytes = new_part.bytes;
668 self.body_offset = new_part.body_offset;
669 self.body_len = new_part.body_len;
670 self.headers.remove_all_named("Content-Type");
674 self.headers.remove_all_named("Content-Transfer-Encoding");
675 self.headers.append(&mut new_part.headers.headers);
677 Ok(())
678 }
679
680 pub fn replace_binary_body(&mut self, content_type: &[u8], content: &[u8]) -> Result<()> {
681 let mut new_part = Self::new_binary(content_type, content, None)?;
682 self.bytes = new_part.bytes;
683 self.body_offset = new_part.body_offset;
684 self.body_len = new_part.body_len;
685 self.headers.remove_all_named("Content-Type");
689 self.headers.remove_all_named("Content-Transfer-Encoding");
690 self.headers.append(&mut new_part.headers.headers);
692 Ok(())
693 }
694
695 pub fn new_no_transfer_encoding(content_type: &str, bytes: &[u8]) -> Result<Self> {
696 if bytes.iter().any(|b| !b.is_ascii()) {
697 return Err(MailParsingError::EightBit);
698 }
699
700 let mut headers = HeaderMap::default();
701
702 let ct = MimeParameters::new(content_type);
703 headers.set_content_type(ct)?;
704
705 let bytes = String::from_utf8_lossy(bytes).to_string();
706 let body_len = bytes.len();
707
708 Ok(Self {
709 bytes: bytes.into(),
710 headers,
711 body_offset: 0,
712 body_len,
713 conformance: MessageConformance::default(),
714 parts: vec![],
715 intro: "".into(),
716 outro: "".into(),
717 })
718 }
719
720 pub fn new_text(content_type: impl AsRef<[u8]>, content: impl AsRef<BStr>) -> Result<Self> {
724 let content = content.as_ref();
725 let qp_encoded = quoted_printable::encode(content);
727
728 let (mut encoded, encoding) = if qp_encoded == content {
729 (qp_encoded, None)
730 } else if qp_encoded.len() <= BASE64_RFC2045.encode_len(content.len()) {
731 (qp_encoded, Some("quoted-printable"))
732 } else {
733 (BASE64_RFC2045.encode(content).into_bytes(), Some("base64"))
736 };
737
738 if !encoded.ends_with(b"\r\n") {
739 encoded.extend_from_slice(b"\r\n");
740 }
741 let mut headers = HeaderMap::default();
742
743 let mut ct = MimeParameters::new(content_type);
744 ct.set(
745 "charset",
746 if content.is_ascii() {
747 "us-ascii"
748 } else {
749 "utf-8"
750 },
751 );
752 headers.set_content_type(ct)?;
753
754 if let Some(encoding) = encoding {
755 headers.set_content_transfer_encoding(MimeParameters::new(encoding))?;
756 }
757
758 let body_len = encoded.len();
759 let bytes =
760 String::from_utf8(encoded).expect("transfer encoder to produce valid ASCII output");
761
762 Ok(Self {
763 bytes: bytes.into(),
764 headers,
765 body_offset: 0,
766 body_len,
767 conformance: MessageConformance::default(),
768 parts: vec![],
769 intro: "".into(),
770 outro: "".into(),
771 })
772 }
773
774 pub fn new_text_plain(content: impl AsRef<BStr>) -> Result<Self> {
775 Self::new_text("text/plain", content)
776 }
777
778 pub fn new_html(content: impl AsRef<BStr>) -> Result<Self> {
779 Self::new_text("text/html", content)
780 }
781
782 pub fn new_multipart(
783 content_type: impl AsRef<[u8]>,
784 parts: Vec<Self>,
785 boundary: Option<&[u8]>,
786 ) -> Result<Self> {
787 let mut headers = HeaderMap::default();
788
789 let mut ct = MimeParameters::new(content_type);
790 match boundary {
791 Some(b) => {
792 ct.set("boundary", b);
793 }
794 None => {
795 let uuid = uuid::Uuid::new_v4();
797 let boundary = data_encoding::BASE64_NOPAD.encode(uuid.as_bytes());
798 ct.set("boundary", &boundary);
799 }
800 }
801 headers.set_content_type(ct)?;
802
803 Ok(Self {
804 bytes: "".into(),
805 headers,
806 body_offset: 0,
807 body_len: 0,
808 conformance: MessageConformance::default(),
809 parts,
810 intro: "".into(),
811 outro: "".into(),
812 })
813 }
814
815 pub fn new_binary(
816 content_type: impl AsRef<[u8]>,
817 content: &[u8],
818 options: Option<&AttachmentOptions>,
819 ) -> Result<Self> {
820 let mut encoded = BASE64_RFC2045.encode(content);
821 if !encoded.ends_with("\r\n") {
822 encoded.push_str("\r\n");
823 }
824 let mut headers = HeaderMap::default();
825
826 let mut ct = MimeParameters::new(content_type);
827
828 if let Some(opts) = options {
829 let mut cd = MimeParameters::new(if opts.inline { "inline" } else { "attachment" });
830 if let Some(name) = &opts.file_name {
831 cd.set("filename", name);
832 let encoding = if name.chars().any(|c| !c.is_ascii()) {
833 MimeParameterEncoding::QuotedRfc2047
834 } else {
835 MimeParameterEncoding::None
836 };
837 ct.set_with_encoding("name", name, encoding);
838 }
839 headers.set_content_disposition(cd)?;
840
841 if let Some(id) = &opts.content_id {
842 headers.set_content_id(MessageID(id.clone()))?;
843 }
844 }
845
846 headers.set_content_type(ct)?;
847 headers.set_content_transfer_encoding(MimeParameters::new("base64"))?;
848
849 let body_len = encoded.len();
850
851 Ok(Self {
852 bytes: encoded.into(),
853 headers,
854 body_offset: 0,
855 body_len,
856 conformance: MessageConformance::default(),
857 parts: vec![],
858 intro: "".into(),
859 outro: "".into(),
860 })
861 }
862
863 pub fn simplified_structure(&'a self) -> Result<SimplifiedStructure<'a>> {
868 let parts = self.simplified_structure_pointers()?;
869
870 let mut text = None;
871 let mut html = None;
872 let mut amp_html = None;
873
874 let headers = &self
875 .resolve_ptr(parts.header_part)
876 .expect("header part to always be valid")
877 .headers;
878
879 if let Some(p) = parts.text_part.and_then(|p| self.resolve_ptr(p)) {
880 text = match p.body()? {
881 DecodedBody::Text(t) => Some(t),
882 DecodedBody::Binary(_) => {
883 return Err(MailParsingError::BodyParse(
884 "expected text/plain part to be text, but it is binary".to_string(),
885 ))
886 }
887 };
888 }
889 if let Some(p) = parts.html_part.and_then(|p| self.resolve_ptr(p)) {
890 html = match p.body()? {
891 DecodedBody::Text(t) => Some(t),
892 DecodedBody::Binary(_) => {
893 return Err(MailParsingError::BodyParse(
894 "expected text/html part to be text, but it is binary".to_string(),
895 ))
896 }
897 };
898 }
899 if let Some(p) = parts.amp_html_part.and_then(|p| self.resolve_ptr(p)) {
900 amp_html = match p.body()? {
901 DecodedBody::Text(t) => Some(t),
902 DecodedBody::Binary(_) => {
903 return Err(MailParsingError::BodyParse(
904 "expected text/x-amp-html part to be text, but it is binary".to_string(),
905 ))
906 }
907 };
908 }
909
910 let mut attachments = vec![];
911 for ptr in parts.attachments {
912 attachments.push(self.resolve_ptr(ptr).expect("pointer to be valid").clone());
913 }
914
915 Ok(SimplifiedStructure {
916 text,
917 html,
918 amp_html,
919 headers,
920 attachments,
921 })
922 }
923
924 pub fn resolve_ptr(&self, ptr: PartPointer) -> Option<&Self> {
926 let mut current = self;
927 let mut cursor = ptr.0.as_slice();
928
929 loop {
930 match cursor.first() {
931 Some(&idx) => {
932 current = current.parts.get(idx as usize)?;
933 cursor = &cursor[1..];
934 }
935 None => {
936 return Some(current);
938 }
939 }
940 }
941 }
942
943 pub fn resolve_ptr_mut(&mut self, ptr: PartPointer) -> Option<&mut Self> {
945 let mut current = self;
946 let mut cursor = ptr.0.as_slice();
947
948 loop {
949 match cursor.first() {
950 Some(&idx) => {
951 current = current.parts.get_mut(idx as usize)?;
952 cursor = &cursor[1..];
953 }
954 None => {
955 return Some(current);
957 }
958 }
959 }
960 }
961
962 pub fn simplified_structure_pointers(&self) -> Result<SimplifiedStructurePointers> {
968 self.simplified_structure_pointers_impl(None)
969 }
970
971 fn simplified_structure_pointers_impl(
972 &self,
973 my_idx: Option<u8>,
974 ) -> Result<SimplifiedStructurePointers> {
975 let info = Rfc2045Info::new(&self.headers);
976 let is_inline = info
977 .attachment_options
978 .as_ref()
979 .map(|ao| ao.inline)
980 .unwrap_or(true);
981
982 if let Some(ct) = &info.content_type {
983 if is_inline {
984 if ct.value == "text/plain" {
985 return Ok(SimplifiedStructurePointers {
986 amp_html_part: None,
987 text_part: Some(PartPointer::root_or_nth(my_idx)),
988 html_part: None,
989 header_part: PartPointer::root_or_nth(my_idx),
990 attachments: vec![],
991 });
992 }
993 if ct.value == "text/html" {
994 return Ok(SimplifiedStructurePointers {
995 amp_html_part: None,
996 html_part: Some(PartPointer::root_or_nth(my_idx)),
997 text_part: None,
998 header_part: PartPointer::root_or_nth(my_idx),
999 attachments: vec![],
1000 });
1001 }
1002 if ct.value == "text/x-amp-html" {
1003 return Ok(SimplifiedStructurePointers {
1004 amp_html_part: Some(PartPointer::root_or_nth(my_idx)),
1005 html_part: None,
1006 text_part: None,
1007 header_part: PartPointer::root_or_nth(my_idx),
1008 attachments: vec![],
1009 });
1010 }
1011 }
1012
1013 if ct.value.starts_with_str("multipart/") {
1014 let mut text_part = None;
1015 let mut html_part = None;
1016 let mut amp_html_part = None;
1017 let mut attachments = vec![];
1018
1019 for (i, p) in self.parts.iter().enumerate() {
1020 let part_idx = i.try_into().map_err(|_| MailParsingError::TooManyParts)?;
1021 if let Ok(s) = p.simplified_structure_pointers_impl(Some(part_idx)) {
1022 if let Some(p) = s.text_part {
1023 let ptr = PartPointer::root_or_nth(my_idx).append(p);
1024 if text_part.is_none() {
1025 text_part.replace(ptr);
1026 } else {
1027 attachments.push(ptr);
1028 }
1029 }
1030 if let Some(p) = s.html_part {
1031 let ptr = PartPointer::root_or_nth(my_idx).append(p);
1032 if html_part.is_none() {
1033 html_part.replace(ptr);
1034 } else {
1035 attachments.push(ptr);
1036 }
1037 }
1038 if let Some(p) = s.amp_html_part {
1039 let ptr = PartPointer::root_or_nth(my_idx).append(p);
1040 if amp_html_part.is_none() {
1041 amp_html_part.replace(ptr);
1042 } else {
1043 attachments.push(ptr);
1044 }
1045 }
1046 for attachment in s.attachments {
1047 attachments.push(PartPointer::root_or_nth(my_idx).append(attachment));
1048 }
1049 }
1050 }
1051
1052 return Ok(SimplifiedStructurePointers {
1053 amp_html_part,
1054 html_part,
1055 text_part,
1056 header_part: PartPointer::root_or_nth(my_idx),
1057 attachments,
1058 });
1059 }
1060
1061 return Ok(SimplifiedStructurePointers {
1062 html_part: None,
1063 text_part: None,
1064 amp_html_part: None,
1065 header_part: PartPointer::root_or_nth(my_idx),
1066 attachments: vec![PartPointer::root_or_nth(my_idx)],
1067 });
1068 }
1069
1070 Ok(SimplifiedStructurePointers {
1072 text_part: Some(PartPointer::root_or_nth(my_idx)),
1073 html_part: None,
1074 amp_html_part: None,
1075 header_part: PartPointer::root_or_nth(my_idx),
1076 attachments: vec![],
1077 })
1078 }
1079
1080 pub fn check_fix_conformance(
1081 &self,
1082 check: MessageConformance,
1083 fix: MessageConformance,
1084 settings: CheckFixSettings,
1085 ) -> Result<Option<Self>> {
1086 let mut msg = self.clone();
1087 let conformance = msg.deep_conformance_check();
1088
1089 let check = check - fix;
1091
1092 if check.intersects(conformance) {
1093 let problems = check.intersection(conformance);
1094 return Err(MailParsingError::ConformanceIssues(problems));
1095 }
1096
1097 if !fix.intersects(conformance) {
1098 return Ok(None);
1099 }
1100
1101 let to_fix = fix.intersection(conformance);
1102
1103 let missing_headers_only = to_fix
1104 .difference(
1105 MessageConformance::MISSING_DATE_HEADER
1106 | MessageConformance::MISSING_MIME_VERSION
1107 | MessageConformance::MISSING_MESSAGE_ID_HEADER,
1108 )
1109 .is_empty();
1110
1111 if !missing_headers_only {
1112 if to_fix.contains(MessageConformance::NEEDS_TRANSFER_ENCODING) {
1113 if settings.detect_encoding {
1122 if let Some(data_bytes) = &settings.data_bytes {
1123 let norm_settings = NormalizerSettings {
1124 include_encodings: settings.include_encodings.clone(),
1125 exclude_encodings: settings.exclude_encodings.clone(),
1126 ..Default::default()
1127 };
1128
1129 let guess =
1130 charset_normalizer_rs::from_bytes(&*data_bytes, Some(norm_settings))
1131 .map_err(|err| MailParsingError::CharsetDetectionFailed(err))?;
1132 if let Some(best) = guess.get_best() {
1133 if let Some(decoded) = best.decoded_payload() {
1134 msg = MimePart::parse(decoded.to_string())?;
1135 }
1136 }
1137 }
1138 }
1139 }
1140
1141 msg = msg.rebuild(Some(&settings))?;
1142 }
1143
1144 if to_fix.contains(MessageConformance::MISSING_DATE_HEADER) {
1145 msg.headers_mut().set_date(Utc::now())?;
1146 }
1147
1148 if to_fix.contains(MessageConformance::MISSING_MIME_VERSION) {
1149 msg.headers_mut().set_mime_version("1.0")?;
1150 }
1151
1152 if to_fix.contains(MessageConformance::MISSING_MESSAGE_ID_HEADER) {
1153 if let Some(message_id) = &settings.message_id {
1154 msg.headers_mut()
1155 .set_message_id(MessageID(message_id.clone().into()))?;
1156 }
1157 }
1158
1159 Ok(Some(msg))
1160 }
1161}
1162
1163#[derive(Default, Debug, Clone, Deserialize)]
1164pub struct CheckFixSettings {
1165 #[serde(default)]
1166 pub detect_encoding: bool,
1167 #[serde(default)]
1168 pub include_encodings: Vec<String>,
1169 #[serde(default)]
1170 pub exclude_encodings: Vec<String>,
1171 #[serde(default)]
1172 pub message_id: Option<String>,
1173 #[serde(skip)]
1174 pub data_bytes: Option<Arc<Box<[u8]>>>,
1175}
1176
1177#[derive(Debug, Clone, PartialEq, Eq)]
1184pub struct PartPointer(Vec<u8>);
1185
1186impl PartPointer {
1187 pub fn root() -> Self {
1189 Self(vec![])
1190 }
1191
1192 pub fn root_or_nth(n: Option<u8>) -> Self {
1195 match n {
1196 Some(n) => Self::nth(n),
1197 None => Self::root(),
1198 }
1199 }
1200
1201 pub fn nth(n: u8) -> Self {
1203 Self(vec![n])
1204 }
1205
1206 pub fn append(mut self, mut other: Self) -> Self {
1209 self.0.append(&mut other.0);
1210 Self(self.0)
1211 }
1212
1213 pub fn id_string(&self) -> String {
1214 let mut id = String::new();
1215 for p in &self.0 {
1216 if !id.is_empty() {
1217 id.push('.');
1218 }
1219 id.push_str(&p.to_string());
1220 }
1221 id
1222 }
1223}
1224
1225#[derive(Debug, Clone)]
1226pub struct SimplifiedStructurePointers {
1227 pub text_part: Option<PartPointer>,
1229 pub html_part: Option<PartPointer>,
1231 pub amp_html_part: Option<PartPointer>,
1233 pub header_part: PartPointer,
1235 pub attachments: Vec<PartPointer>,
1237}
1238
1239#[derive(Debug, Clone, PartialEq)]
1240pub struct SimplifiedStructure<'a> {
1241 pub text: Option<SharedString<'a>>,
1242 pub html: Option<SharedString<'a>>,
1243 pub amp_html: Option<SharedString<'a>>,
1244 pub headers: &'a HeaderMap<'a>,
1245 pub attachments: Vec<MimePart<'a>>,
1246}
1247
1248#[serde_as]
1249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1250#[serde(deny_unknown_fields)]
1251pub struct AttachmentOptions {
1252 #[serde_as(as = "Option<BStringUtf8>")]
1253 #[serde(default)]
1254 pub file_name: Option<BString>,
1255 #[serde(default)]
1256 pub inline: bool,
1257 #[serde_as(as = "Option<BStringUtf8>")]
1258 #[serde(default)]
1259 pub content_id: Option<BString>,
1260}
1261
1262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1263pub enum ContentTransferEncoding {
1264 SevenBit,
1265 EightBit,
1266 Binary,
1267 QuotedPrintable,
1268 Base64,
1269}
1270
1271impl FromStr for ContentTransferEncoding {
1272 type Err = MailParsingError;
1273
1274 fn from_str(s: &str) -> Result<Self> {
1275 if s.eq_ignore_ascii_case("7bit") {
1276 Ok(Self::SevenBit)
1277 } else if s.eq_ignore_ascii_case("8bit") {
1278 Ok(Self::EightBit)
1279 } else if s.eq_ignore_ascii_case("binary") {
1280 Ok(Self::Binary)
1281 } else if s.eq_ignore_ascii_case("quoted-printable") {
1282 Ok(Self::QuotedPrintable)
1283 } else if s.eq_ignore_ascii_case("base64") {
1284 Ok(Self::Base64)
1285 } else {
1286 Err(MailParsingError::InvalidContentTransferEncoding(
1287 s.to_string(),
1288 ))
1289 }
1290 }
1291}
1292
1293#[derive(Debug, PartialEq)]
1294pub enum DecodedBody<'a> {
1295 Text(SharedString<'a>),
1296 Binary(Vec<u8>),
1297}
1298
1299impl<'a> DecodedBody<'a> {
1300 pub fn to_string_lossy(&'a self) -> Cow<'a, str> {
1301 match self {
1302 Self::Text(s) => s.to_str_lossy(),
1303 Self::Binary(b) => String::from_utf8_lossy(b),
1304 }
1305 }
1306}
1307
1308#[cfg(test)]
1309mod test {
1310 use super::*;
1311
1312 #[test]
1313 fn msg_parsing() {
1314 let message = concat!(
1315 "Subject: hello there\n",
1316 "From: Someone <someone@example.com>\n",
1317 "\n",
1318 "I am the body"
1319 );
1320
1321 let part = MimePart::parse(message).unwrap();
1322 k9::assert_equal!(message.as_bytes(), part.to_message_bytes());
1323 assert_eq!(part.raw_body(), "I am the body");
1324 k9::snapshot!(
1325 part.body(),
1326 r#"
1327Ok(
1328 Text(
1329 "I am the body",
1330 ),
1331)
1332"#
1333 );
1334
1335 k9::snapshot!(
1336 BString::from(part.rebuild(None).unwrap().to_message_bytes()),
1337 r#"
1338Content-Type: text/plain;\r
1339\tcharset="us-ascii"\r
1340Subject: hello there\r
1341From: Someone <someone@example.com>\r
1342\r
1343I am the body\r
1344
1345"#
1346 );
1347 }
1348
1349 #[test]
1350 fn mime_bogus_body() {
1351 let message = concat!(
1352 "Subject: hello there\n",
1353 "From: Someone <someone@example.com>\n",
1354 "Mime-Version: 1.0\n",
1355 "Content-Type: text/plain\n",
1356 "Content-Transfer-Encoding: base64\n",
1357 "\n",
1358 "hello\n"
1359 );
1360
1361 let part = MimePart::parse(message).unwrap();
1362 assert_eq!(
1363 part.body().unwrap_err(),
1364 MailParsingError::BodyParse(
1365 "base64 decode: invalid length at 4 b='o' in hello\n".to_string()
1366 )
1367 );
1368 }
1369
1370 #[test]
1371 fn mime_encoded_body() {
1372 let message = concat!(
1373 "Subject: hello there\n",
1374 "From: Someone <someone@example.com>\n",
1375 "Mime-Version: 1.0\n",
1376 "Content-Type: text/plain\n",
1377 "Content-Transfer-Encoding: base64\n",
1378 "\n",
1379 "aGVsbG8K\n"
1380 );
1381
1382 let part = MimePart::parse(message).unwrap();
1383 k9::assert_equal!(message.as_bytes(), part.to_message_bytes());
1384 assert_eq!(part.raw_body(), "aGVsbG8K\n");
1385 k9::snapshot!(
1386 part.body(),
1387 r#"
1388Ok(
1389 Text(
1390 "hello
1391",
1392 ),
1393)
1394"#
1395 );
1396
1397 k9::snapshot!(
1398 BString::from(part.rebuild(None).unwrap().to_message_bytes()),
1399 r#"
1400Content-Type: text/plain;\r
1401\tcharset="us-ascii"\r
1402Content-Transfer-Encoding: quoted-printable\r
1403Subject: hello there\r
1404From: Someone <someone@example.com>\r
1405Mime-Version: 1.0\r
1406\r
1407hello=0A\r
1408
1409"#
1410 );
1411 }
1412
1413 #[test]
1414 fn mime_multipart_1() {
1415 let message = concat!(
1416 "Subject: This is a test email\n",
1417 "Content-Type: multipart/alternative; boundary=foobar\n",
1418 "Mime-Version: 1.0\n",
1419 "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\n",
1420 "\n",
1421 "--foobar\n",
1422 "Content-Type: text/plain; charset=utf-8\n",
1423 "Content-Transfer-Encoding: quoted-printable\n",
1424 "\n",
1425 "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\n",
1426 "--foobar\n",
1427 "Content-Type: text/html\n",
1428 "Content-Transfer-Encoding: base64\n",
1429 "\n",
1430 "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \n",
1431 "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \n",
1432 "--foobar--\n",
1433 "After the final boundary stuff gets ignored.\n"
1434 );
1435
1436 let part = MimePart::parse(message).unwrap();
1437
1438 k9::assert_equal!(message.as_bytes(), part.to_message_bytes());
1439
1440 let children = part.child_parts();
1441 k9::assert_equal!(children.len(), 2);
1442
1443 k9::snapshot!(
1444 children[0].body(),
1445 r#"
1446Ok(
1447 Text(
1448 "This is the plaintext version, in utf-8. Proof by Euro: €\r
1449",
1450 ),
1451)
1452"#
1453 );
1454 k9::snapshot!(
1455 children[1].body(),
1456 r#"
1457Ok(
1458 Text(
1459 "<html><body>This is the <b>HTML</b> version, in us-ascii. Proof by Euro: €</body></html>
1460",
1461 ),
1462)
1463"#
1464 );
1465 }
1466
1467 #[test]
1468 fn mutate_1() {
1469 let message = concat!(
1470 "Subject: This is a test email\r\n",
1471 "Content-Type: multipart/alternative; boundary=foobar\r\n",
1472 "Mime-Version: 1.0\r\n",
1473 "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1474 "\r\n",
1475 "--foobar\r\n",
1476 "Content-Type: text/plain; charset=utf-8\r\n",
1477 "Content-Transfer-Encoding: quoted-printable\r\n",
1478 "\r\n",
1479 "This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r\n",
1480 "--foobar\r\n",
1481 "Content-Type: text/html\r\n",
1482 "Content-Transfer-Encoding: base64\r\n",
1483 "\r\n",
1484 "PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r\n",
1485 "dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r\n",
1486 "--foobar--\r\n",
1487 "After the final boundary stuff gets ignored.\r\n"
1488 );
1489
1490 let mut part = MimePart::parse(message).unwrap();
1491 k9::assert_equal!(message.as_bytes(), part.to_message_bytes());
1492 fn munge(part: &mut MimePart) {
1493 let headers = part.headers_mut();
1494 headers.push(Header::with_name_value("X-Woot", "Hello"));
1495 headers.insert(0, Header::with_name_value("X-First", "at the top"));
1496 headers.retain(|hdr| !hdr.get_name().eq_ignore_ascii_case(b"date"));
1497 }
1498 munge(&mut part);
1499
1500 let re_encoded = BString::from(part.to_message_bytes());
1501 k9::snapshot!(
1502 re_encoded,
1503 r#"
1504X-First: at the top\r
1505Subject: This is a test email\r
1506Content-Type: multipart/alternative; boundary=foobar\r
1507Mime-Version: 1.0\r
1508X-Woot: Hello\r
1509\r
1510--foobar\r
1511Content-Type: text/plain; charset=utf-8\r
1512Content-Transfer-Encoding: quoted-printable\r
1513\r
1514This is the plaintext version, in utf-8. Proof by Euro: =E2=82=AC\r
1515--foobar\r
1516Content-Type: text/html\r
1517Content-Transfer-Encoding: base64\r
1518\r
1519PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1520dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1521--foobar--\r
1522After the final boundary stuff gets ignored.\r
1523
1524"#
1525 );
1526
1527 eprintln!("part before mutate:\n{part:#?}");
1528
1529 part.child_parts_mut().retain(|part| {
1530 let ct = part.headers().content_type().unwrap().unwrap();
1531 ct.value == "text/html"
1532 });
1533
1534 eprintln!("part with html removed is:\n{part:#?}");
1535
1536 let re_encoded = BString::from(part.to_message_bytes());
1537 k9::snapshot!(
1538 re_encoded,
1539 r#"
1540X-First: at the top\r
1541Subject: This is a test email\r
1542Content-Type: multipart/alternative; boundary=foobar\r
1543Mime-Version: 1.0\r
1544X-Woot: Hello\r
1545\r
1546--foobar\r
1547Content-Type: text/html\r
1548Content-Transfer-Encoding: base64\r
1549\r
1550PGh0bWw+PGJvZHk+VGhpcyBpcyB0aGUgPGI+SFRNTDwvYj4gdmVyc2lvbiwgaW4g \r
1551dXMtYXNjaWkuIFByb29mIGJ5IEV1cm86ICZldXJvOzwvYm9keT48L2h0bWw+Cg== \r
1552--foobar--\r
1553After the final boundary stuff gets ignored.\r
1554
1555"#
1556 );
1557 }
1558
1559 #[test]
1560 fn replace_text_body() {
1561 let mut part = MimePart::new_text_plain("Hello 👻\r\n").unwrap();
1562 let encoded = BString::from(part.to_message_bytes());
1563 k9::snapshot!(
1564 &encoded,
1565 r#"
1566Content-Type: text/plain;\r
1567\tcharset="utf-8"\r
1568Content-Transfer-Encoding: base64\r
1569\r
1570SGVsbG8g8J+Ruw0K\r
1571
1572"#
1573 );
1574
1575 part.replace_text_body("text/plain", "Hello 🚀\r\n")
1576 .unwrap();
1577 let encoded = BString::from(part.to_message_bytes());
1578 k9::snapshot!(
1579 &encoded,
1580 r#"
1581Content-Type: text/plain;\r
1582\tcharset="utf-8"\r
1583Content-Transfer-Encoding: base64\r
1584\r
1585SGVsbG8g8J+agA0K\r
1586
1587"#
1588 );
1589 }
1590
1591 #[test]
1592 fn construct_1() {
1593 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";
1594
1595 let part = MimePart::new_text_plain(input_text).unwrap();
1596
1597 let encoded = BString::from(part.to_message_bytes());
1598 k9::snapshot!(
1599 &encoded,
1600 r#"
1601Content-Type: text/plain;\r
1602\tcharset="utf-8"\r
1603Content-Transfer-Encoding: quoted-printable\r
1604\r
1605Well, hello there! This is the plaintext version, in utf-8. Here's a Euro: =\r
1606=E2=82=AC, and here are some emoji =F0=9F=91=BB =F0=9F=8D=89 =F0=9F=92=A9 a=\r
1607nd this long should be long enough that we wrap it in the returned part, le=\r
1608t's see how that turns out!\r
1609
1610"#
1611 );
1612
1613 let parsed_part = MimePart::parse(encoded.clone()).unwrap();
1614 k9::assert_equal!(encoded, parsed_part.to_message_bytes());
1615 k9::assert_equal!(part.body().unwrap(), DecodedBody::Text(input_text.into()));
1616 k9::snapshot!(
1617 parsed_part.simplified_structure_pointers(),
1618 "
1619Ok(
1620 SimplifiedStructurePointers {
1621 text_part: Some(
1622 PartPointer(
1623 [],
1624 ),
1625 ),
1626 html_part: None,
1627 amp_html_part: None,
1628 header_part: PartPointer(
1629 [],
1630 ),
1631 attachments: [],
1632 },
1633)
1634"
1635 );
1636 }
1637
1638 #[test]
1639 fn construct_2() {
1640 let msg = MimePart::new_multipart(
1641 "multipart/mixed",
1642 vec![
1643 MimePart::new_text_plain("plain text").unwrap(),
1644 MimePart::new_html("<b>rich</b> text").unwrap(),
1645 MimePart::new_binary(
1646 "application/octet-stream",
1647 &[0, 1, 2, 3],
1648 Some(&AttachmentOptions {
1649 file_name: Some("woot.bin".into()),
1650 inline: false,
1651 content_id: Some("woot.id".into()),
1652 }),
1653 )
1654 .unwrap(),
1655 ],
1656 Some(b"my-boundary"),
1657 )
1658 .unwrap();
1659 k9::snapshot!(
1660 BString::from(msg.to_message_bytes()),
1661 r#"
1662Content-Type: multipart/mixed;\r
1663\tboundary="my-boundary"\r
1664\r
1665--my-boundary\r
1666Content-Type: text/plain;\r
1667\tcharset="us-ascii"\r
1668\r
1669plain text\r
1670--my-boundary\r
1671Content-Type: text/html;\r
1672\tcharset="us-ascii"\r
1673\r
1674<b>rich</b> text\r
1675--my-boundary\r
1676Content-Disposition: attachment;\r
1677\tfilename="woot.bin"\r
1678Content-ID: <woot.id>\r
1679Content-Type: application/octet-stream;\r
1680\tname="woot.bin"\r
1681Content-Transfer-Encoding: base64\r
1682\r
1683AAECAw==\r
1684--my-boundary--\r
1685
1686"#
1687 );
1688
1689 k9::snapshot!(
1690 msg.simplified_structure_pointers(),
1691 "
1692Ok(
1693 SimplifiedStructurePointers {
1694 text_part: Some(
1695 PartPointer(
1696 [
1697 0,
1698 ],
1699 ),
1700 ),
1701 html_part: Some(
1702 PartPointer(
1703 [
1704 1,
1705 ],
1706 ),
1707 ),
1708 amp_html_part: None,
1709 header_part: PartPointer(
1710 [],
1711 ),
1712 attachments: [
1713 PartPointer(
1714 [
1715 2,
1716 ],
1717 ),
1718 ],
1719 },
1720)
1721"
1722 );
1723 }
1724
1725 #[test]
1726 fn attachment_name_order_prefers_content_disposition() {
1727 let message = concat!(
1728 "Content-Type: multipart/mixed;\r\n",
1729 " boundary=\"woot\"\r\n",
1730 "\r\n",
1731 "--woot\r\n",
1732 "Content-Type: text/plain;\r\n",
1733 " charset=\"us-ascii\"\r\n",
1734 "\r\n",
1735 "Hello, I am the main message content\r\n",
1736 "--woot\r\n",
1737 "Content-Disposition: attachment;\r\n",
1738 " filename=cdname\r\n",
1739 "Content-Type: application/octet-stream;\r\n",
1740 " name=ctname\r\n",
1741 "Content-Transfer-Encoding: base64\r\n",
1742 "\r\n",
1743 "u6o=\r\n",
1744 "--woot--\r\n"
1745 );
1746 let part = MimePart::parse(message).unwrap();
1747 let structure = part.simplified_structure().unwrap();
1748
1749 k9::assert_equal!(
1750 structure.attachments[0].rfc2045_info().attachment_options,
1751 Some(AttachmentOptions {
1752 content_id: None,
1753 inline: false,
1754 file_name: Some("cdname".into()),
1755 })
1756 );
1757 }
1758
1759 #[test]
1760 fn attachment_name_accepts_content_type_name() {
1761 let message = concat!(
1762 "Content-Type: multipart/mixed;\r\n",
1763 " boundary=\"woot\"\r\n",
1764 "\r\n",
1765 "--woot\r\n",
1766 "Content-Type: text/plain;\r\n",
1767 " charset=\"us-ascii\"\r\n",
1768 "\r\n",
1769 "Hello, I am the main message content\r\n",
1770 "--woot\r\n",
1771 "Content-Disposition: attachment\r\n",
1772 "Content-Type: application/octet-stream;\r\n",
1773 " name=ctname\r\n",
1774 "Content-Transfer-Encoding: base64\r\n",
1775 "\r\n",
1776 "u6o=\r\n",
1777 "--woot--\r\n"
1778 );
1779 let part = MimePart::parse(message).unwrap();
1780 let structure = part.simplified_structure().unwrap();
1781
1782 k9::assert_equal!(
1783 structure.attachments[0].rfc2045_info().attachment_options,
1784 Some(AttachmentOptions {
1785 content_id: None,
1786 inline: false,
1787 file_name: Some("ctname".into()),
1788 })
1789 );
1790 }
1791
1792 #[test]
1793 fn funky_headers() {
1794 let message = concat!(
1795 "Subject\r\n",
1796 "Other:\r\n",
1797 "Content-Type: multipart/alternative; boundary=foobar\r\n",
1798 "Mime-Version: 1.0\r\n",
1799 "Date: Sun, 02 Oct 2016 07:06:22 -0700 (PDT)\r\n",
1800 "\r\n",
1801 "The body.\r\n"
1802 );
1803
1804 let part = MimePart::parse(message).unwrap();
1805 assert!(part
1806 .conformance()
1807 .contains(MessageConformance::MISSING_COLON_VALUE));
1808 }
1809
1810 #[test]
1815 fn rebuild_binary() {
1816 let expect = &[0, 1, 2, 3, 0xbe, 4, 5];
1817 let part = MimePart::new_binary("applicat/octet-stream", expect, None).unwrap();
1818
1819 let rebuilt = part.rebuild(None).unwrap();
1820 let body = rebuilt.body().unwrap();
1821
1822 assert_eq!(body, DecodedBody::Binary(expect.to_vec()));
1823 }
1824
1825 #[test]
1828 fn rebuild_invitation() {
1829 let message = concat!(
1830 "Subject: Test for events 2\r\n",
1831 "Content-Type: multipart/mixed;\r\n",
1832 " boundary=8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1833 "\r\n",
1834 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1835 "Content-Type: multipart/alternative;\r\n",
1836 " boundary=a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1837 "\r\n",
1838 "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1839 "Content-Transfer-Encoding: quoted-printable\r\n",
1840 "Content-Type: text/plain; charset=UTF-8\r\n",
1841 "\r\n",
1842 "This is a test for calendar event invitation\r\n",
1843 "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r\n",
1844 "Content-Transfer-Encoding: quoted-printable\r\n",
1845 "Content-Type: text/html; charset=UTF-8\r\n",
1846 "\r\n",
1847 "<p>This is a test for calendar event invitation</p>\r\n",
1848 "--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f--\r\n",
1849 "\r\n",
1850 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1851 "Content-Disposition: inline; name=\"Invitation.ics\"\r\n",
1852 "Content-Type: text/calendar; method=REQUEST; name=\"Invitation.ics\"\r\n",
1853 "\r\n",
1854 "Invitation\r\n",
1855 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r\n",
1856 "Content-Disposition: attachment; filename=\"event.ics\"\r\n",
1857 "Content-Type: application/ics\r\n",
1858 "\r\n",
1859 "Event\r\n",
1860 "--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3--\r\n",
1861 "\r\n"
1862 );
1863
1864 let part = MimePart::parse(message).unwrap();
1865 let rebuilt = part.rebuild(None).unwrap();
1866
1867 k9::snapshot!(
1868 BString::from(rebuilt.to_message_bytes()),
1869 r#"
1870Content-Type: multipart/mixed;\r
1871\tboundary="8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3"\r
1872Subject: Test for events 2\r
1873\r
1874--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1875Content-Type: multipart/alternative;\r
1876\tboundary="a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f"\r
1877\r
1878--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r
1879Content-Type: text/plain;\r
1880\tcharset="us-ascii"\r
1881\r
1882This is a test for calendar event invitation\r
1883--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f\r
1884Content-Type: text/html;\r
1885\tcharset="us-ascii"\r
1886\r
1887<p>This is a test for calendar event invitation</p>\r
1888--a4e0aff9e05c7d94e2e13bd5590302f7802daac1e952c065207790d15a9f--\r
1889--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1890Content-Type: text/calendar;\r
1891\tcharset="us-ascii";\r
1892\tmethod="REQUEST";\r
1893\tname="Invitation.ics"\r
1894\r
1895Invitation\r
1896--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3\r
1897Content-Disposition: attachment;\r
1898\tfilename="event.ics"\r
1899Content-Type: application/ics;\r
1900\tname="event.ics"\r
1901Content-Transfer-Encoding: base64\r
1902\r
1903RXZlbnQNCg==\r
1904--8a54d64d7ad7c04a084478052b36cbe1609b33bf3a41203aaee8dd642cd3--\r
1905
1906"#
1907 );
1908 }
1909
1910 #[test]
1911 fn check_conformance_angle_msg_id() {
1912 const DOUBLE_ANGLE_ONLY: &str = "Subject: hello\r
1913Message-ID: <<1234@example.com>>\r
1914\r
1915Hello";
1916 let msg = MimePart::parse(DOUBLE_ANGLE_ONLY).unwrap();
1917 k9::snapshot!(
1918 msg.check_fix_conformance(
1919 MessageConformance::MISSING_MESSAGE_ID_HEADER,
1920 MessageConformance::empty(),
1921 CheckFixSettings::default(),
1922 )
1923 .unwrap_err()
1924 .to_string(),
1925 "Message has conformance issues: MISSING_MESSAGE_ID_HEADER"
1926 );
1927
1928 let rebuilt = BString::from(
1929 msg.check_fix_conformance(
1930 MessageConformance::MISSING_MESSAGE_ID_HEADER,
1931 MessageConformance::MISSING_MESSAGE_ID_HEADER,
1932 CheckFixSettings {
1933 message_id: Some("id@example.com".to_string()),
1934 ..Default::default()
1935 },
1936 )
1937 .unwrap()
1938 .unwrap()
1939 .to_message_bytes(),
1940 );
1941
1942 k9::snapshot!(
1943 rebuilt,
1944 r#"
1945Subject: hello\r
1946Message-ID: <id@example.com>\r
1947\r
1948Hello
1949"#
1950 );
1951
1952 const DOUBLE_ANGLE_AND_LONG_LINE: &str = "Subject: hello\r
1953Message-ID: <<1234@example.com>>\r
1954\r
1955Hello this is a really long line Hello this is a really long line \
1956Hello this is a really long line Hello this is a really long line \
1957Hello this is a really long line Hello this is a really long line \
1958Hello this is a really long line Hello this is a really long line \
1959Hello this is a really long line Hello this is a really long line \
1960Hello this is a really long line Hello this is a really long line \
1961Hello this is a really long line Hello this is a really long line
1962";
1963 let msg = MimePart::parse(DOUBLE_ANGLE_AND_LONG_LINE).unwrap();
1964 let rebuilt = BString::from(
1965 msg.check_fix_conformance(
1966 MessageConformance::MISSING_COLON_VALUE,
1967 MessageConformance::MISSING_MESSAGE_ID_HEADER | MessageConformance::LINE_TOO_LONG,
1968 CheckFixSettings {
1969 message_id: Some("id@example.com".to_string()),
1970 ..Default::default()
1971 },
1972 )
1973 .unwrap()
1974 .unwrap()
1975 .to_message_bytes(),
1976 );
1977
1978 k9::snapshot!(
1979 rebuilt,
1980 r#"
1981Content-Type: text/plain;\r
1982\tcharset="us-ascii"\r
1983Content-Transfer-Encoding: quoted-printable\r
1984Subject: hello\r
1985Message-ID: <id@example.com>\r
1986\r
1987Hello this is a really long line Hello this is a really long line Hello thi=\r
1988s is a really long line Hello this is a really long line Hello this is a re=\r
1989ally long line Hello this is a really long line Hello this is a really long=\r
1990 line Hello this is a really long line Hello this is a really long line Hel=\r
1991lo this is a really long line Hello this is a really long line Hello this i=\r
1992s a really long line Hello this is a really long line Hello this is a reall=\r
1993y long line=0A\r
1994
1995"#
1996 );
1997 }
1998
1999 #[test]
2000 fn check_conformance() {
2001 const MULTI_HEADER_CONTENT: &str =
2002 "X-Hello: there\r\nX-Header: value\r\nSubject: Hello\r\nX-Header: another value\r\nFrom :Someone@somewhere\r\n\r\nBody";
2003
2004 let msg = MimePart::parse(MULTI_HEADER_CONTENT).unwrap();
2005 let rebuilt = BString::from(
2006 msg.check_fix_conformance(
2007 MessageConformance::default(),
2008 MessageConformance::MISSING_MIME_VERSION,
2009 CheckFixSettings::default(),
2010 )
2011 .unwrap()
2012 .unwrap()
2013 .to_message_bytes(),
2014 );
2015 k9::snapshot!(
2016 rebuilt,
2017 r#"
2018X-Hello: there\r
2019X-Header: value\r
2020Subject: Hello\r
2021X-Header: another value\r
2022From :Someone@somewhere\r
2023Mime-Version: 1.0\r
2024\r
2025Body
2026"#
2027 );
2028
2029 let msg = MimePart::parse(MULTI_HEADER_CONTENT).unwrap();
2030 let rebuilt = BString::from(
2031 msg.check_fix_conformance(
2032 MessageConformance::default(),
2033 MessageConformance::MISSING_MIME_VERSION | MessageConformance::NAME_ENDS_WITH_SPACE,
2034 CheckFixSettings::default(),
2035 )
2036 .unwrap()
2037 .unwrap()
2038 .to_message_bytes(),
2039 );
2040 k9::snapshot!(
2041 rebuilt,
2042 r#"
2043Content-Type: text/plain;\r
2044\tcharset="us-ascii"\r
2045X-Hello: there\r
2046X-Header: value\r
2047Subject: Hello\r
2048X-Header: another value\r
2049From: <Someone@somewhere>\r
2050Mime-Version: 1.0\r
2051\r
2052Body\r
2053
2054"#
2055 );
2056 }
2057
2058 #[test]
2059 fn check_fix_latin_input() {
2060 const POUNDS: &[u8] = b"Subject: \xa3\r\n\r\nGBP\r\n";
2061 let msg = MimePart::parse(POUNDS).unwrap();
2062 assert_eq!(
2063 msg.conformance(),
2064 MessageConformance::NEEDS_TRANSFER_ENCODING
2065 | MessageConformance::MISSING_DATE_HEADER
2066 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2067 | MessageConformance::MISSING_MIME_VERSION
2068 );
2069 let rebuilt = msg
2070 .check_fix_conformance(
2071 MessageConformance::default(),
2072 MessageConformance::NEEDS_TRANSFER_ENCODING,
2073 CheckFixSettings {
2074 detect_encoding: true,
2075 include_encodings: vec!["iso-8859-1".to_string()],
2076 data_bytes: Some(Arc::new(POUNDS.into())),
2077 ..Default::default()
2078 },
2079 )
2080 .unwrap()
2081 .unwrap();
2082
2083 let subject = rebuilt.headers.subject().unwrap().unwrap();
2084 assert_eq!(subject, "£");
2085 }
2086
2087 #[test]
2094 fn check_fix_utf8_inside_transfer_encoding() {
2095 const CONTENT: &str = "Subject: hello\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\n2KrYs9iqDQoNCg==\r\n";
2096
2097 let msg = MimePart::parse(CONTENT).unwrap();
2098
2099 assert_eq!(
2102 msg.conformance(),
2103 MessageConformance::MISSING_DATE_HEADER
2104 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2105 | MessageConformance::MISSING_MIME_VERSION
2106 );
2107
2108 assert_eq!(
2110 msg.deep_conformance_check(),
2111 MessageConformance::NEEDS_TRANSFER_ENCODING
2112 | MessageConformance::MISSING_DATE_HEADER
2113 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2114 | MessageConformance::MISSING_MIME_VERSION
2115 );
2116 let rebuilt = msg
2117 .check_fix_conformance(
2118 MessageConformance::default(),
2119 MessageConformance::NEEDS_TRANSFER_ENCODING,
2120 CheckFixSettings::default(),
2121 )
2122 .unwrap()
2123 .unwrap();
2124
2125 eprintln!("{rebuilt:?}");
2126 assert_eq!(rebuilt.body().unwrap().to_string_lossy().trim(), "تست");
2127 }
2128
2129 #[test]
2130 fn check_fix_latin1_inside_transfer_encoding() {
2131 const CONTENT: &str = "Subject: hello\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\nVGhlIGNvc3QgaXMgozQyLjAwCg==\r\n";
2132
2133 let msg = MimePart::parse(CONTENT).unwrap();
2134
2135 assert_eq!(
2138 msg.conformance(),
2139 MessageConformance::MISSING_DATE_HEADER
2140 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2141 | MessageConformance::MISSING_MIME_VERSION
2142 );
2143
2144 assert_eq!(
2146 msg.deep_conformance_check(),
2147 MessageConformance::NEEDS_TRANSFER_ENCODING
2148 | MessageConformance::MISSING_DATE_HEADER
2149 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2150 | MessageConformance::MISSING_MIME_VERSION
2151 );
2152 let rebuilt = msg
2153 .check_fix_conformance(
2154 MessageConformance::default(),
2155 MessageConformance::NEEDS_TRANSFER_ENCODING,
2156 CheckFixSettings {
2157 detect_encoding: true,
2158 include_encodings: vec!["iso-8859-1".to_string()],
2159 ..Default::default()
2160 },
2161 )
2162 .unwrap()
2163 .unwrap();
2164
2165 eprintln!("{rebuilt:?}");
2166 assert_eq!(
2167 rebuilt.body().unwrap().to_string_lossy().trim(),
2168 "The cost is £42.00"
2169 );
2170 }
2171
2172 #[test]
2173 fn check_fix_unknown_inside_transfer_encoding() {
2174 const CONTENT: &str = "Subject: hello\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\nowo=\r\n";
2179
2180 let msg = MimePart::parse(CONTENT).unwrap();
2181
2182 assert_eq!(
2185 msg.conformance(),
2186 MessageConformance::MISSING_DATE_HEADER
2187 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2188 | MessageConformance::MISSING_MIME_VERSION
2189 );
2190
2191 assert_eq!(
2193 msg.deep_conformance_check(),
2194 MessageConformance::NEEDS_TRANSFER_ENCODING
2195 | MessageConformance::MISSING_DATE_HEADER
2196 | MessageConformance::MISSING_MESSAGE_ID_HEADER
2197 | MessageConformance::MISSING_MIME_VERSION
2198 );
2199 let rebuilt = msg
2200 .check_fix_conformance(
2201 MessageConformance::default(),
2202 MessageConformance::NEEDS_TRANSFER_ENCODING,
2203 CheckFixSettings {
2204 detect_encoding: true,
2205 include_encodings: vec!["iso-8859-1".to_string()],
2206 ..Default::default()
2207 },
2208 )
2209 .unwrap()
2210 .unwrap();
2211
2212 eprintln!("{rebuilt:?}");
2213 assert_eq!(rebuilt.body().unwrap().to_string_lossy().trim(), "�");
2214 }
2215
2216 #[test]
2217 fn nested_multipart_mixed_related() {
2218 let message = concat!(
2220 "MIME-Version: 1.0\r\n",
2221 "Content-Type: multipart/mixed;\r\n",
2222 "\tboundary=\"----=_Part_602641_1899404624.1775349148919\"\r\n",
2223 "\r\n",
2224 "------=_Part_602641_1899404624.1775349148919\r\n",
2225 "Content-Type: multipart/related;\r\n",
2226 "\tboundary=\"----=_Part_602642_1070442961.1775349148920\"\r\n",
2227 "\r\n",
2228 "------=_Part_602642_1070442961.1775349148920\r\n",
2229 "Content-Type: text/html;charset=UTF-8\r\n",
2230 "Content-Transfer-Encoding: quoted-printable\r\n",
2231 "\r\n",
2232 "<html><body>Test HTML</body></html>\r\n",
2233 "------=_Part_602642_1070442961.1775349148920\r\n",
2234 "Content-Type: image/png; name=inline\r\n",
2235 "Content-Transfer-Encoding: base64\r\n",
2236 "Content-Disposition: inline; filename=inline\r\n",
2237 "Content-ID: <dell-aiops>\r\n",
2238 "\r\n",
2239 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\r\n",
2240 "------=_Part_602642_1070442961.1775349148920--\r\n",
2241 "------=_Part_602641_1899404624.1775349148919--\r\n"
2242 );
2243
2244 let root = MimePart::parse(message).unwrap();
2245
2246 fn ct(p: &MimePart) -> String {
2248 p.headers()
2249 .content_type()
2250 .unwrap()
2251 .unwrap()
2252 .value
2253 .to_string()
2254 }
2255
2256 assert_eq!(ct(&root), "multipart/mixed");
2257
2258 let [related_part] = &root.child_parts()[..] else {
2260 panic!("root must have one child")
2261 };
2262 assert_eq!(ct(related_part), "multipart/related");
2263
2264 let [html_part, image_part] = &related_part.child_parts()[..] else {
2266 panic!("related part must have two children")
2267 };
2268
2269 assert_eq!(ct(html_part), "text/html");
2271 assert_eq!(ct(image_part), "image/png");
2272
2273 let simplified = root.simplified_structure().unwrap();
2275 let DecodedBody::Text(html) = html_part.body().unwrap() else {
2276 panic!("must be text")
2277 };
2278 assert_eq!(
2279 simplified,
2280 SimplifiedStructure {
2281 text: None,
2282 html: Some(html),
2283 amp_html: None,
2284 headers: &root.headers(),
2285 attachments: vec![image_part.clone()],
2286 }
2287 );
2288 }
2289
2290 #[test]
2295 fn check_trailing_bits_test() {
2296 let decoded = BASE64_RFC2045.decode(b"aHRtbD4NCi==").unwrap();
2299 assert_eq!(decoded, b"html>\r\n");
2300 }
2301}