1#[cfg(feature = "oauth2")]
7pub mod oauth2;
8pub mod passwd;
9#[cfg(feature = "pgp")]
10pub mod pgp;
11
12use std::{
13 collections::HashMap,
14 env::temp_dir,
15 ffi::OsStr,
16 fs, io,
17 path::{Path, PathBuf},
18 vec,
19};
20
21#[cfg(feature = "derive")]
22use crate::serde::deserialize_shell_expanded_string;
23#[cfg(feature = "sync")]
24use dirs::data_dir;
25use dirs::download_dir;
26use mail_builder::headers::address::{Address, EmailAddress};
27use mail_parser::Address::*;
28use mml::MimeInterpreterBuilder;
29#[cfg(feature = "notify")]
30use notify_rust::Notification;
31use process::Command;
32use shellexpand_utils::{shellexpand_path, shellexpand_str, try_shellexpand_path};
33use tracing::debug;
34
35#[cfg(feature = "pgp")]
36use self::pgp::PgpConfig;
37#[cfg(feature = "sync")]
38use super::sync::config::SyncConfig;
39#[doc(inline)]
40pub use super::{Error, Result};
41use crate::{
42 date::from_mail_parser_to_chrono_datetime,
43 email::config::EmailTextPlainFormat,
44 envelope::{config::EnvelopeConfig, Envelope},
45 flag::config::FlagConfig,
46 folder::{config::FolderConfig, FolderKind, DRAFTS, INBOX, SENT, TRASH},
47 message::config::MessageConfig,
48 template::{
49 config::TemplateConfig,
50 forward::config::{ForwardTemplatePostingStyle, ForwardTemplateSignatureStyle},
51 new::config::NewTemplateSignatureStyle,
52 reply::config::{ReplyTemplatePostingStyle, ReplyTemplateSignatureStyle},
53 },
54 watch::config::WatchHook,
55};
56
57pub const DEFAULT_PAGE_SIZE: usize = 10;
58pub const DEFAULT_SIGNATURE_DELIM: &str = "-- \n";
59
60pub trait HasAccountConfig {
61 fn account_config(&self) -> &AccountConfig;
62}
63
64#[derive(Clone, Debug, Default, Eq, PartialEq)]
71#[cfg_attr(
72 feature = "derive",
73 derive(serde::Serialize, serde::Deserialize),
74 serde(rename_all = "kebab-case", deny_unknown_fields)
75)]
76pub struct AccountConfig {
77 pub name: String,
82
83 #[cfg_attr(
85 feature = "derive",
86 serde(deserialize_with = "deserialize_shell_expanded_string")
87 )]
88 pub email: String,
89
90 pub display_name: Option<String>,
94
95 pub signature: Option<String>,
100
101 pub signature_delim: Option<String>,
105
106 pub downloads_dir: Option<PathBuf>,
112
113 pub folder: Option<FolderConfig>,
115
116 pub envelope: Option<EnvelopeConfig>,
118
119 pub flag: Option<FlagConfig>,
121
122 pub message: Option<MessageConfig>,
124
125 pub template: Option<TemplateConfig>,
127
128 #[cfg(feature = "sync")]
130 pub sync: Option<SyncConfig>,
131
132 #[cfg(feature = "pgp")]
134 pub pgp: Option<PgpConfig>,
135}
136
137impl AccountConfig {
138 pub fn find_full_signature(&self) -> Option<String> {
143 let delim = self
144 .signature_delim
145 .as_deref()
146 .unwrap_or(DEFAULT_SIGNATURE_DELIM);
147
148 let signature = self.signature.as_ref();
149
150 signature.map(|path_or_raw| {
151 let signature = try_shellexpand_path(path_or_raw)
152 .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))
153 .and_then(fs::read_to_string)
154 .unwrap_or_else(|_err| {
155 debug!("cannot read signature from path: {_err}");
156 debug!("{_err:?}");
157 shellexpand_str(path_or_raw)
158 });
159 format!("{}{}", delim, signature.trim())
160 })
161 }
162
163 pub fn get_downloads_dir(&self) -> PathBuf {
167 self.downloads_dir
168 .as_ref()
169 .map(shellexpand_path)
170 .or_else(download_dir)
171 .unwrap_or_else(temp_dir)
172 }
173
174 pub fn get_download_file_path(
186 &self,
187 downloads_dir: Option<&Path>,
188 path: impl AsRef<Path>,
189 ) -> Result<PathBuf> {
190 let path = path.as_ref();
191
192 let file_name = path
193 .file_name()
194 .ok_or_else(|| Error::GetFileNameFromPathSyncError(path.to_owned()))?;
195
196 let final_path = match downloads_dir {
197 Some(dir) => dir.join(file_name),
198 None => self.get_downloads_dir().join(file_name),
199 };
200
201 rename_file_if_duplicate(&final_path, |path, _count| path.is_file())
202 }
203
204 #[cfg(feature = "sync")]
206 pub fn is_sync_enabled(&self) -> bool {
207 self.sync
208 .as_ref()
209 .and_then(|c| c.enable)
210 .unwrap_or_default()
211 }
212
213 #[cfg(feature = "sync")]
215 pub fn does_sync_dir_exist(&self) -> bool {
216 match self.sync.as_ref().and_then(|c| c.dir.as_ref()) {
217 Some(dir) => try_shellexpand_path(dir).is_ok(),
218 None => data_dir()
219 .map(|dir| {
220 dir.join("pimalaya")
221 .join("email")
222 .join("sync")
223 .join(&self.name)
224 .is_dir()
225 })
226 .unwrap_or_default(),
227 }
228 }
229
230 #[cfg(feature = "watch")]
232 pub async fn exec_received_envelope_hook(&self, envelope: &Envelope) {
233 let hook = self
234 .envelope
235 .as_ref()
236 .and_then(|c| c.watch.as_ref())
237 .and_then(|c| c.received.as_ref());
238
239 if let Some(hook) = hook.as_ref() {
240 self.exec_envelope_hook(hook, envelope).await
241 }
242 }
243
244 #[cfg(feature = "watch")]
246 pub async fn exec_any_envelope_hook(&self, envelope: &Envelope) {
247 let hook = self
248 .envelope
249 .as_ref()
250 .and_then(|c| c.watch.as_ref())
251 .and_then(|c| c.any.as_ref());
252
253 if let Some(hook) = hook.as_ref() {
254 self.exec_envelope_hook(hook, envelope).await
255 }
256 }
257
258 pub async fn exec_envelope_hook(&self, hook: &WatchHook, envelope: &Envelope) {
260 let sender = envelope.from.name.as_deref().unwrap_or(&envelope.from.addr);
261 let sender_name = envelope.from.name.as_deref().unwrap_or("unknown");
262 let recipient = envelope.to.name.as_deref().unwrap_or(&envelope.to.addr);
263 let recipient_name = envelope.to.name.as_deref().unwrap_or("unknown");
264
265 if let Some(cmd) = hook.cmd.as_ref() {
266 let res = cmd
267 .clone()
268 .replace("{id}", &envelope.id)
269 .replace("{subject}", &envelope.subject)
270 .replace("{sender}", sender)
271 .replace("{sender.name}", sender_name)
272 .replace("{sender.address}", &envelope.from.addr)
273 .replace("{recipient}", recipient)
274 .replace("{recipient.name}", recipient_name)
275 .replace("{recipient.address}", &envelope.to.addr)
276 .run()
277 .await;
278
279 if let Err(_err) = res {
280 debug!("error while executing watch command hook");
281 debug!("{_err:?}");
282 }
283 }
284
285 #[allow(unused_variables)]
286 let replace = move |fmt: &str, envelope: &Envelope| -> String {
287 fmt.replace("{id}", &envelope.id)
288 .replace("{subject}", &envelope.subject)
289 .replace("{sender}", sender)
290 .replace("{sender.name}", sender_name)
291 .replace("{sender.address}", &envelope.from.addr)
292 .replace("{recipient}", recipient)
293 .replace("{recipient.name}", recipient_name)
294 .replace("{recipient.address}", &envelope.to.addr)
295 };
296
297 #[cfg(all(feature = "notify", target_os = "linux"))]
298 if let Some(notify) = hook.notify.as_ref() {
299 let res = Notification::new()
300 .summary(&replace(¬ify.summary, envelope))
301 .body(&replace(¬ify.body, envelope))
302 .show_async()
303 .await;
304 if let Err(err) = res {
305 debug!("error while sending system notification");
306 debug!("{err:?}");
307 }
308 }
309
310 #[cfg(all(feature = "notify", not(target_os = "linux")))]
311 if let Some(notify) = hook.notify.as_ref() {
312 let summary = replace(¬ify.summary, &envelope);
313 let body = replace(¬ify.body, &envelope);
314
315 let res = tokio::task::spawn_blocking(move || {
316 Notification::new().summary(&summary).body(&body).show()
317 })
318 .await;
319
320 if let Err(err) = res {
321 debug!("cannot send system notification");
322 debug!("{err:?}");
323 } else {
324 let res = res.unwrap();
325 if let Err(err) = res {
326 debug!("error while sending system notification");
327 debug!("{err:?}");
328 }
329 }
330 }
331
332 if let Some(callback) = hook.callback.as_ref() {
333 let res = callback(envelope).await;
334 if let Err(_err) = res {
335 debug!("error while executing callback");
336 debug!("{_err:?}");
337 }
338 }
339 }
340
341 pub fn find_folder_alias(&self, from_name: &str) -> Option<String> {
345 self.folder
346 .as_ref()
347 .and_then(|c| c.aliases.as_ref())
348 .and_then(|aliases| {
349 aliases.iter().find_map(|(name, alias)| {
350 if name.eq_ignore_ascii_case(from_name.trim()) {
351 Some(shellexpand_str(alias))
352 } else {
353 None
354 }
355 })
356 })
357 }
358
359 pub fn get_folder_alias(&self, folder: &str) -> String {
362 self.find_folder_alias(folder)
363 .unwrap_or_else(|| shellexpand_str(folder))
364 }
365
366 pub fn get_inbox_folder_alias(&self) -> String {
368 self.get_folder_alias(INBOX)
369 }
370
371 pub fn get_sent_folder_alias(&self) -> String {
373 self.get_folder_alias(SENT)
374 }
375
376 pub fn get_drafts_folder_alias(&self) -> String {
378 self.get_folder_alias(DRAFTS)
379 }
380
381 pub fn get_trash_folder_alias(&self) -> String {
383 self.get_folder_alias(TRASH)
384 }
385
386 pub fn is_trash_folder(&self, folder: &str) -> bool {
388 self.get_folder_alias(folder) == self.get_trash_folder_alias()
389 }
390
391 pub fn is_delete_message_style_flag(&self) -> bool {
394 self.message
395 .as_ref()
396 .and_then(|c| c.delete.as_ref())
397 .and_then(|c| c.style.as_ref())
398 .filter(|c| c.is_flag())
399 .is_some()
400 }
401
402 pub fn get_folder_aliases(&self) -> Option<&HashMap<String, String>> {
404 self.folder.as_ref().and_then(|c| c.aliases.as_ref())
405 }
406
407 pub fn find_folder_kind_from_alias(&self, alias: &str) -> Option<FolderKind> {
413 self.folder
414 .as_ref()
415 .and_then(|c| c.aliases.as_ref())
416 .and_then(|aliases| {
417 let from_alias = shellexpand_str(alias);
418 aliases.iter().find_map(|(kind_or_name, alias)| {
419 if shellexpand_str(alias).eq_ignore_ascii_case(&from_alias) {
420 Some(kind_or_name.into())
421 } else {
422 None
423 }
424 })
425 })
426 }
427
428 pub fn get_envelope_list_page_size(&self) -> usize {
431 self.envelope
432 .as_ref()
433 .and_then(|c| c.list.as_ref())
434 .and_then(|c| c.page_size)
435 .unwrap_or(DEFAULT_PAGE_SIZE)
436 }
437
438 #[cfg(feature = "thread")]
441 pub fn get_envelope_thread_page_size(&self) -> usize {
442 self.envelope
443 .as_ref()
444 .and_then(|c| c.thread.as_ref())
445 .and_then(|c| c.page_size)
446 .unwrap_or(DEFAULT_PAGE_SIZE)
447 }
448
449 pub fn get_message_read_format(&self) -> EmailTextPlainFormat {
452 self.message
453 .as_ref()
454 .and_then(|c| c.read.as_ref())
455 .and_then(|c| c.format.as_ref())
456 .cloned()
457 .unwrap_or_default()
458 }
459
460 pub fn get_message_read_headers(&self) -> Vec<String> {
463 self.message
464 .as_ref()
465 .and_then(|c| c.read.as_ref())
466 .and_then(|c| c.headers.as_ref())
467 .cloned()
468 .unwrap_or(vec![
469 "From".into(),
470 "To".into(),
471 "Cc".into(),
472 "Subject".into(),
473 ])
474 }
475
476 pub fn get_message_write_headers(&self) -> Vec<String> {
479 self.message
480 .as_ref()
481 .and_then(|c| c.write.as_ref())
482 .and_then(|c| c.headers.as_ref())
483 .cloned()
484 .unwrap_or(vec![
485 "From".into(),
486 "To".into(),
487 "In-Reply-To".into(),
488 "Cc".into(),
489 "Subject".into(),
490 ])
491 }
492
493 pub fn find_message_pre_send_hook(&self) -> Option<&Command> {
495 self.message
496 .as_ref()
497 .and_then(|c| c.send.as_ref())
498 .and_then(|c| c.pre_hook.as_ref())
499 }
500
501 pub fn should_save_copy_sent_message(&self) -> bool {
504 self.message
505 .as_ref()
506 .and_then(|c| c.send.as_ref())
507 .and_then(|c| c.save_copy)
508 .unwrap_or(true)
509 }
510
511 pub fn generate_tpl_interpreter(&self) -> MimeInterpreterBuilder {
514 let builder =
515 MimeInterpreterBuilder::new().with_save_attachments_dir(self.get_downloads_dir());
516
517 #[cfg(feature = "pgp")]
518 if let Some(ref pgp) = self.pgp {
519 return builder.with_pgp(pgp.clone());
520 }
521
522 builder
523 }
524
525 pub fn get_envelope_list_datetime_fmt(&self) -> String {
528 self.envelope
529 .as_ref()
530 .and_then(|c| c.list.as_ref())
531 .and_then(|c| c.datetime_fmt.clone())
532 .unwrap_or_else(|| String::from("%F %R%:z"))
533 }
534
535 pub fn has_envelope_list_datetime_local_tz(&self) -> bool {
538 self.envelope
539 .as_ref()
540 .and_then(|c| c.list.as_ref())
541 .and_then(|c| c.datetime_local_tz)
542 .unwrap_or_default()
543 }
544
545 pub fn get_new_template_signature_style(&self) -> NewTemplateSignatureStyle {
547 self.template
548 .as_ref()
549 .and_then(|c| c.new.as_ref())
550 .and_then(|c| c.signature_style.clone())
551 .unwrap_or_default()
552 }
553
554 pub fn get_reply_template_signature_style(&self) -> ReplyTemplateSignatureStyle {
555 self.template
556 .as_ref()
557 .and_then(|c| c.reply.as_ref())
558 .and_then(|c| c.signature_style.clone())
559 .unwrap_or_default()
560 }
561
562 pub fn get_reply_template_posting_style(&self) -> ReplyTemplatePostingStyle {
563 self.template
564 .as_ref()
565 .and_then(|c| c.reply.as_ref())
566 .and_then(|c| c.posting_style.clone())
567 .unwrap_or_default()
568 }
569
570 pub fn get_reply_template_quote_headline(&self, msg: &mail_parser::Message) -> Option<String> {
571 let date = from_mail_parser_to_chrono_datetime(msg.date()?)?;
572
573 let senders = match (msg.from(), msg.sender()) {
574 (Some(List(a)), _) if !a.is_empty() => {
575 a.iter().fold(String::new(), |mut senders, sender| {
576 if let Some(name) = sender.name() {
577 if !senders.is_empty() {
578 senders.push_str(", ");
579 }
580 senders.push_str(name);
581 } else if let Some(addr) = sender.address() {
582 if !senders.is_empty() {
583 senders.push_str(", ");
584 }
585 senders.push_str(addr);
586 }
587 senders
588 })
589 }
590 (Some(Group(g)), _) if !g.is_empty() => {
591 g.iter().fold(String::new(), |mut senders, sender| {
592 if let Some(ref name) = sender.name {
593 if !senders.is_empty() {
594 senders.push_str(", ");
595 }
596 senders.push_str(name);
597 }
598 senders
599 })
600 }
601 (_, Some(List(a))) if !a.is_empty() => {
602 a.iter().fold(String::new(), |mut senders, sender| {
603 if let Some(name) = sender.name() {
604 if !senders.is_empty() {
605 senders.push_str(", ");
606 }
607 senders.push_str(name);
608 } else if let Some(addr) = sender.address() {
609 if !senders.is_empty() {
610 senders.push_str(", ");
611 }
612 senders.push_str(addr);
613 }
614 senders
615 })
616 }
617 (_, Some(Group(g))) if !g.is_empty() => {
618 g.iter().fold(String::new(), |mut senders, sender| {
619 if let Some(ref name) = sender.name {
620 if !senders.is_empty() {
621 senders.push_str(", ");
622 }
623 senders.push_str(name);
624 }
625 senders
626 })
627 }
628 _ => String::new(),
629 };
630
631 let fmt = self
632 .template
633 .as_ref()
634 .and_then(|c| c.reply.as_ref())
635 .and_then(|c| c.quote_headline_fmt.clone())
636 .unwrap_or_else(|| String::from("On %d/%m/%Y %H:%M, {senders} wrote:\n"));
637
638 Some(date.format(&fmt.replace("{senders}", &senders)).to_string())
639 }
640
641 pub fn get_forward_template_signature_style(&self) -> ForwardTemplateSignatureStyle {
642 self.template
643 .as_ref()
644 .and_then(|c| c.forward.as_ref())
645 .and_then(|c| c.signature_style.clone())
646 .unwrap_or_default()
647 }
648
649 pub fn get_forward_template_posting_style(&self) -> ForwardTemplatePostingStyle {
650 self.template
651 .as_ref()
652 .and_then(|c| c.forward.as_ref())
653 .and_then(|c| c.posting_style.clone())
654 .unwrap_or_default()
655 }
656
657 pub fn get_forward_template_quote_headline(&self) -> String {
658 self.template
659 .as_ref()
660 .and_then(|c| c.forward.as_ref())
661 .and_then(|c| c.quote_headline.clone())
662 .unwrap_or_else(|| String::from("-------- Forwarded Message --------\n"))
663 }
664}
665
666impl<'a> From<&'a AccountConfig> for Address<'a> {
667 fn from(config: &'a AccountConfig) -> Self {
668 Address::Address(EmailAddress {
669 name: config.display_name.as_ref().map(Into::into),
670 email: config.email.as_str().into(),
671 })
672 }
673}
674
675pub(crate) fn rename_file_if_duplicate(
682 origin_file_path: &Path,
683 is_file: impl Fn(&PathBuf, u8) -> bool,
684) -> Result<PathBuf> {
685 let mut count = 0;
686
687 let mut file_path = origin_file_path.to_owned();
688 let file_stem = origin_file_path.file_stem().and_then(OsStr::to_str);
689 let file_ext = origin_file_path
690 .extension()
691 .and_then(OsStr::to_str)
692 .map(|fext| String::from(".") + fext)
693 .unwrap_or_default();
694
695 while is_file(&file_path, count) {
696 count += 1;
697 file_path.set_file_name(
698 &file_stem
699 .map(|fstem| format!("{}_{}{}", fstem, count, file_ext))
700 .ok_or_else(|| Error::ParseDownloadFileNameError(file_path.to_owned()))?,
701 );
702 }
703
704 Ok(file_path)
705}
706
707#[cfg(test)]
708mod tests {
709 use std::path::PathBuf;
710
711 #[test]
712 fn rename_file_if_duplicate() {
713 let path = PathBuf::from("downloads/file.ext");
714
715 assert!(matches!(
717 super::rename_file_if_duplicate(&path, |_, _| false),
718 Ok(path) if path == PathBuf::from("downloads/file.ext")
719 ));
720
721 assert!(matches!(
723 super::rename_file_if_duplicate(&path, |_, count| count < 1),
724 Ok(path) if path == PathBuf::from("downloads/file_1.ext")
725 ));
726
727 assert!(matches!(
729 super::rename_file_if_duplicate(&path, |_, count| count < 5),
730 Ok(path) if path == PathBuf::from("downloads/file_5.ext")
731 ));
732
733 let path = PathBuf::from("downloads/file");
735 assert!(matches!(
736 super::rename_file_if_duplicate(&path, |_, count| count < 5),
737 Ok(path) if path == PathBuf::from("downloads/file_5")
738 ));
739
740 let path = PathBuf::from("downloads/file.ext.ext2");
742 assert!(matches!(
743 super::rename_file_if_duplicate(&path, |_, count| count < 5),
744 Ok(path) if path == PathBuf::from("downloads/file.ext_5.ext2")
745 ));
746 }
747}