1pub 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#[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 fn parsed_builder<'a>(bytes: &'a mut Cow<[u8]>) -> Option<mail_parser::Message<'a>> {
64 MessageParser::new().parse((*bytes).as_ref())
65 }
66
67 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 pub fn raw(&self) -> Result<&[u8], Error> {
78 self.parsed().map(|parsed| parsed.raw_message())
79 }
80
81 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 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 mime: tree_magic_mini::from_u8(part.contents()).to_owned(),
245 body: part.contents().to_owned(),
246 }
247 })
248 .collect())
249 }
250
251 pub fn new_tpl_builder(config: Arc<AccountConfig>) -> NewTemplateBuilder {
253 NewTemplateBuilder::new(config)
254 }
255
256 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 pub fn to_reply_tpl_builder(&self, config: Arc<AccountConfig>) -> ReplyTemplateBuilder {
278 ReplyTemplateBuilder::new(self, config)
279 }
280
281 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#[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 "Subject",
519 "To",
520 "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", "Cc", "Bcc", ])
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}