himalaya 0.5.7

Command-line interface for email management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
use anyhow::{anyhow, Context, Result};
use lettre::transport::smtp::authentication::Credentials as SmtpCredentials;
use log::{debug, info, trace};
use mailparse::MailAddr;
use std::{collections::HashMap, env, ffi::OsStr, fs, path::PathBuf};

use crate::{config::*, output::run_cmd};

/// Represents the user account.
#[derive(Debug, Default, Clone)]
pub struct AccountConfig {
    /// Represents the name of the user account.
    pub name: String,
    /// Makes this account the default one.
    pub default: bool,
    /// Represents the display name of the user account.
    pub display_name: String,
    /// Represents the email address of the user account.
    pub email: String,
    /// Represents the downloads directory (mostly for attachments).
    pub downloads_dir: PathBuf,
    /// Represents the signature of the user.
    pub sig: Option<String>,
    /// Represents the default page size for listings.
    pub default_page_size: usize,
    /// Represents the notify command.
    pub notify_cmd: Option<String>,
    /// Overrides the default IMAP query "NEW" used to fetch new messages
    pub notify_query: String,
    /// Represents the watch commands.
    pub watch_cmds: Vec<String>,

    /// Represents mailbox aliases.
    pub mailboxes: HashMap<String, String>,

    /// Represents the SMTP host.
    pub smtp_host: String,
    /// Represents the SMTP port.
    pub smtp_port: u16,
    /// Enables StartTLS.
    pub smtp_starttls: bool,
    /// Trusts any certificate.
    pub smtp_insecure: bool,
    /// Represents the SMTP login.
    pub smtp_login: String,
    /// Represents the SMTP password command.
    pub smtp_passwd_cmd: String,

    /// Represents the command used to encrypt a message.
    pub pgp_encrypt_cmd: Option<String>,
    /// Represents the command used to decrypt a message.
    pub pgp_decrypt_cmd: Option<String>,
}

impl<'a> AccountConfig {
    /// tries to create an account from a config and an optional account name.
    pub fn from_config_and_opt_account_name(
        config: &'a DeserializedConfig,
        account_name: Option<&str>,
    ) -> Result<(AccountConfig, BackendConfig)> {
        info!("begin: parsing account and backend configs from config and account name");

        debug!("account name: {:?}", account_name.unwrap_or("default"));
        let (name, account) = match account_name.map(|name| name.trim()) {
            Some("default") | Some("") | None => config
                .accounts
                .iter()
                .find(|(_, account)| match account {
                    DeserializedAccountConfig::Imap(account) => account.default.unwrap_or_default(),
                    DeserializedAccountConfig::Maildir(account) => {
                        account.default.unwrap_or_default()
                    }
                    #[cfg(feature = "notmuch")]
                    DeserializedAccountConfig::Notmuch(account) => {
                        account.default.unwrap_or_default()
                    }
                })
                .map(|(name, account)| (name.to_owned(), account))
                .ok_or_else(|| anyhow!("cannot find default account")),
            Some(name) => config
                .accounts
                .get(name)
                .map(|account| (name.to_owned(), account))
                .ok_or_else(|| anyhow!(r#"cannot find account "{}""#, name)),
        }?;

        let base_account = account.to_base();
        let downloads_dir = base_account
            .downloads_dir
            .as_ref()
            .and_then(|dir| dir.to_str())
            .and_then(|dir| shellexpand::full(dir).ok())
            .map(|dir| PathBuf::from(dir.to_string()))
            .or_else(|| {
                config
                    .downloads_dir
                    .as_ref()
                    .and_then(|dir| dir.to_str())
                    .and_then(|dir| shellexpand::full(dir).ok())
                    .map(|dir| PathBuf::from(dir.to_string()))
            })
            .unwrap_or_else(env::temp_dir);

        let default_page_size = base_account
            .default_page_size
            .as_ref()
            .or_else(|| config.default_page_size.as_ref())
            .unwrap_or(&DEFAULT_PAGE_SIZE)
            .to_owned();

        let default_sig_delim = DEFAULT_SIG_DELIM.to_string();
        let sig_delim = base_account
            .signature_delimiter
            .as_ref()
            .or_else(|| config.signature_delimiter.as_ref())
            .unwrap_or(&default_sig_delim);
        let sig = base_account
            .signature
            .as_ref()
            .or_else(|| config.signature.as_ref());
        let sig = sig
            .and_then(|sig| shellexpand::full(sig).ok())
            .map(String::from)
            .and_then(|sig| fs::read_to_string(sig).ok())
            .or_else(|| sig.map(|sig| sig.to_owned()))
            .map(|sig| format!("{}{}", sig_delim, sig.trim_end()));

        let account_config = AccountConfig {
            name,
            display_name: base_account
                .name
                .as_ref()
                .unwrap_or(&config.name)
                .to_owned(),
            downloads_dir,
            sig,
            default_page_size,
            notify_cmd: base_account.notify_cmd.clone(),
            notify_query: base_account
                .notify_query
                .as_ref()
                .or_else(|| config.notify_query.as_ref())
                .unwrap_or(&String::from("NEW"))
                .to_owned(),
            watch_cmds: base_account
                .watch_cmds
                .as_ref()
                .or_else(|| config.watch_cmds.as_ref())
                .unwrap_or(&vec![])
                .to_owned(),
            mailboxes: base_account.mailboxes.clone(),
            default: base_account.default.unwrap_or_default(),
            email: base_account.email.to_owned(),

            smtp_host: base_account.smtp_host.to_owned(),
            smtp_port: base_account.smtp_port,
            smtp_starttls: base_account.smtp_starttls.unwrap_or_default(),
            smtp_insecure: base_account.smtp_insecure.unwrap_or_default(),
            smtp_login: base_account.smtp_login.to_owned(),
            smtp_passwd_cmd: base_account.smtp_passwd_cmd.to_owned(),

            pgp_encrypt_cmd: base_account.pgp_encrypt_cmd.to_owned(),
            pgp_decrypt_cmd: base_account.pgp_decrypt_cmd.to_owned(),
        };
        trace!("account config: {:?}", account_config);

        let backend_config = match account {
            DeserializedAccountConfig::Imap(config) => BackendConfig::Imap(ImapBackendConfig {
                imap_host: config.imap_host.clone(),
                imap_port: config.imap_port.clone(),
                imap_starttls: config.imap_starttls.unwrap_or_default(),
                imap_insecure: config.imap_insecure.unwrap_or_default(),
                imap_login: config.imap_login.clone(),
                imap_passwd_cmd: config.imap_passwd_cmd.clone(),
            }),
            DeserializedAccountConfig::Maildir(config) => {
                BackendConfig::Maildir(MaildirBackendConfig {
                    maildir_dir: shellexpand::full(&config.maildir_dir)?.to_string().into(),
                })
            }
            #[cfg(feature = "notmuch")]
            DeserializedAccountConfig::Notmuch(config) => {
                BackendConfig::Notmuch(NotmuchBackendConfig {
                    notmuch_database_dir: shellexpand::full(&config.notmuch_database_dir)?
                        .to_string()
                        .into(),
                })
            }
        };
        trace!("backend config: {:?}", backend_config);

        info!("end: parsing account and backend configs from config and account name");
        Ok((account_config, backend_config))
    }

    /// Builds the full RFC822 compliant address of the user account.
    pub fn address(&self) -> Result<MailAddr> {
        let has_special_chars =
            "()<>[]:;@.,".contains(|special_char| self.display_name.contains(special_char));
        let addr = if self.display_name.is_empty() {
            self.email.clone()
        } else if has_special_chars {
            // Wraps the name with double quotes if it contains any special character.
            format!("\"{}\" <{}>", self.display_name, self.email)
        } else {
            format!("{} <{}>", self.display_name, self.email)
        };

        Ok(mailparse::addrparse(&addr)
            .context(format!(
                "cannot parse account address {:?}",
                self.display_name
            ))?
            .first()
            .ok_or_else(|| anyhow!("cannot parse account address {:?}", self.display_name))?
            .clone())
    }

    /// Builds the user account SMTP credentials.
    pub fn smtp_creds(&self) -> Result<SmtpCredentials> {
        let passwd = run_cmd(&self.smtp_passwd_cmd).context("cannot run SMTP passwd cmd")?;
        let passwd = passwd
            .trim_end_matches(|c| c == '\r' || c == '\n')
            .to_owned();

        Ok(SmtpCredentials::new(self.smtp_login.to_owned(), passwd))
    }

    /// Encrypts a file.
    pub fn pgp_encrypt_file(&self, addr: &str, path: PathBuf) -> Result<Option<String>> {
        if let Some(cmd) = self.pgp_encrypt_cmd.as_ref() {
            let encrypt_file_cmd = format!("{} {} {:?}", cmd, addr, path);
            run_cmd(&encrypt_file_cmd).map(Some).context(format!(
                "cannot run pgp encrypt command {:?}",
                encrypt_file_cmd
            ))
        } else {
            Ok(None)
        }
    }

    /// Decrypts a file.
    pub fn pgp_decrypt_file(&self, path: PathBuf) -> Result<Option<String>> {
        if let Some(cmd) = self.pgp_decrypt_cmd.as_ref() {
            let decrypt_file_cmd = format!("{} {:?}", cmd, path);
            run_cmd(&decrypt_file_cmd).map(Some).context(format!(
                "cannot run pgp decrypt command {:?}",
                decrypt_file_cmd
            ))
        } else {
            Ok(None)
        }
    }

    /// Gets the download path from a file name.
    pub fn get_download_file_path<S: AsRef<str>>(&self, file_name: S) -> Result<PathBuf> {
        let file_path = self.downloads_dir.join(file_name.as_ref());
        self.get_unique_download_file_path(&file_path, |path, _count| path.is_file())
            .context(format!(
                "cannot get download file path of {:?}",
                file_name.as_ref()
            ))
    }

    /// Gets the unique download path from a file name by adding suffixes in case of name conflicts.
    pub fn get_unique_download_file_path(
        &self,
        original_file_path: &PathBuf,
        is_file: impl Fn(&PathBuf, u8) -> bool,
    ) -> Result<PathBuf> {
        let mut count = 0;
        let file_ext = original_file_path
            .extension()
            .and_then(OsStr::to_str)
            .map(|fext| String::from(".") + fext)
            .unwrap_or_default();
        let mut file_path = original_file_path.clone();

        while is_file(&file_path, count) {
            count += 1;
            file_path.set_file_name(OsStr::new(
                &original_file_path
                    .file_stem()
                    .and_then(OsStr::to_str)
                    .map(|fstem| format!("{}_{}{}", fstem, count, file_ext))
                    .ok_or_else(|| anyhow!("cannot get stem from file {:?}", original_file_path))?,
            ));
        }

        Ok(file_path)
    }

    /// Runs the notify command.
    pub fn run_notify_cmd<S: AsRef<str>>(&self, subject: S, sender: S) -> Result<()> {
        let subject = subject.as_ref();
        let sender = sender.as_ref();

        let default_cmd = format!(r#"notify-send "New message from {}" "{}""#, sender, subject);
        let cmd = self
            .notify_cmd
            .as_ref()
            .map(|cmd| format!(r#"{} {:?} {:?}"#, cmd, subject, sender))
            .unwrap_or(default_cmd);

        debug!("run command: {}", cmd);
        run_cmd(&cmd).context("cannot run notify cmd")?;
        Ok(())
    }
}

/// Represents all existing kind of account (backend).
#[derive(Debug, Clone)]
pub enum BackendConfig {
    Imap(ImapBackendConfig),
    Maildir(MaildirBackendConfig),
    #[cfg(feature = "notmuch")]
    Notmuch(NotmuchBackendConfig),
}

/// Represents the IMAP backend.
#[derive(Debug, Default, Clone)]
pub struct ImapBackendConfig {
    /// Represents the IMAP host.
    pub imap_host: String,
    /// Represents the IMAP port.
    pub imap_port: u16,
    /// Enables StartTLS.
    pub imap_starttls: bool,
    /// Trusts any certificate.
    pub imap_insecure: bool,
    /// Represents the IMAP login.
    pub imap_login: String,
    /// Represents the IMAP password command.
    pub imap_passwd_cmd: String,
}

impl ImapBackendConfig {
    /// Gets the IMAP password of the user account.
    pub fn imap_passwd(&self) -> Result<String> {
        let passwd = run_cmd(&self.imap_passwd_cmd).context("cannot run IMAP passwd cmd")?;
        let passwd = passwd
            .trim_end_matches(|c| c == '\r' || c == '\n')
            .to_owned();
        Ok(passwd)
    }
}

/// Represents the Maildir backend.
#[derive(Debug, Default, Clone)]
pub struct MaildirBackendConfig {
    /// Represents the Maildir directory path.
    pub maildir_dir: PathBuf,
}

/// Represents the Notmuch backend.
#[cfg(feature = "notmuch")]
#[derive(Debug, Default, Clone)]
pub struct NotmuchBackendConfig {
    /// Represents the Notmuch database path.
    pub notmuch_database_dir: PathBuf,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_should_get_unique_download_file_path() {
        let account = AccountConfig::default();
        let path = PathBuf::from("downloads/file.ext");

        // When file path is unique
        assert!(matches!(
            account.get_unique_download_file_path(&path, |_, _| false),
            Ok(path) if path == PathBuf::from("downloads/file.ext")
        ));

        // When 1 file path already exist
        assert!(matches!(
            account.get_unique_download_file_path(&path, |_, count| count <  1),
            Ok(path) if path == PathBuf::from("downloads/file_1.ext")
        ));

        // When 5 file paths already exist
        assert!(matches!(
            account.get_unique_download_file_path(&path, |_, count| count < 5),
            Ok(path) if path == PathBuf::from("downloads/file_5.ext")
        ));

        // When file path has no extension
        let path = PathBuf::from("downloads/file");
        assert!(matches!(
            account.get_unique_download_file_path(&path, |_, count| count < 5),
            Ok(path) if path == PathBuf::from("downloads/file_5")
        ));

        // When file path has 2 extensions
        let path = PathBuf::from("downloads/file.ext.ext2");
        assert!(matches!(
            account.get_unique_download_file_path(&path, |_, count| count < 5),
            Ok(path) if path == PathBuf::from("downloads/file.ext_5.ext2")
        ));
    }
}