mml/message/body/compiler/
mod.rs

1//! # MML to MIME message body compilation module
2//!
3//! Module dedicated to MML → MIME message body compilation.
4
5mod parsers;
6mod tokens;
7
8use std::{ffi::OsStr, fs, ops::Deref};
9
10use async_recursion::async_recursion;
11use mail_builder::{
12    mime::{BodyPart, MimePart},
13    MessageBuilder,
14};
15use shellexpand_utils::shellexpand_path;
16#[allow(unused_imports)]
17use tracing::{debug, warn};
18
19#[cfg(feature = "pgp")]
20use crate::pgp::Pgp;
21use crate::{Error, Result};
22
23use super::{
24    ALTERNATIVE, ATTACHMENT, DISPOSITION, ENCODING, ENCODING_7BIT, ENCODING_8BIT, ENCODING_BASE64,
25    ENCODING_QUOTED_PRINTABLE, FILENAME, INLINE, MIXED, MULTIPART_BEGIN, MULTIPART_BEGIN_ESCAPED,
26    MULTIPART_END, MULTIPART_END_ESCAPED, NAME, PART_BEGIN, PART_BEGIN_ESCAPED, PART_END,
27    PART_END_ESCAPED, RECIPIENT_FILENAME, RELATED, TYPE,
28};
29#[cfg(feature = "pgp")]
30use super::{ENCRYPT, PGP_MIME, SIGN};
31
32use self::{parsers::prelude::*, tokens::Part};
33
34/// MML → MIME message body compiler.
35///
36/// The compiler follows the builder pattern, where the build function
37/// is named `compile`.
38#[derive(Clone, Debug, Default, Eq, PartialEq)]
39pub struct MmlBodyCompiler {
40    #[cfg(feature = "pgp")]
41    pgp: Option<Pgp>,
42    #[cfg(feature = "pgp")]
43    pgp_sender: Option<String>,
44    #[cfg(feature = "pgp")]
45    pgp_recipients: Vec<String>,
46}
47
48impl<'a> MmlBodyCompiler {
49    /// Create a new MML message body compiler with default options.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    #[cfg(feature = "pgp")]
55    pub fn set_pgp(&mut self, pgp: impl Into<Pgp>) {
56        self.pgp = Some(pgp.into());
57    }
58
59    #[cfg(feature = "pgp")]
60    pub fn with_pgp(mut self, pgp: impl Into<Pgp>) -> Self {
61        self.set_pgp(pgp);
62        self
63    }
64
65    #[cfg(feature = "pgp")]
66    pub fn set_some_pgp(&mut self, pgp: Option<impl Into<Pgp>>) {
67        self.pgp = pgp.map(Into::into);
68    }
69
70    #[cfg(feature = "pgp")]
71    pub fn with_some_pgp(mut self, pgp: Option<impl Into<Pgp>>) -> Self {
72        self.set_some_pgp(pgp);
73        self
74    }
75
76    #[cfg(feature = "pgp")]
77    pub fn with_pgp_sender(mut self, sender: Option<String>) -> Self {
78        self.pgp_sender = sender;
79        self
80    }
81
82    #[cfg(feature = "pgp")]
83    pub fn with_pgp_recipients(mut self, recipients: Vec<String>) -> Self {
84        self.pgp_recipients = recipients;
85        self
86    }
87
88    /// Encrypt the given MIME part using PGP.
89    #[cfg(feature = "pgp")]
90    async fn encrypt_part(&self, clear_part: &MimePart<'a>) -> Result<MimePart<'a>> {
91        match &self.pgp {
92            None => {
93                debug!("cannot encrypt part: pgp not configured");
94                Ok(clear_part.clone())
95            }
96            Some(pgp) => {
97                let recipients = self.pgp_recipients.clone();
98
99                let mut clear_part_bytes = Vec::new();
100                clear_part
101                    .clone()
102                    .write_part(&mut clear_part_bytes)
103                    .map_err(Error::WriteCompiledPartToVecError)?;
104
105                let encrypted_part_bytes = pgp.encrypt(recipients, clear_part_bytes).await?;
106                let encrypted_part_bytes =
107                    encrypted_part_bytes
108                        .into_iter()
109                        .fold(Vec::new(), |mut part, b| {
110                            if b == b'\n' {
111                                part.push(b'\r');
112                                part.push(b'\n');
113                            } else {
114                                part.push(b);
115                            };
116                            part
117                        });
118                let encrypted_part = MimePart::new(
119                    "multipart/encrypted; protocol=\"application/pgp-encrypted\"",
120                    vec![
121                        MimePart::new("application/pgp-encrypted", "Version: 1"),
122                        MimePart::new("application/octet-stream", encrypted_part_bytes)
123                            .transfer_encoding("7bit"),
124                    ],
125                );
126
127                Ok(encrypted_part)
128            }
129        }
130    }
131
132    /// Try to encrypt the given MIME part using PGP.
133    ///
134    /// If the operation fails, log a warning and return the original
135    /// MIME part.
136    #[cfg(feature = "pgp")]
137    async fn try_encrypt_part(&self, clear_part: MimePart<'a>) -> MimePart<'a> {
138        match self.encrypt_part(&clear_part).await {
139            Ok(encrypted_part) => encrypted_part,
140            Err(err) => {
141                debug!("cannot encrypt email part using pgp: {err}");
142                debug!("{err:?}");
143                clear_part
144            }
145        }
146    }
147
148    /// Sign the given MIME part using PGP.
149    #[cfg(feature = "pgp")]
150    async fn sign_part(&self, clear_part: MimePart<'a>) -> Result<MimePart<'a>> {
151        match &self.pgp {
152            None => {
153                debug!("cannot sign part: pgp not configured");
154                Ok(clear_part.clone())
155            }
156            Some(pgp) => {
157                let sender = self
158                    .pgp_sender
159                    .as_ref()
160                    .ok_or(Error::PgpSignMissingSenderError)?;
161
162                let mut clear_part_bytes = Vec::new();
163                clear_part
164                    .clone()
165                    .write_part(&mut clear_part_bytes)
166                    .map_err(Error::WriteCompiledPartToVecError)?;
167
168                let signature_bytes = pgp.sign(sender, clear_part_bytes).await?;
169                let signature_bytes =
170                    signature_bytes.into_iter().fold(Vec::new(), |mut part, b| {
171                        if b == b'\n' {
172                            part.push(b'\r');
173                            part.push(b'\n');
174                        } else {
175                            part.push(b);
176                        };
177                        part
178                    });
179
180                let signed_part = MimePart::new(
181                    "multipart/signed; protocol=\"application/pgp-signature\"; micalg=\"pgp-sha256\"",
182                    vec![
183                        clear_part,
184                        MimePart::new("application/pgp-signature", signature_bytes)
185                            .transfer_encoding("7bit"),
186                    ],
187                );
188
189                Ok(signed_part)
190            }
191        }
192    }
193
194    /// Try to sign the given MIME part using PGP.
195    ///
196    /// If the operation fails, log a warning and return the original
197    /// MIME part.
198    #[cfg(feature = "pgp")]
199    async fn try_sign_part(&self, clear_part: MimePart<'a>) -> MimePart<'a> {
200        match self.sign_part(clear_part.clone()).await {
201            Ok(signed_part) => signed_part,
202            Err(err) => {
203                debug!("cannot sign email part using pgp: {err}");
204                debug!("{err:?}");
205                clear_part
206            }
207        }
208    }
209
210    /// Replace escaped opening and closing tags by normal opening and
211    /// closing tags.
212    fn unescape_mml_markup(text: impl AsRef<str>) -> String {
213        text.as_ref()
214            .replace(PART_BEGIN_ESCAPED, PART_BEGIN)
215            .replace(PART_END_ESCAPED, PART_END)
216            .replace(MULTIPART_BEGIN_ESCAPED, MULTIPART_BEGIN)
217            .replace(MULTIPART_END_ESCAPED, MULTIPART_END)
218    }
219
220    /// Compile given parts parsed from a MML body to a
221    /// [MessageBuilder].
222    async fn compile_parts(&'a self, parts: Vec<Part<'a>>) -> Result<MessageBuilder> {
223        let mut builder = MessageBuilder::new();
224
225        builder = match parts.len() {
226            0 => builder.text_body(String::new()),
227            1 => builder.body(self.compile_part(parts.into_iter().next().unwrap()).await?),
228            _ => {
229                let mut compiled_parts = Vec::new();
230
231                for part in parts {
232                    let part = self.compile_part(part).await?;
233                    compiled_parts.push(part);
234                }
235
236                builder.body(MimePart::new("multipart/mixed", compiled_parts))
237            }
238        };
239
240        Ok(builder)
241    }
242
243    /// Compile the given part parsed from MML body to a [MimePart].
244    #[async_recursion]
245    async fn compile_part(&'a self, part: Part<'a>) -> Result<MimePart> {
246        match part {
247            Part::Multi(props, parts) => {
248                let no_parts = BodyPart::Multipart(Vec::new());
249
250                let mut multi_part = match props.get(TYPE) {
251                    Some(&MIXED) | None => MimePart::new("multipart/mixed", no_parts),
252                    Some(&ALTERNATIVE) => MimePart::new("multipart/alternative", no_parts),
253                    Some(&RELATED) => MimePart::new("multipart/related", no_parts),
254                    Some(unknown) => {
255                        debug!("unknown multipart type {unknown}, falling back to mixed");
256                        MimePart::new("multipart/mixed", no_parts)
257                    }
258                };
259
260                for part in parts {
261                    multi_part.add_part(self.compile_part(part).await?)
262                }
263
264                #[cfg(feature = "pgp")]
265                {
266                    multi_part = match props.get(SIGN) {
267                        Some(&PGP_MIME) => self.try_sign_part(multi_part).await,
268                        _ => multi_part,
269                    };
270
271                    multi_part = match props.get(ENCRYPT) {
272                        Some(&PGP_MIME) => self.try_encrypt_part(multi_part).await,
273                        _ => multi_part,
274                    };
275                }
276
277                Ok(multi_part)
278            }
279            Part::Single(ref props, body) => {
280                let fpath = props.get(FILENAME).map(shellexpand_path);
281
282                let mut part = match &fpath {
283                    Some(fpath) => {
284                        let contents = fs::read(fpath)
285                            .map_err(|err| Error::ReadAttachmentError(err, fpath.clone()))?;
286                        let mut ctype = Part::get_or_guess_content_type(props, &contents).into();
287                        if let Some(name) = props.get(NAME) {
288                            ctype = ctype.attribute("name", *name);
289                        }
290                        MimePart::new(ctype, contents)
291                    }
292                    None => {
293                        let mut ctype =
294                            Part::get_or_guess_content_type(props, body.as_bytes()).into();
295                        if let Some(name) = props.get(NAME) {
296                            ctype = ctype.attribute("name", *name);
297                        }
298                        MimePart::new(ctype, body)
299                    }
300                };
301
302                part = match props.get(ENCODING) {
303                    Some(&ENCODING_7BIT) => part.transfer_encoding(ENCODING_7BIT),
304                    Some(&ENCODING_8BIT) => part.transfer_encoding(ENCODING_8BIT),
305                    Some(&ENCODING_QUOTED_PRINTABLE) => {
306                        part.transfer_encoding(ENCODING_QUOTED_PRINTABLE)
307                    }
308                    Some(&ENCODING_BASE64) => part.transfer_encoding(ENCODING_BASE64),
309                    _ => part,
310                };
311
312                part = match props.get(DISPOSITION) {
313                    Some(&INLINE) => part.inline(),
314                    Some(&ATTACHMENT) => part.attachment(
315                        props
316                            .get(RECIPIENT_FILENAME)
317                            .map(Deref::deref)
318                            .or_else(|| match &fpath {
319                                Some(fpath) => fpath.file_name().and_then(OsStr::to_str),
320                                None => None,
321                            })
322                            .unwrap_or("noname")
323                            .to_owned(),
324                    ),
325                    _ if fpath.is_some() => part.attachment(
326                        props
327                            .get(RECIPIENT_FILENAME)
328                            .map(ToString::to_string)
329                            .or_else(|| {
330                                fpath
331                                    .unwrap()
332                                    .file_name()
333                                    .and_then(OsStr::to_str)
334                                    .map(ToString::to_string)
335                            })
336                            .unwrap_or_else(|| "noname".to_string()),
337                    ),
338                    _ => part,
339                };
340
341                #[cfg(feature = "pgp")]
342                {
343                    part = match props.get(SIGN) {
344                        Some(&PGP_MIME) => self.try_sign_part(part).await,
345                        _ => part,
346                    };
347
348                    part = match props.get(ENCRYPT) {
349                        Some(&PGP_MIME) => self.try_encrypt_part(part).await,
350                        _ => part,
351                    };
352                };
353
354                Ok(part)
355            }
356            Part::PlainText(body) => {
357                let body = Self::unescape_mml_markup(body);
358                let part = MimePart::new("text/plain", body);
359                Ok(part)
360            }
361        }
362    }
363
364    /// Compile the given raw MML body to MIME body.
365    pub async fn compile(&'a self, mml_body: &'a str) -> Result<MessageBuilder> {
366        let res = parsers::parts().parse(mml_body);
367        if let Some(parts) = res.output() {
368            Ok(self.compile_parts(parts.to_owned()).await?)
369        } else {
370            let errs = res.errors().map(|err| err.clone().into_owned()).collect();
371            Err(Error::ParseMmlError(errs, mml_body.to_owned()))
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use concat_with::concat_line;
379    use std::io::prelude::*;
380    use tempfile::Builder;
381
382    use super::MmlBodyCompiler;
383
384    #[tokio::test]
385    async fn plain() {
386        let mml_body = concat_line!("Hello, world!", "");
387
388        let msg = MmlBodyCompiler::new()
389            .compile(mml_body)
390            .await
391            .unwrap()
392            .message_id("id@localhost")
393            .date(0_u64)
394            .write_to_string()
395            .unwrap();
396
397        let expected_msg = concat_line!(
398            "Message-ID: <id@localhost>\r",
399            "Date: Thu, 1 Jan 1970 00:00:00 +0000\r",
400            "MIME-Version: 1.0\r",
401            "Content-Type: text/plain; charset=\"utf-8\"\r",
402            "Content-Transfer-Encoding: 7bit\r",
403            "\r",
404            "Hello, world!\r",
405            "",
406        );
407
408        assert_eq!(msg, expected_msg);
409    }
410
411    #[tokio::test]
412    async fn html() {
413        let mml_body = concat_line!(
414            "<#part type=\"text/html\">",
415            "<h1>Hello, world!</h1>",
416            "<#/part>",
417        );
418
419        let msg = MmlBodyCompiler::new()
420            .compile(mml_body)
421            .await
422            .unwrap()
423            .message_id("id@localhost")
424            .date(0_u64)
425            .write_to_string()
426            .unwrap();
427
428        let expected_msg = concat_line!(
429            "Message-ID: <id@localhost>\r",
430            "Date: Thu, 1 Jan 1970 00:00:00 +0000\r",
431            "MIME-Version: 1.0\r",
432            "Content-Type: text/html; charset=\"utf-8\"\r",
433            "Content-Transfer-Encoding: 7bit\r",
434            "\r",
435            "<h1>Hello, world!</h1>\r",
436            "",
437        );
438
439        assert_eq!(msg, expected_msg);
440    }
441
442    #[tokio::test]
443    async fn attachment() {
444        let mut attachment = Builder::new()
445            .prefix("attachment")
446            .suffix(".txt")
447            .rand_bytes(0)
448            .tempfile()
449            .unwrap();
450        write!(attachment, "Hello, world!").unwrap();
451        let attachment_path = attachment.path().to_string_lossy();
452
453        let mml_body = format!(
454            "<#part filename={attachment_path} type=text/plain name=custom recipient-filename=/tmp/custom encoding=base64>discarded body<#/part>"
455        );
456
457        let msg = MmlBodyCompiler::new()
458            .compile(&mml_body)
459            .await
460            .unwrap()
461            .message_id("id@localhost")
462            .date(0_u64)
463            .write_to_string()
464            .unwrap();
465
466        let expected_msg = concat_line!(
467            "Message-ID: <id@localhost>\r",
468            "Date: Thu, 1 Jan 1970 00:00:00 +0000\r",
469            "MIME-Version: 1.0\r",
470            "Content-Type: text/plain; name=\"custom\"\r",
471            "Content-Transfer-Encoding: base64\r",
472            "Content-Disposition: attachment; filename=\"/tmp/custom\"\r",
473            "\r",
474            "Hello, world!",
475        );
476
477        assert_eq!(msg, expected_msg);
478    }
479}