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