email/account/config/
mod.rs

1//! Module dedicated to account configuration.
2//!
3//! This module contains the representation of the user's current
4//! account configuration named [`AccountConfig`].
5
6#[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/// The user's account configuration.
63///
64/// It represents everything that the user can customize for a given
65/// account. It is the main configuration used by all other
66/// modules. Usually, it serves as a reference for building config
67/// file structure.
68#[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    /// The name of the user account.
76    ///
77    /// The account name is used as an unique identifier for a given
78    /// configuration.
79    pub name: String,
80
81    /// The email address of the user account.
82    pub email: String,
83
84    /// The display name of the user.
85    ///
86    /// It usually corresponds to the full name of the user.
87    pub display_name: Option<String>,
88
89    /// The email signature of the user.
90    ///
91    /// It can be either a path to a file (usually `~/.signature`) or
92    /// a raw string.
93    pub signature: Option<String>,
94
95    /// The email signature delimiter of the user signature.
96    ///
97    /// Defaults to `-- \n`.
98    pub signature_delim: Option<String>,
99
100    /// The downloads directory.
101    ///
102    /// It is mostly used for downloading messages
103    /// attachments. Defaults to the system temporary directory
104    /// (usually `/tmp`).
105    pub downloads_dir: Option<PathBuf>,
106
107    /// The folder configuration.
108    pub folder: Option<FolderConfig>,
109
110    /// The envelope configuration.
111    pub envelope: Option<EnvelopeConfig>,
112
113    /// The flag configuration.
114    pub flag: Option<FlagConfig>,
115
116    /// The message configuration.
117    pub message: Option<MessageConfig>,
118
119    /// The message configuration.
120    pub template: Option<TemplateConfig>,
121
122    /// The account synchronization configuration.
123    #[cfg(feature = "sync")]
124    pub sync: Option<SyncConfig>,
125
126    /// The PGP configuration.
127    #[cfg(feature = "pgp")]
128    pub pgp: Option<PgpConfig>,
129}
130
131impl AccountConfig {
132    /// Get the signature, including the delimiter.
133    ///
134    /// Uses the default delimiter `-- \n` in case no delimiter has
135    /// been defined. Return `None` if no signature has been defined.
136    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    /// Get then expand the downloads directory path.
158    ///
159    /// Falls back to [`dirs::download_dir`].
160    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    /// Build the downloadable version of the given path.
169    ///
170    /// The aim of this helper is to build a safe download path for a
171    /// given path.
172    ///
173    /// First, only the file name of the give path is taken in order
174    /// to prevent any interaction outside of the downloads directory.
175    ///
176    /// Then, a suffix may be added to the final path if it already
177    /// exists on the filesystem in order to prevent any overriding or
178    /// data loss.
179    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    /// Return `true` if the synchronization is enabled.
192    #[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    /// Return `true` if the synchronization directory already exists.
201    #[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    /// Execute the envelope received hook.
218    #[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    /// Execute the envelope any hook.
232    #[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    /// Execute the given envelope hook.
246    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(&notify.summary, envelope))
288                .body(&replace(&notify.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(&notify.summary, &envelope);
300            let body = replace(&notify.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    /// Find the alias of the given folder name.
329    ///
330    /// The alias is also shell expanded.
331    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    /// Find the alias of the given folder, otherwise return the given
347    /// folder itself.
348    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    /// Get the inbox folder alias.
354    pub fn get_inbox_folder_alias(&self) -> String {
355        self.get_folder_alias(INBOX)
356    }
357
358    /// Get the sent folder alias.
359    pub fn get_sent_folder_alias(&self) -> String {
360        self.get_folder_alias(SENT)
361    }
362
363    /// Get the drafts folder alias.
364    pub fn get_drafts_folder_alias(&self) -> String {
365        self.get_folder_alias(DRAFTS)
366    }
367
368    /// Get the trash folder alias.
369    pub fn get_trash_folder_alias(&self) -> String {
370        self.get_folder_alias(TRASH)
371    }
372
373    /// Return `true` if the given folder matches the Trash folder.
374    pub fn is_trash_folder(&self, folder: &str) -> bool {
375        self.get_folder_alias(folder) == self.get_trash_folder_alias()
376    }
377
378    /// Return `true` if the delete message style matches the
379    /// flag-based message deletion style.
380    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    /// Get all folder aliases.
390    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    /// Find the folder kind associated to the given folder alias.
395    ///
396    /// This function is the reverse of [`get_folder_alias`], as it
397    /// tries to find a key (folder kind) matching the given value
398    /// (folder alias).
399    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    /// Get the envelope listing page size if defined, otherwise
416    /// return the default one.
417    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    /// Get the envelope threading page size if defined, otherwise
426    /// return the default one.
427    #[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    /// Get the message reading format if defined, otherwise return
437    /// the default one.
438    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    /// Get the message reading headers if defined, otherwise return
448    /// the default ones.
449    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    /// Get the message writing headers if defined, otherwise return
464    /// the default ones.
465    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    /// Find the message pre-send hook.
481    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    /// Return `true` if a copy of sent messages should be saved in
489    /// the sent folder.
490    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    /// Generate a template interpreter with prefilled options from
499    /// the current user account configuration.
500    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    /// Get the envelope listing datetime format, otherwise return the
513    /// default one.
514    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    /// Return `true` if the envelope listing datetime local timezone
523    /// option is enabled.
524    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    /// Get the new template signature placement.
533    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
662/// Rename duplicated file by adding a auto-incremented counter
663/// suffix.
664///
665/// Helper that check if the given file path already exists: if so,
666/// creates a new path with an auto-incremented integer suffix and
667/// returs it, otherwise returs the original file path.
668pub(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        // when file path is unique
703        assert!(matches!(
704            super::rename_file_if_duplicate(&path, |_, _| false),
705            Ok(path) if path == PathBuf::from("downloads/file.ext")
706        ));
707
708        // when 1 file path already exist
709        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        // when 5 file paths already exist
715        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        // when file path has no extension
721        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        // when file path has 2 extensions
728        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}