email/email/message/
mod.rs

1//! Module dedicated to email messages.
2//!
3//! The message is the content of the email, which is composed of a
4//! header and a body.
5//!
6//! The core concept of this module is the [Message] structure, which
7//! is just wrapper around the [mail_parser::Message] struct.
8
9pub mod add;
10pub mod attachment;
11pub mod config;
12pub mod copy;
13pub mod delete;
14pub mod get;
15#[cfg(feature = "imap")]
16pub mod imap;
17pub mod r#move;
18pub mod peek;
19pub mod remove;
20pub mod send;
21#[cfg(feature = "sync")]
22pub mod sync;
23pub mod template;
24
25use std::{
26    borrow::Cow,
27    fs, io,
28    path::{Path, PathBuf},
29    sync::Arc,
30};
31
32#[cfg(feature = "imap")]
33use imap_client::imap_next::imap_types::{core::Vec1, fetch::MessageDataItem};
34use mail_parser::{MessageParser, MimeHeaders, PartType};
35#[cfg(feature = "maildir")]
36use maildirs::MaildirEntry;
37use mml::MimeInterpreterBuilder;
38use ouroboros::self_referencing;
39use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
40use template::Template;
41use tracing::debug;
42use uuid::Uuid;
43
44use self::{
45    attachment::Attachment,
46    template::{
47        forward::ForwardTemplateBuilder, new::NewTemplateBuilder, reply::ReplyTemplateBuilder,
48    },
49};
50use crate::{account::config::AccountConfig, email::error::Error};
51
52/// The message wrapper.
53#[self_referencing]
54pub struct Message<'a> {
55    bytes: Cow<'a, [u8]>,
56    #[borrows(mut bytes)]
57    #[covariant]
58    parsed: Option<mail_parser::Message<'this>>,
59}
60
61impl Message<'_> {
62    /// Builds an optional message from a raw message.
63    fn parsed_builder<'a>(bytes: &'a mut Cow<[u8]>) -> Option<mail_parser::Message<'a>> {
64        MessageParser::new().parse((*bytes).as_ref())
65    }
66
67    /// Returns the parsed version of the message.
68    pub fn parsed(&self) -> Result<&mail_parser::Message, Error> {
69        let msg = self
70            .borrow_parsed()
71            .as_ref()
72            .ok_or(Error::ParseEmailMessageError)?;
73        Ok(msg)
74    }
75
76    /// Returns the raw version of the message.
77    pub fn raw(&self) -> Result<&[u8], Error> {
78        self.parsed().map(|parsed| parsed.raw_message())
79    }
80
81    /// Downloads parts in the given destination.
82    pub fn download_parts(&self, dest: impl AsRef<Path>) -> Result<PathBuf, Error> {
83        let dest = dest.as_ref();
84        let dest = if dest.is_file() {
85            dest.parent().unwrap()
86        } else {
87            dest.as_ref()
88        };
89
90        #[derive(Default)]
91        struct Parts<'a> {
92            plain: String,
93            html: String,
94            content_ids: Vec<(&'a str, PathBuf)>,
95        }
96
97        let Parts {
98            mut plain,
99            mut html,
100            content_ids,
101        } = self
102            .parsed()?
103            .parts
104            .par_iter()
105            .try_fold(Parts::default, |mut output, part| {
106                match &part.body {
107                    PartType::Text(text) => {
108                        if let Some(header) = part.content_type() {
109                            let ctype = header.ctype();
110                            if let Some(stype) = header.subtype() {
111                                if !stype.eq_ignore_ascii_case("plain") {
112                                    let mtype = format!("{ctype}/{stype}");
113                                    let exts = mime_guess::get_mime_extensions_str(&mtype);
114                                    let ext = *exts.and_then(|exts| exts.first()).unwrap_or(&"txt");
115
116                                    let name = match part.attachment_name() {
117                                        None => PathBuf::from(Uuid::new_v4().to_string())
118                                            .with_extension(ext),
119                                        Some(name) => {
120                                            let mut name = PathBuf::from(name);
121                                            if name.extension().is_none() {
122                                                name.set_extension(ext);
123                                            }
124                                            name
125                                        }
126                                    };
127
128                                    let path = dest.join(name);
129                                    debug!("download non-plain text part at {}", path.display());
130                                    fs::write(&path, text.as_ref())?;
131
132                                    if let Some(id) = part.content_id() {
133                                        output.content_ids.push((id, path));
134                                    }
135
136                                    return io::Result::Ok(output);
137                                }
138                            }
139                        }
140
141                        if !output.plain.is_empty() {
142                            output.plain.push('\r');
143                            output.plain.push('\n');
144                        }
145
146                        output.plain.push_str(text.as_ref().into());
147                    }
148                    PartType::Html(text) => {
149                        if !output.html.is_empty() {
150                            output.html.push('\r');
151                            output.html.push('\n');
152                        }
153
154                        output.html.push_str(text.as_ref().into());
155                    }
156                    PartType::Binary(bin) | PartType::InlineBinary(bin) => {
157                        let ctype = part.content_type().map(|h| (h.ctype(), h.subtype()));
158                        let mtype = if let Some((ctype, Some(stype))) = ctype {
159                            format!("{ctype}/{stype}")
160                        } else {
161                            tree_magic_mini::from_u8(part.contents()).to_owned()
162                        };
163
164                        let exts = mime_guess::get_mime_extensions_str(&mtype);
165                        let ext = exts.and_then(|exts| exts.first());
166
167                        let mut name = match part.attachment_name() {
168                            Some(name) => PathBuf::from(name),
169                            None => PathBuf::from(Uuid::new_v4().to_string()),
170                        };
171
172                        if let Some(ext) = ext {
173                            name.set_extension(ext);
174                        }
175
176                        let path = dest.join(name);
177                        debug!("download attachment at {}", path.display());
178                        fs::write(&path, bin.as_ref())?;
179
180                        if let Some(id) = part.content_id() {
181                            output.content_ids.push((id, path));
182                        }
183                    }
184                    PartType::Message(message) => {
185                        debug!("download message part");
186
187                        let name = match part.attachment_name() {
188                            Some(name) => name.to_owned(),
189                            None => Uuid::new_v4().to_string(),
190                        };
191
192                        let name = PathBuf::from(name).with_extension("eml");
193
194                        let path = dest.join(name);
195                        debug!("download message at {}", path.display());
196                        fs::write(path, message.raw_message())?;
197                    }
198                    PartType::Multipart(_) => (),
199                };
200
201                Ok(output)
202            })
203            .try_reduce(Parts::default, |mut a, b| {
204                a.content_ids.extend(b.content_ids);
205                Ok(Parts {
206                    plain: a.plain + &b.plain,
207                    html: a.html + &b.html,
208                    content_ids: a.content_ids,
209                })
210            })?;
211
212        for (cid, path) in content_ids {
213            let cid = String::from("cid:") + cid;
214            plain = plain.replace(&cid, path.to_str().unwrap());
215            html = html.replace(&cid, path.to_str().unwrap());
216        }
217
218        if !plain.trim().is_empty() {
219            let path = dest.join("plain.txt");
220            debug!("download plain text at {}", path.display());
221            fs::write(path, plain.as_bytes())?;
222        }
223
224        if !html.trim().is_empty() {
225            let path = dest.join("index.html");
226            debug!("download HTML text at {}", path.display());
227            fs::write(path, html.as_bytes())?;
228        }
229
230        Ok(dest.to_owned())
231    }
232
233    /// Returns the list of message attachment.
234    pub fn attachments(&self) -> Result<Vec<Attachment>, Error> {
235        Ok(self
236            .parsed()?
237            .attachments()
238            .map(|part| {
239                Attachment {
240                    filename: part.attachment_name().map(ToOwned::to_owned),
241                    // better to guess the real mime type from the
242                    // body instead of using the one given from the
243                    // content type
244                    mime: tree_magic_mini::from_u8(part.contents()).to_owned(),
245                    body: part.contents().to_owned(),
246                }
247            })
248            .collect())
249    }
250
251    /// Creates a new template builder from an account configuration.
252    pub fn new_tpl_builder(config: Arc<AccountConfig>) -> NewTemplateBuilder {
253        NewTemplateBuilder::new(config)
254    }
255
256    /// Turns the current message into a read.
257    pub async fn to_read_tpl(
258        &self,
259        config: &AccountConfig,
260        with_interpreter: impl Fn(MimeInterpreterBuilder) -> MimeInterpreterBuilder,
261    ) -> Result<Template, Error> {
262        let interpreter = config
263            .generate_tpl_interpreter()
264            .with_show_only_headers(config.get_message_read_headers());
265        let tpl = with_interpreter(interpreter)
266            .build()
267            .from_msg(self.parsed()?)
268            .await
269            .map_err(Error::InterpretEmailAsTplError)?;
270        Ok(Template::new(tpl))
271    }
272
273    /// Turns the current message into a reply template builder.
274    ///
275    /// The fact to return a template builder makes it easier to
276    /// customize the final template from the outside.
277    pub fn to_reply_tpl_builder(&self, config: Arc<AccountConfig>) -> ReplyTemplateBuilder {
278        ReplyTemplateBuilder::new(self, config)
279    }
280
281    /// Turns the current message into a forward template builder.
282    ///
283    /// The fact to return a template builder makes it easier to
284    /// customize the final template from the outside.
285    pub fn to_forward_tpl_builder(&self, config: Arc<AccountConfig>) -> ForwardTemplateBuilder {
286        ForwardTemplateBuilder::new(self, config)
287    }
288}
289
290impl<'a> From<Vec<u8>> for Message<'a> {
291    fn from(bytes: Vec<u8>) -> Self {
292        MessageBuilder {
293            bytes: Cow::Owned(bytes),
294            parsed_builder: Message::parsed_builder,
295        }
296        .build()
297    }
298}
299
300impl<'a> From<&'a [u8]> for Message<'a> {
301    fn from(bytes: &'a [u8]) -> Self {
302        MessageBuilder {
303            bytes: Cow::Borrowed(bytes),
304            parsed_builder: Message::parsed_builder,
305        }
306        .build()
307    }
308}
309
310impl<'a> From<&'a str> for Message<'a> {
311    fn from(str: &'a str) -> Self {
312        str.as_bytes().into()
313    }
314}
315
316// TODO: move to maildir module
317#[cfg(feature = "maildir")]
318impl<'a> From<&'a mut MaildirEntry> for Message<'a> {
319    fn from(entry: &'a mut MaildirEntry) -> Self {
320        MessageBuilder {
321            bytes: Cow::Owned(entry.read().unwrap_or_default()),
322            parsed_builder: Message::parsed_builder,
323        }
324        .build()
325    }
326}
327
328enum RawMessages {
329    #[cfg(feature = "imap")]
330    Imap(Vec<Vec1<MessageDataItem<'static>>>),
331    #[cfg(feature = "maildir")]
332    MailEntries(Vec<MaildirEntry>),
333    #[cfg(feature = "notmuch")]
334    Notmuch(Vec<Vec<u8>>),
335    #[allow(dead_code)]
336    None,
337}
338
339#[self_referencing]
340pub struct Messages {
341    raw: RawMessages,
342    #[borrows(mut raw)]
343    #[covariant]
344    emails: Vec<Message<'this>>,
345}
346
347impl Messages {
348    #[allow(dead_code)]
349    fn emails_builder<'a>(raw: &'a mut RawMessages) -> Vec<Message<'a>> {
350        match raw {
351            #[cfg(feature = "imap")]
352            RawMessages::Imap(items) => items
353                .iter()
354                .filter_map(|items| match Message::try_from(items.as_ref()) {
355                    Ok(msg) => Some(msg),
356                    Err(err) => {
357                        tracing::debug!(?err, "cannot build imap message");
358                        None
359                    }
360                })
361                .collect(),
362            #[cfg(feature = "maildir")]
363            RawMessages::MailEntries(entries) => entries.iter_mut().map(Message::from).collect(),
364            #[cfg(feature = "notmuch")]
365            RawMessages::Notmuch(raw) => raw
366                .iter()
367                .map(|raw| Message::from(raw.as_slice()))
368                .collect(),
369            RawMessages::None => vec![],
370        }
371    }
372
373    pub fn first(&self) -> Option<&Message> {
374        self.borrow_emails().iter().next()
375    }
376
377    pub fn to_vec(&self) -> Vec<&Message> {
378        self.borrow_emails().iter().collect()
379    }
380}
381
382#[cfg(feature = "imap")]
383impl From<Vec<Vec1<MessageDataItem<'static>>>> for Messages {
384    fn from(items: Vec<Vec1<MessageDataItem<'static>>>) -> Self {
385        MessagesBuilder {
386            raw: RawMessages::Imap(items),
387            emails_builder: Messages::emails_builder,
388        }
389        .build()
390    }
391}
392
393#[cfg(feature = "maildir")]
394impl TryFrom<Vec<MaildirEntry>> for Messages {
395    type Error = Error;
396
397    fn try_from(entries: Vec<MaildirEntry>) -> Result<Self, Error> {
398        if entries.is_empty() {
399            Err(Error::ParseEmailFromEmptyEntriesError)
400        } else {
401            Ok(MessagesBuilder {
402                raw: RawMessages::MailEntries(entries),
403                emails_builder: Messages::emails_builder,
404            }
405            .build())
406        }
407    }
408}
409
410#[cfg(feature = "notmuch")]
411impl From<Vec<Vec<u8>>> for Messages {
412    fn from(raw: Vec<Vec<u8>>) -> Self {
413        MessagesBuilder {
414            raw: RawMessages::Notmuch(raw),
415            emails_builder: Messages::emails_builder,
416        }
417        .build()
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use std::sync::Arc;
424
425    use concat_with::concat_line;
426
427    use crate::{
428        account::config::AccountConfig,
429        message::{config::MessageConfig, get::config::MessageReadConfig, Message},
430        template::Template,
431    };
432
433    #[tokio::test]
434    async fn to_read_tpl() {
435        let config = AccountConfig::default();
436        let email = Message::from(concat_line!(
437            "Content-Type: text/plain",
438            "From: from@localhost",
439            "To: to@localhost",
440            "Subject: subject",
441            "",
442            "Hello!",
443            "",
444            "-- ",
445            "Regards,",
446        ));
447
448        let tpl = email.to_read_tpl(&config, |i| i).await.unwrap();
449
450        let expected_tpl = concat_line!(
451            "From: from@localhost",
452            "To: to@localhost",
453            "Subject: subject",
454            "",
455            "Hello!",
456            "",
457            "-- ",
458            "Regards,",
459        );
460
461        assert_eq!(*tpl, expected_tpl);
462    }
463
464    #[tokio::test]
465    async fn to_read_tpl_with_show_all_headers() {
466        let config = AccountConfig::default();
467        let email = Message::from(concat_line!(
468            "Content-Type: text/plain",
469            "From: from@localhost",
470            "To: to@localhost",
471            "Subject: subject",
472            "",
473            "Hello!",
474            "",
475            "-- ",
476            "Regards,"
477        ));
478
479        let tpl = email
480            .to_read_tpl(&config, |i| i.with_show_all_headers())
481            .await
482            .unwrap();
483
484        let expected_tpl = concat_line!(
485            "Content-Type: text/plain",
486            "From: from@localhost",
487            "To: to@localhost",
488            "Subject: subject",
489            "",
490            "Hello!",
491            "",
492            "-- ",
493            "Regards,",
494        );
495
496        assert_eq!(*tpl, expected_tpl);
497    }
498
499    #[tokio::test]
500    async fn to_read_tpl_with_show_only_headers() {
501        let config = AccountConfig::default();
502        let email = Message::from(concat_line!(
503            "Content-Type: text/plain",
504            "From: from@localhost",
505            "To: to@localhost",
506            "Subject: subject",
507            "",
508            "Hello!",
509            "",
510            "-- ",
511            "Regards,"
512        ));
513
514        let tpl = email
515            .to_read_tpl(&config, |i| {
516                i.with_show_only_headers([
517                    // existing headers
518                    "Subject",
519                    "To",
520                    // nonexisting header
521                    "Content-Disposition",
522                ])
523            })
524            .await
525            .unwrap();
526
527        let expected_tpl = concat_line!(
528            "Subject: subject",
529            "To: to@localhost",
530            "",
531            "Hello!",
532            "",
533            "-- ",
534            "Regards,",
535        );
536
537        assert_eq!(*tpl, expected_tpl);
538    }
539
540    #[tokio::test]
541    async fn to_read_tpl_with_email_reading_headers() {
542        let config = AccountConfig {
543            message: Some(MessageConfig {
544                read: Some(MessageReadConfig {
545                    headers: Some(vec!["X-Custom".into()]),
546                    ..Default::default()
547                }),
548                ..Default::default()
549            }),
550            ..AccountConfig::default()
551        };
552
553        let email = Message::from(concat_line!(
554            "Content-Type: text/plain",
555            "From: from@localhost",
556            "To: to@localhost",
557            "Subject: subject",
558            "X-Custom: custom",
559            "",
560            "Hello!",
561            "",
562            "-- ",
563            "Regards,",
564        ));
565
566        let tpl = email
567            .to_read_tpl(&config, |i| {
568                i.with_show_additional_headers([
569                    "Subject", // existing headers
570                    "Cc", "Bcc", // nonexisting headers
571                ])
572            })
573            .await
574            .unwrap();
575
576        let expected_tpl = concat_line!(
577            "X-Custom: custom",
578            "Subject: subject",
579            "",
580            "Hello!",
581            "",
582            "-- ",
583            "Regards,",
584        );
585
586        assert_eq!(*tpl, expected_tpl);
587    }
588
589    #[tokio::test]
590    async fn to_forward_tpl_builder() {
591        let config = Arc::new(AccountConfig {
592            email: "to@localhost".into(),
593            ..AccountConfig::default()
594        });
595
596        let email = Message::from(concat_line!(
597            "Content-Type: text/plain",
598            "From: from@localhost",
599            "To: to@localhost, to2@localhost",
600            "Cc: cc@localhost, cc2@localhost",
601            "Bcc: bcc@localhost",
602            "Subject: subject",
603            "",
604            "Hello!",
605            "",
606            "-- ",
607            "Regards,",
608        ));
609
610        let tpl = email.to_forward_tpl_builder(config).build().await.unwrap();
611
612        let expected_tpl = Template::new_with_cursor(
613            concat_line!(
614                "From: to@localhost",
615                "To: ",
616                "Subject: Fwd: subject",
617                "",
618                "",
619                "",
620                "-------- Forwarded Message --------",
621                "From: from@localhost",
622                "To: to@localhost, to2@localhost",
623                "Cc: cc@localhost, cc2@localhost",
624                "Subject: subject",
625                "",
626                "Hello!",
627                "",
628                "-- ",
629                "Regards,",
630            ),
631            (5, 0),
632        );
633
634        assert_eq!(tpl, expected_tpl);
635    }
636
637    #[tokio::test]
638    async fn to_forward_tpl_builder_with_date_and_signature() {
639        let config = Arc::new(AccountConfig {
640            email: "to@localhost".into(),
641            signature: Some("Cordialement,".into()),
642            ..AccountConfig::default()
643        });
644
645        let email = Message::from(concat_line!(
646            "Content-Type: text/plain",
647            "Date: Thu, 10 Nov 2022 14:26:33 +0000",
648            "From: from@localhost",
649            "To: to@localhost, to2@localhost",
650            "Cc: cc@localhost, cc2@localhost",
651            "Bcc: bcc@localhost",
652            "Subject: subject",
653            "",
654            "Hello!",
655            "",
656            "-- ",
657            "Regards,",
658        ));
659
660        let tpl = email.to_forward_tpl_builder(config).build().await.unwrap();
661
662        let expected_tpl = Template::new_with_cursor(
663            concat_line!(
664                "From: to@localhost",
665                "To: ",
666                "Subject: Fwd: subject",
667                "",
668                "",
669                "",
670                "-- ",
671                "Cordialement,",
672                "",
673                "-------- Forwarded Message --------",
674                "Date: Thu, 10 Nov 2022 14:26:33 +0000",
675                "From: from@localhost",
676                "To: to@localhost, to2@localhost",
677                "Cc: cc@localhost, cc2@localhost",
678                "Subject: subject",
679                "",
680                "Hello!",
681                "",
682                "-- ",
683                "Regards,",
684            ),
685            (5, 0),
686        );
687
688        assert_eq!(tpl, expected_tpl);
689    }
690}