mml/message/body/
interpreter.rs

1//! # MIME to MML message body interpretation module
2//!
3//! Module dedicated to MIME → MML message body interpretation.
4
5use 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/// Filters parts to show by MIME type.
24#[derive(Clone, Debug, Default, Eq, PartialEq)]
25pub enum FilterParts {
26    /// Show all parts. This filter enables MML markup since multiple
27    /// parts with different MIME types can be mixed together, which
28    /// can be hard to navigate through.
29    #[default]
30    All,
31
32    /// Show only parts matching the given MIME type. This filter
33    /// disables MML markup since only one MIME type is shown.
34    Only(String),
35
36    /// Show only parts matching the given list of MIME types. This
37    /// filter enables MML markup since multiple parts with different
38    /// MIME types can be mixed together, which can be hard to
39    /// navigate through.
40    Include(Vec<String>),
41
42    /// Show all parts except those matching the given list of MIME
43    /// types. This filter enables MML markup since multiple parts
44    /// with different MIME types can be mixed together, which can be
45    /// hard to navigate through.
46    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/// MIME → MML message body interpreter.
70///
71/// The interpreter follows the builder pattern, where the build function
72/// is named `interpret_*`.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct MimeBodyInterpreter {
75    /// Defines visibility of the multipart markup `<#multipart>`.
76    ///
77    /// When `true`, multipart markup is visible. This is useful when
78    /// you need to see multiparts nested structure.
79    ///
80    /// When `false`, multipart markup is hidden. The structure is
81    /// flatten, which means all parts and subparts are shown at the
82    /// same top level.
83    ///
84    /// This option shows or hides the multipart markup, not their
85    /// content. The content is always shown. To filter parts with
86    /// their content, see [`MimeBodyInterpreter::filter_parts`] and
87    /// [`FilterParts`].
88    show_multiparts: bool,
89
90    /// Defines visibility of the part markup `<#part>`.
91    ///
92    /// When `true`, part markup is visible. This is useful when you
93    /// want to get more information about parts being interpreted
94    /// (MIME type, description etc).
95    ///
96    /// When `false`, part markup is hidden. Only the content is
97    /// shown.
98    ///
99    /// This option shows or hides the part markup, not their
100    /// content. The content is always shown. To filter parts with
101    /// their content, see [`MimeBodyInterpreter::filter_parts`] and
102    /// [`FilterParts`].
103    show_parts: bool,
104
105    /// Defines visibility of the part markup `<#part
106    /// disposition=attachment>`.
107    ///
108    /// This option is dedicated to attachment parts, and it overrides
109    /// [`Self::show_parts`].
110    show_attachments: bool,
111
112    /// Defines visibility of the part markup `<#part
113    /// disposition=inline>`.
114    ///
115    /// This option is dedicated to inline attachment parts, and it
116    /// overrides [`Self::show_parts`].
117    show_inline_attachments: bool,
118
119    /// Defines parts visibility.
120    ///
121    /// This option filters parts to show or hide by their MIME
122    /// type. If you want to show or hide MML markup instead, see
123    /// [`Self::show_multiparts`], [`Self::show_parts`],
124    /// [`Self::show_attachments`] and
125    /// [`Self::show_inline_attachments`].
126    filter_parts: FilterParts,
127
128    /// Defines visibility of signatures in `text/plain` parts.
129    ///
130    /// When `false`, this option tries to remove signatures from
131    /// plain text parts starting by the standard delimiter `-- \n`.
132    show_plain_texts_signature: bool,
133
134    /// Defines the saving strategy of attachments content.
135    ///
136    /// An attachment is interpreted this way: `<#part
137    /// filename=attachment.ext>`.
138    ///
139    /// When `true`, the file (with its content) is automatically
140    /// created at the given filename. Directory can be customized via
141    /// [`Self::save_attachments_dir`]. This option is particularly
142    /// useful when transferring a message with its attachments.
143    save_attachments: bool,
144
145    /// Defines the directory for [`Self::save_attachments`] strategy.
146    ///
147    /// This option saves attachments to the given directory instead
148    /// of the default temporary one given by
149    /// [`std::env::temp_dir()`].
150    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    /// Replace normal opening and closing tags by escaped opening and
265    /// closing tags.
266    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    /// Decrypt the given [MessagePart] using PGP.
274    #[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    /// Verify the given [Message] using PGP.
298    #[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                // TODO: check if content matches "Version: 1"
564            }
565            PartType::Multipart(_) if ctype == "application/pgp-signature" => {
566                // nothing to do, signature already verified above
567            }
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    /// Interpret the given MIME [Message] as a MML message string.
595    pub async fn interpret_msg<'a>(&self, msg: &Message<'a>) -> Result<String> {
596        self.interpret_part(msg, msg.root_part()).await
597    }
598
599    /// Interpret the given MIME message bytes as a MML message
600    /// string.
601    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    /// Interpret the given MIME [MessageBuilder] as a MML message
609    /// string.
610    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 &lt;HTML&gt; 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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; 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 &lt;HTML&gt; 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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; text&nbsp;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 &lt;HTML&gt; 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 &lt;HTML&gt; 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}