1use std::{env, fs, path::PathBuf};
6
7use async_recursion::async_recursion;
8use mail_builder::MessageBuilder;
9use mail_parser::{Message, MessageParser, MessagePart, MimeHeaders, PartType};
10use nanohtml2text::html2text;
11#[allow(unused_imports)]
12use tracing::{debug, trace, warn};
13
14#[cfg(feature = "pgp")]
15use crate::pgp::Pgp;
16use crate::{Error, Result};
17
18use super::{
19 MULTIPART_BEGIN, MULTIPART_BEGIN_ESCAPED, MULTIPART_END, MULTIPART_END_ESCAPED, PART_BEGIN,
20 PART_BEGIN_ESCAPED, PART_END, PART_END_ESCAPED,
21};
22
23#[derive(Clone, Debug, Default, Eq, PartialEq)]
25pub enum FilterParts {
26 #[default]
30 All,
31
32 Only(String),
35
36 Include(Vec<String>),
41
42 Exclude(Vec<String>),
47}
48
49impl FilterParts {
50 pub fn only(&self, that: impl AsRef<str>) -> bool {
51 match self {
52 Self::All => false,
53 Self::Only(this) => this == that.as_ref(),
54 Self::Include(_) => false,
55 Self::Exclude(_) => false,
56 }
57 }
58
59 pub fn contains(&self, that: impl ToString + AsRef<str>) -> bool {
60 match self {
61 Self::All => true,
62 Self::Only(this) => this == that.as_ref(),
63 Self::Include(this) => this.contains(&that.to_string()),
64 Self::Exclude(this) => !this.contains(&that.to_string()),
65 }
66 }
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct MimeBodyInterpreter {
75 show_multiparts: bool,
89
90 show_parts: bool,
104
105 show_attachments: bool,
111
112 show_inline_attachments: bool,
118
119 filter_parts: FilterParts,
127
128 show_plain_texts_signature: bool,
133
134 save_attachments: bool,
144
145 save_attachments_dir: PathBuf,
151
152 #[cfg(feature = "pgp")]
153 pgp: Option<Pgp>,
154 #[cfg(feature = "pgp")]
155 pgp_sender: Option<String>,
156 #[cfg(feature = "pgp")]
157 pgp_recipient: Option<String>,
158}
159
160impl Default for MimeBodyInterpreter {
161 fn default() -> Self {
162 Self {
163 show_multiparts: false,
164 show_parts: true,
165 show_attachments: true,
166 show_inline_attachments: true,
167 filter_parts: Default::default(),
168 show_plain_texts_signature: true,
169 save_attachments: Default::default(),
170 save_attachments_dir: Self::default_save_attachments_dir(),
171 #[cfg(feature = "pgp")]
172 pgp: Default::default(),
173 #[cfg(feature = "pgp")]
174 pgp_sender: Default::default(),
175 #[cfg(feature = "pgp")]
176 pgp_recipient: Default::default(),
177 }
178 }
179}
180
181impl MimeBodyInterpreter {
182 pub fn default_save_attachments_dir() -> PathBuf {
183 env::temp_dir()
184 }
185
186 pub fn new() -> Self {
187 Self::default()
188 }
189
190 pub fn with_show_multiparts(mut self, visibility: bool) -> Self {
191 self.show_multiparts = visibility;
192 self
193 }
194
195 pub fn with_show_parts(mut self, visibility: bool) -> Self {
196 self.show_parts = visibility;
197 self
198 }
199
200 pub fn with_filter_parts(mut self, filter: FilterParts) -> Self {
201 self.filter_parts = filter;
202 self
203 }
204
205 pub fn with_show_plain_texts_signature(mut self, visibility: bool) -> Self {
206 self.show_plain_texts_signature = visibility;
207 self
208 }
209
210 pub fn with_show_attachments(mut self, visibility: bool) -> Self {
211 self.show_attachments = visibility;
212 self
213 }
214
215 pub fn with_show_inline_attachments(mut self, visibility: bool) -> Self {
216 self.show_inline_attachments = visibility;
217 self
218 }
219
220 pub fn with_save_attachments(mut self, visibility: bool) -> Self {
221 self.save_attachments = visibility;
222 self
223 }
224
225 pub fn with_save_attachments_dir(mut self, dir: impl Into<PathBuf>) -> Self {
226 self.save_attachments_dir = dir.into();
227 self
228 }
229
230 #[cfg(feature = "pgp")]
231 pub fn set_pgp(&mut self, pgp: impl Into<Pgp>) {
232 self.pgp = Some(pgp.into());
233 }
234
235 #[cfg(feature = "pgp")]
236 pub fn with_pgp(mut self, pgp: impl Into<Pgp>) -> Self {
237 self.set_pgp(pgp);
238 self
239 }
240
241 #[cfg(feature = "pgp")]
242 pub fn set_some_pgp(&mut self, pgp: Option<impl Into<Pgp>>) {
243 self.pgp = pgp.map(Into::into);
244 }
245
246 #[cfg(feature = "pgp")]
247 pub fn with_some_pgp(mut self, pgp: Option<impl Into<Pgp>>) -> Self {
248 self.set_some_pgp(pgp);
249 self
250 }
251
252 #[cfg(feature = "pgp")]
253 pub fn with_pgp_sender(mut self, sender: Option<String>) -> Self {
254 self.pgp_sender = sender;
255 self
256 }
257
258 #[cfg(feature = "pgp")]
259 pub fn with_pgp_recipient(mut self, recipient: Option<String>) -> Self {
260 self.pgp_recipient = recipient;
261 self
262 }
263
264 fn escape_mml_markup(text: String) -> String {
267 text.replace(PART_BEGIN, PART_BEGIN_ESCAPED)
268 .replace(PART_END, PART_END_ESCAPED)
269 .replace(MULTIPART_BEGIN, MULTIPART_BEGIN_ESCAPED)
270 .replace(MULTIPART_END, MULTIPART_END_ESCAPED)
271 }
272
273 #[cfg(feature = "pgp")]
275 async fn decrypt_part(&self, encrypted_part: &MessagePart<'_>) -> Result<String> {
276 match &self.pgp {
277 None => {
278 debug!("cannot decrypt part: pgp not configured");
279 Ok(String::from_utf8_lossy(encrypted_part.contents()).to_string())
280 }
281 Some(pgp) => {
282 let recipient = self
283 .pgp_recipient
284 .as_ref()
285 .ok_or(Error::PgpDecryptMissingRecipientError)?;
286 let encrypted_bytes = encrypted_part.contents().to_owned();
287 let decrypted_part = pgp.decrypt(recipient, encrypted_bytes).await?;
288 let clear_part = MessageParser::new()
289 .parse(&decrypted_part)
290 .ok_or(Error::ParsePgpDecryptedPartError)?;
291 let tpl = self.interpret_msg(&clear_part).await?;
292 Ok(tpl)
293 }
294 }
295 }
296
297 #[cfg(feature = "pgp")]
299 async fn verify_msg(&self, msg: &Message<'_>, ids: &[usize]) -> Result<()> {
300 match &self.pgp {
301 None => {
302 debug!("cannot verify message: pgp not configured");
303 }
304 Some(pgp) => {
305 let signed_part = msg.part(ids[0]).unwrap();
306 let signed_part_bytes = msg.raw_message
307 [signed_part.raw_header_offset()..signed_part.raw_end_offset()]
308 .to_owned();
309
310 let signature_part = msg.part(ids[1]).unwrap();
311 let signature_bytes = signature_part.contents().to_owned();
312
313 let recipient = self
314 .pgp_recipient
315 .as_ref()
316 .ok_or(Error::PgpDecryptMissingRecipientError)?;
317 pgp.verify(recipient, signature_bytes, signed_part_bytes)
318 .await?;
319 }
320 };
321
322 Ok(())
323 }
324
325 fn interpret_attachment(&self, ctype: &str, part: &MessagePart, data: &[u8]) -> Result<String> {
326 let mut tpl = String::new();
327
328 if self.show_attachments && self.filter_parts.contains(ctype) {
329 let fname = self
330 .save_attachments_dir
331 .join(part.attachment_name().unwrap_or("noname"));
332
333 if self.save_attachments {
334 fs::write(&fname, data)
335 .map_err(|err| Error::WriteAttachmentError(err, fname.clone()))?;
336 }
337
338 let fname = fname.to_string_lossy();
339 tpl = format!("<#part type={ctype} filename=\"{fname}\"><#/part>\n");
340 }
341
342 Ok(tpl)
343 }
344
345 fn interpret_inline_attachment(
346 &self,
347 ctype: &str,
348 part: &MessagePart,
349 data: &[u8],
350 ) -> Result<String> {
351 let mut tpl = String::new();
352
353 if self.show_inline_attachments && self.filter_parts.contains(ctype) {
354 let ctype = get_ctype(part);
355 let fname = self.save_attachments_dir.join(
356 part.attachment_name()
357 .or(part.content_id())
358 .unwrap_or("noname"),
359 );
360
361 if self.save_attachments {
362 fs::write(&fname, data)
363 .map_err(|err| Error::WriteAttachmentError(err, fname.clone()))?;
364 }
365
366 let fname = fname.to_string_lossy();
367 tpl = format!("<#part type={ctype} disposition=inline filename=\"{fname}\"><#/part>\n");
368 }
369
370 Ok(tpl)
371 }
372
373 fn interpret_text(&self, ctype: &str, text: &str) -> String {
374 let mut tpl = String::new();
375
376 if self.filter_parts.contains(ctype) {
377 let text = text.replace('\r', "");
378 let text = Self::escape_mml_markup(text);
379
380 if !self.show_parts || self.filter_parts.only(ctype) {
381 tpl.push_str(&text);
382 } else {
383 tpl.push_str(&format!("<#part type={ctype}>\n"));
384 tpl.push_str(&text);
385 tpl.push_str("<#/part>\n");
386 }
387 }
388
389 tpl
390 }
391
392 fn interpret_text_plain(&self, plain: &str) -> String {
393 let mut tpl = String::new();
394
395 if self.filter_parts.contains("text/plain") {
396 let plain = plain.replace('\r', "");
397 let mut plain = Self::escape_mml_markup(plain);
398
399 if !self.show_plain_texts_signature {
400 plain = plain
401 .rsplit_once("-- \n")
402 .map(|(body, _signature)| body.to_owned())
403 .unwrap_or(plain);
404 }
405
406 tpl.push_str(&plain);
407 }
408
409 tpl
410 }
411
412 fn interpret_text_html(&self, html: &str) -> String {
413 let mut tpl = String::new();
414
415 if self.filter_parts.contains("text/html") {
416 if self.filter_parts.only("text/html") {
417 let html = html.replace('\r', "");
418 let html = Self::escape_mml_markup(html);
419 tpl.push_str(&html);
420 } else {
421 let html = html2text(&html);
422 let html = Self::escape_mml_markup(html);
423
424 if self.show_parts {
425 tpl.push_str("<#part type=text/html>\n");
426 }
427
428 tpl.push_str(&html);
429
430 if self.show_parts {
431 tpl.push_str("<#/part>\n");
432 }
433 }
434 }
435
436 tpl
437 }
438
439 #[async_recursion]
440 async fn interpret_part(&self, msg: &Message<'_>, part: &MessagePart<'_>) -> Result<String> {
441 let mut tpl = String::new();
442 let ctype = get_ctype(part);
443
444 match &part.body {
445 PartType::Text(plain) if ctype == "text/plain" => {
446 tpl.push_str(&self.interpret_text_plain(plain));
447 }
448 PartType::Text(text) => {
449 tpl.push_str(&self.interpret_text(&ctype, text));
450 }
451 PartType::Html(html) => {
452 tpl.push_str(&self.interpret_text_html(html));
453 }
454 PartType::Binary(data) => {
455 tpl.push_str(&self.interpret_attachment(&ctype, part, data)?);
456 }
457 PartType::InlineBinary(data) => {
458 tpl.push_str(&self.interpret_inline_attachment(&ctype, part, data)?);
459 }
460 PartType::Message(msg) => {
461 tpl.push_str(&self.interpret_msg(msg).await?);
462 }
463 PartType::Multipart(ids) if ctype == "multipart/alternative" => {
464 let mut parts = ids.iter().filter_map(|id| msg.part(*id));
465
466 let part = match &self.filter_parts {
467 FilterParts::All => {
468 let part = parts
469 .clone()
470 .find_map(|part| match &part.body {
471 PartType::Text(plain)
472 if is_plain(part) && !plain.trim().is_empty() =>
473 {
474 Some(Ok(self.interpret_text_plain(plain)))
475 }
476 _ => None,
477 })
478 .or_else(|| {
479 parts.clone().find_map(|part| match &part.body {
480 PartType::Html(html) if !html.trim().is_empty() => {
481 Some(Ok(self.interpret_text_html(html)))
482 }
483 _ => None,
484 })
485 })
486 .or_else(|| {
487 parts.clone().find_map(|part| {
488 let ctype = get_ctype(part);
489 match &part.body {
490 PartType::Text(text) if !text.trim().is_empty() => {
491 Some(Ok(self.interpret_text(&ctype, text)))
492 }
493 _ => None,
494 }
495 })
496 });
497
498 match part {
499 Some(part) => Some(part),
500 None => match parts.next() {
501 Some(part) => Some(self.interpret_part(msg, part).await),
502 None => None,
503 },
504 }
505 }
506 FilterParts::Only(ctype) => {
507 match parts
508 .clone()
509 .find(|part| get_ctype(part).starts_with(ctype))
510 {
511 Some(part) => Some(self.interpret_part(msg, part).await),
512 None => None,
513 }
514 }
515 FilterParts::Include(ctypes) => {
516 match parts.clone().find(|part| ctypes.contains(&get_ctype(part))) {
517 Some(part) => Some(self.interpret_part(msg, part).await),
518 None => None,
519 }
520 }
521 FilterParts::Exclude(ctypes) => {
522 match parts
523 .clone()
524 .find(|part| !ctypes.contains(&get_ctype(part)))
525 {
526 Some(part) => Some(self.interpret_part(msg, part).await),
527 None => None,
528 }
529 }
530 };
531
532 if let Some(part) = part {
533 tpl.push_str(&part?);
534 }
535 }
536 #[cfg(feature = "pgp")]
537 PartType::Multipart(ids) if ctype == "multipart/encrypted" => {
538 match self.decrypt_part(msg.part(ids[1]).unwrap()).await {
539 Ok(ref clear_part) => tpl.push_str(clear_part),
540 Err(err) => {
541 debug!("cannot decrypt email part using pgp: {err}");
542 trace!("{err:?}");
543 }
544 }
545 }
546 #[cfg(feature = "pgp")]
547 PartType::Multipart(ids) if ctype == "multipart/signed" => {
548 match self.verify_msg(msg, ids).await {
549 Ok(()) => {
550 debug!("email part successfully verified using pgp");
551 }
552 Err(err) => {
553 debug!("cannot verify email part using pgp: {err}");
554 trace!("{err:?}");
555 }
556 }
557
558 let signed_part = msg.part(ids[0]).unwrap();
559 let clear_part = &self.interpret_part(msg, signed_part).await?;
560 tpl.push_str(clear_part);
561 }
562 PartType::Multipart(_) if ctype == "application/pgp-encrypted" => {
563 }
565 PartType::Multipart(_) if ctype == "application/pgp-signature" => {
566 }
568 PartType::Multipart(ids) => {
569 if self.show_multiparts {
570 let stype = part
571 .content_type()
572 .and_then(|p| p.subtype())
573 .unwrap_or("mixed");
574 tpl.push_str(&format!("<#multipart type={stype}>\n"));
575 }
576
577 for id in ids {
578 if let Some(part) = msg.part(*id) {
579 tpl.push_str(&self.interpret_part(msg, part).await?);
580 } else {
581 debug!("cannot find part {id}, skipping it");
582 }
583 }
584
585 if self.show_multiparts {
586 tpl.push_str("<#/multipart>\n");
587 }
588 }
589 }
590
591 Ok(tpl)
592 }
593
594 pub async fn interpret_msg<'a>(&self, msg: &Message<'a>) -> Result<String> {
596 self.interpret_part(msg, msg.root_part()).await
597 }
598
599 pub async fn interpret_bytes<'a>(&self, bytes: impl AsRef<[u8]> + 'a) -> Result<String> {
602 let msg = MessageParser::new()
603 .parse(bytes.as_ref())
604 .ok_or(Error::ParseMimeMessageError)?;
605 self.interpret_msg(&msg).await
606 }
607
608 pub async fn interpret_msg_builder<'a>(&self, builder: MessageBuilder<'a>) -> Result<String> {
611 let bytes = builder.write_to_vec().map_err(Error::WriteMessageError)?;
612 self.interpret_bytes(&bytes).await
613 }
614}
615
616fn get_ctype(part: &MessagePart) -> String {
617 part.content_type()
618 .and_then(|ctype| {
619 ctype
620 .subtype()
621 .map(|stype| format!("{}/{stype}", ctype.ctype()))
622 })
623 .unwrap_or_else(|| String::from("application/octet-stream"))
624}
625
626fn is_plain(part: &MessagePart) -> bool {
627 get_ctype(part) == "text/plain"
628}
629
630#[cfg(test)]
631mod tests {
632 use concat_with::concat_line;
633 use mail_builder::{mime::MimePart, MessageBuilder};
634
635 use super::{FilterParts, MimeBodyInterpreter};
636
637 #[tokio::test]
638 async fn nested_multiparts() {
639 let builder = MessageBuilder::new().body(MimePart::new(
640 "multipart/mixed",
641 vec![
642 MimePart::new("text/plain", "This is a plain text part.\n"),
643 MimePart::new(
644 "multipart/related",
645 vec![
646 MimePart::new("text/plain", "\nThis is a second plain text part.\n\n"),
647 MimePart::new("text/plain", "This is a third plain text part.\n\n\n"),
648 ],
649 ),
650 ],
651 ));
652
653 let tpl = MimeBodyInterpreter::new()
654 .interpret_msg_builder(builder.clone())
655 .await
656 .unwrap();
657
658 let expected_tpl = concat_line!(
659 "This is a plain text part.",
660 "",
661 "This is a second plain text part.",
662 "",
663 "This is a third plain text part.",
664 "",
665 "",
666 "",
667 );
668
669 assert_eq!(tpl, expected_tpl);
670 }
671
672 #[tokio::test]
673 async fn nested_multiparts_with_markup() {
674 let builder = MessageBuilder::new().body(MimePart::new(
675 "multipart/mixed",
676 vec![
677 MimePart::new("text/plain", "This is a plain text part.\n\n"),
678 MimePart::new(
679 "multipart/related",
680 vec![
681 MimePart::new("text/plain", "This is a second plain text part.\n\n"),
682 MimePart::new("text/plain", "This is a third plain text part.\n\n"),
683 ],
684 ),
685 ],
686 ));
687
688 let tpl = MimeBodyInterpreter::new()
689 .with_show_multiparts(true)
690 .interpret_msg_builder(builder.clone())
691 .await
692 .unwrap();
693
694 let expected_tpl = concat_line!(
695 "<#multipart type=mixed>",
696 "This is a plain text part.",
697 "",
698 "<#multipart type=related>",
699 "This is a second plain text part.",
700 "",
701 "This is a third plain text part.",
702 "",
703 "<#/multipart>",
704 "<#/multipart>",
705 "",
706 );
707
708 assert_eq!(tpl, expected_tpl);
709 }
710
711 #[tokio::test]
712 async fn all_text() {
713 let builder = MessageBuilder::new().body(MimePart::new(
714 "multipart/mixed",
715 vec![
716 MimePart::new("text/plain", "This is a plain text part.\n\n"),
717 MimePart::new("text/html", "<h1>This is a <HTML> text part.</h1>\n"),
718 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
719 ],
720 ));
721
722 let tpl = MimeBodyInterpreter::new()
723 .interpret_msg_builder(builder.clone())
724 .await
725 .unwrap();
726
727 let expected_tpl = concat_line!(
728 "This is a plain text part.",
729 "",
730 "<#part type=text/html>",
731 "This is a <HTML> text part.\r",
732 "\r",
733 "<#/part>",
734 "<#part type=text/json>",
735 "{\"type\": \"This is a JSON text part.\"}",
736 "<#/part>",
737 "",
738 );
739
740 assert_eq!(tpl, expected_tpl);
741 }
742
743 #[tokio::test]
744 async fn only_text_plain() {
745 let builder = MessageBuilder::new().body(MimePart::new(
746 "multipart/mixed",
747 vec![
748 MimePart::new("text/plain", "This is a plain text part.\n"),
749 MimePart::new(
750 "text/html",
751 "<h1>This is a <HTML> text part.</h1>\n",
752 ),
753 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
754 ],
755 ));
756
757 let tpl = MimeBodyInterpreter::new()
758 .with_filter_parts(FilterParts::Only("text/plain".into()))
759 .interpret_msg_builder(builder.clone())
760 .await
761 .unwrap();
762
763 let expected_tpl = concat_line!("This is a plain text part.", "");
764
765 assert_eq!(tpl, expected_tpl);
766 }
767
768 #[tokio::test]
769 async fn only_text_html() {
770 let builder = MessageBuilder::new().body(MimePart::new(
771 "multipart/mixed",
772 vec![
773 MimePart::new("text/plain", "This is a plain text part.\n"),
774 MimePart::new(
775 "text/html",
776 "<h1>This is a <HTML> text part.</h1>\n",
777 ),
778 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
779 ],
780 ));
781
782 let tpl = MimeBodyInterpreter::new()
783 .with_filter_parts(FilterParts::Only("text/html".into()))
784 .interpret_msg_builder(builder.clone())
785 .await
786 .unwrap();
787
788 let expected_tpl = concat_line!("<h1>This is a <HTML> text part.</h1>", "");
789
790 assert_eq!(tpl, expected_tpl);
791 }
792
793 #[tokio::test]
794 async fn only_text_other() {
795 let builder = MessageBuilder::new().body(MimePart::new(
796 "multipart/mixed",
797 vec![
798 MimePart::new("text/plain", "This is a plain text part.\n"),
799 MimePart::new(
800 "text/html",
801 "<h1>This is a <HTML> text part.</h1>\n",
802 ),
803 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
804 ],
805 ));
806
807 let tpl = MimeBodyInterpreter::new()
808 .with_filter_parts(FilterParts::Only("text/json".into()))
809 .interpret_msg_builder(builder.clone())
810 .await
811 .unwrap();
812
813 let expected_tpl = concat_line!("{\"type\": \"This is a JSON text part.\"}", "");
814
815 assert_eq!(tpl, expected_tpl);
816 }
817
818 #[tokio::test]
819 async fn multipart_alternative_text_all_without_plain() {
820 let builder = MessageBuilder::new().body(MimePart::new(
821 "multipart/alternative",
822 vec![
823 MimePart::new("text/html", "<h1>This is a <HTML> text part.</h1>\n"),
824 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
825 ],
826 ));
827
828 let tpl = MimeBodyInterpreter::new()
829 .interpret_msg_builder(builder.clone())
830 .await
831 .unwrap();
832
833 let expected_tpl = concat_line!(
834 "<#part type=text/html>",
835 "This is a <HTML> text part.\r",
836 "\r",
837 "<#/part>",
838 ""
839 );
840
841 assert_eq!(tpl, expected_tpl);
842 }
843
844 #[tokio::test]
845 async fn multipart_alternative_text_all_with_empty_plain() {
846 let builder = MessageBuilder::new().body(MimePart::new(
847 "multipart/alternative",
848 vec![
849 MimePart::new("text/plain", " \n\n"),
850 MimePart::new("text/html", "<h1>This is a <HTML> text part.</h1>\n"),
851 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
852 ],
853 ));
854
855 let tpl = MimeBodyInterpreter::new()
856 .interpret_msg_builder(builder.clone())
857 .await
858 .unwrap();
859
860 let expected_tpl = concat_line!(
861 "<#part type=text/html>",
862 "This is a <HTML> text part.\r",
863 "\r",
864 "<#/part>",
865 ""
866 );
867
868 assert_eq!(tpl, expected_tpl);
869 }
870
871 #[tokio::test]
872 async fn multipart_alternative_text_all_without_plain_nor_html() {
873 let builder = MessageBuilder::new().body(MimePart::new(
874 "multipart/alternative",
875 vec![MimePart::new(
876 "text/json",
877 "{\"type\": \"This is a JSON text part.\"}\n",
878 )],
879 ));
880
881 let tpl = MimeBodyInterpreter::new()
882 .interpret_msg_builder(builder.clone())
883 .await
884 .unwrap();
885
886 let expected_tpl = concat_line!(
887 "<#part type=text/json>",
888 "{\"type\": \"This is a JSON text part.\"}",
889 "<#/part>",
890 ""
891 );
892
893 assert_eq!(tpl, expected_tpl);
894 }
895
896 #[tokio::test]
897 async fn multipart_alternative_text_all() {
898 let builder = MessageBuilder::new().body(MimePart::new(
899 "multipart/alternative",
900 vec![
901 MimePart::new("text/plain", "This is a plain text part.\n"),
902 MimePart::new(
903 "text/html",
904 "<h1>This is a <HTML> text part.</h1>\n",
905 ),
906 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
907 ],
908 ));
909
910 let tpl = MimeBodyInterpreter::new()
911 .interpret_msg_builder(builder.clone())
912 .await
913 .unwrap();
914
915 let expected_tpl = concat_line!("This is a plain text part.", "");
916
917 assert_eq!(tpl, expected_tpl);
918 }
919
920 #[tokio::test]
921 async fn multipart_alternative_text_html_only() {
922 let builder = MessageBuilder::new().body(MimePart::new(
923 "multipart/alternative",
924 vec![
925 MimePart::new("text/plain", "This is a plain text part.\n"),
926 MimePart::new(
927 "text/html",
928 "<h1>This is a <HTML> text part.</h1>\n",
929 ),
930 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
931 ],
932 ));
933
934 let tpl = MimeBodyInterpreter::new()
935 .with_filter_parts(FilterParts::Only("text/html".into()))
936 .interpret_msg_builder(builder.clone())
937 .await
938 .unwrap();
939
940 let expected_tpl = concat_line!("<h1>This is a <HTML> text part.</h1>", "");
941
942 assert_eq!(tpl, expected_tpl);
943 }
944
945 #[tokio::test]
946 async fn attachment() {
947 let builder = MessageBuilder::new().attachment(
948 "application/octet-stream",
949 "attachment.txt",
950 "Hello, world!".as_bytes(),
951 );
952
953 let tpl = MimeBodyInterpreter::new()
954 .with_save_attachments_dir("~/Downloads")
955 .interpret_msg_builder(builder)
956 .await
957 .unwrap();
958
959 let expected_tpl = concat_line!(
960 "<#part type=application/octet-stream filename=\"~/Downloads/attachment.txt\"><#/part>",
961 "",
962 );
963
964 assert_eq!(tpl, expected_tpl);
965 }
966
967 #[tokio::test]
968 async fn hide_parts_single_html() {
969 let builder = MessageBuilder::new().body(MimePart::new(
970 "text/html",
971 "<h1>This is a <HTML> text part.</h1>\n",
972 ));
973
974 let tpl = MimeBodyInterpreter::new()
975 .with_show_parts(false)
976 .interpret_msg_builder(builder.clone())
977 .await
978 .unwrap();
979
980 let expected_tpl = concat_line!("This is a <HTML> text part.\r", "\r", "");
981
982 assert_eq!(tpl, expected_tpl);
983 }
984
985 #[tokio::test]
986 async fn hide_parts_multipart_mixed() {
987 let builder = MessageBuilder::new().body(MimePart::new(
988 "multipart/mixed",
989 vec![
990 MimePart::new("text/plain", "This is a plain text part.\n"),
991 MimePart::new("text/html", "<h1>This is a <HTML> text part.</h1>\n"),
992 MimePart::new("text/json", "{\"type\": \"This is a JSON text part.\"}\n"),
993 ],
994 ));
995
996 let tpl = MimeBodyInterpreter::new()
997 .with_show_parts(false)
998 .with_filter_parts(FilterParts::Include(vec![
999 "text/plain".into(),
1000 "text/html".into(),
1001 ]))
1002 .interpret_msg_builder(builder.clone())
1003 .await
1004 .unwrap();
1005
1006 let expected_tpl = concat_line!(
1007 "This is a plain text part.",
1008 "This is a <HTML> text part.\r",
1009 "\r",
1010 "",
1011 );
1012
1013 assert_eq!(tpl, expected_tpl);
1014 }
1015}