diff --git a/CHANGELOG.md b/CHANGELOG.md index f733484..ab4b4fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Merge `Email` with `Msg` [#21] - List command with pagination [#19] - Icon in table when attachment is present [#16] +- Multi-account [#17] [unreleased]: https://github.com/soywod/himalaya @@ -41,5 +42,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#14]: https://github.com/soywod/himalaya/issues/14 [#15]: https://github.com/soywod/himalaya/issues/15 [#16]: https://github.com/soywod/himalaya/issues/16 +[#17]: https://github.com/soywod/himalaya/issues/17 [#19]: https://github.com/soywod/himalaya/issues/19 [#21]: https://github.com/soywod/himalaya/issues/21 diff --git a/src/config.rs b/src/config.rs index 33365d7..366081f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ -use lettre::transport::smtp::authentication::Credentials; +use lettre::transport::smtp::authentication::Credentials as SmtpCredentials; use serde::Deserialize; use std::{ + collections::HashMap, env, fmt, fs::File, io::{self, Read}, @@ -15,8 +16,11 @@ use toml; pub enum Error { IoError(io::Error), ParseTomlError(toml::de::Error), + ParseTomlAccountsError, GetEnvVarError(env::VarError), GetPathNotFoundError, + GetAccountNotFoundError(String), + GetAccountDefaultNotFoundError, } impl fmt::Display for Error { @@ -25,8 +29,11 @@ impl fmt::Display for Error { match self { Error::IoError(err) => err.fmt(f), Error::ParseTomlError(err) => err.fmt(f), + Error::ParseTomlAccountsError => write!(f, "no account found"), Error::GetEnvVarError(err) => err.fmt(f), Error::GetPathNotFoundError => write!(f, "path not found"), + Error::GetAccountNotFoundError(account) => write!(f, "account {} not found", account), + Error::GetAccountDefaultNotFoundError => write!(f, "no default account found"), } } } @@ -53,33 +60,48 @@ impl From for Error { type Result = result::Result; -// Config +// Account #[derive(Debug, Deserialize)] -pub struct ServerInfo { - pub host: String, - pub port: u16, - pub login: String, - pub password: String, +pub struct Account { + // Override + pub name: Option, + pub downloads_dir: Option, + + // Specific + pub default: Option, + pub email: String, + + pub imap_host: String, + pub imap_port: u16, + pub imap_login: String, + pub imap_password: String, + + pub smtp_host: String, + pub smtp_port: u16, + pub smtp_login: String, + pub smtp_password: String, } -impl ServerInfo { - pub fn get_addr(&self) -> (&str, u16) { - (&self.host, self.port) +impl Account { + pub fn imap_addr(&self) -> (&str, u16) { + (&self.imap_host, self.imap_port) } - pub fn to_smtp_creds(&self) -> Credentials { - Credentials::new(self.login.to_owned(), self.password.to_owned()) + pub fn smtp_creds(&self) -> SmtpCredentials { + SmtpCredentials::new(self.smtp_login.to_owned(), self.smtp_password.to_owned()) } } +// Config + #[derive(Debug, Deserialize)] pub struct Config { pub name: String, - pub email: String, pub downloads_dir: Option, - pub imap: ServerInfo, - pub smtp: ServerInfo, + + #[serde(flatten)] + pub accounts: HashMap, } impl Config { @@ -124,14 +146,35 @@ impl Config { Ok(toml::from_slice(&content)?) } - pub fn email_full(&self) -> String { - format!("{} <{}>", self.name, self.email) + pub fn get_account(&self, name: Option<&str>) -> Result<&Account> { + match name { + Some(name) => self + .accounts + .get(name) + .ok_or_else(|| Error::GetAccountNotFoundError(name.to_owned())), + None => self + .accounts + .iter() + .find(|(_, account)| account.default.unwrap_or(false)) + .map(|(_, account)| account) + .ok_or_else(|| Error::GetAccountDefaultNotFoundError), + } } - pub fn downloads_filepath(&self, filename: &str) -> PathBuf { + pub fn downloads_filepath(&self, account: &Account, filename: &str) -> PathBuf { let temp_dir = env::temp_dir(); - let mut full_path = self.downloads_dir.as_ref().unwrap_or(&temp_dir).to_owned(); + let mut full_path = account + .downloads_dir + .as_ref() + .unwrap_or(self.downloads_dir.as_ref().unwrap_or(&temp_dir)) + .to_owned(); + full_path.push(filename); full_path } + + pub fn address(&self, account: &Account) -> String { + let name = account.name.as_ref().unwrap_or(&self.name); + format!("{} <{}>", name, account.email) + } } diff --git a/src/imap.rs b/src/imap.rs index 3acfa97..6a90c31 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -2,7 +2,7 @@ use imap; use native_tls::{self, TlsConnector, TlsStream}; use std::{fmt, net::TcpStream, result}; -use crate::config; +use crate::config::Account; use crate::mbox::Mbox; use crate::msg::Msg; @@ -64,19 +64,19 @@ type Result = result::Result; #[derive(Debug)] pub struct ImapConnector<'a> { - pub config: &'a config::ServerInfo, + pub account: &'a Account, pub sess: imap::Session>, } impl<'a> ImapConnector<'a> { - pub fn new(config: &'a config::ServerInfo) -> Result { + pub fn new(account: &'a Account) -> Result { let tls = TlsConnector::new()?; - let client = imap::connect(config.get_addr(), &config.host, &tls)?; + let client = imap::connect(account.imap_addr(), &account.imap_host, &tls)?; let sess = client - .login(&config.login, &config.password) + .login(&account.imap_login, &account.imap_password) .map_err(|res| res.0)?; - Ok(Self { config, sess }) + Ok(Self { account, sess }) } pub fn close(&mut self) { diff --git a/src/main.rs b/src/main.rs index 9b6a8b3..fde3fe8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -117,6 +117,13 @@ fn run() -> Result<()> { .about("📫 Minimalist CLI email client") .author("soywod ") .setting(AppSettings::ArgRequiredElseHelp) + .arg( + Arg::with_name("account") + .long("account") + .short("a") + .help("Name of the config file to use") + .value_name("STRING"), + ) .subcommand( SubCommand::with_name("mailboxes") .aliases(&["mboxes", "mb", "m"]) @@ -191,9 +198,12 @@ fn run() -> Result<()> { ) .get_matches(); + let account_name = matches.value_of("account"); + if let Some(_) = matches.subcommand_matches("mailboxes") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mboxes = imap_conn.list_mboxes()?; println!("{}", mboxes.to_table()); @@ -203,7 +213,8 @@ fn run() -> Result<()> { if let Some(matches) = matches.subcommand_matches("list") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let page_size: u32 = matches @@ -225,7 +236,8 @@ fn run() -> Result<()> { if let Some(matches) = matches.subcommand_matches("search") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let page_size: usize = matches @@ -271,7 +283,8 @@ fn run() -> Result<()> { if let Some(matches) = matches.subcommand_matches("read") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); @@ -286,7 +299,8 @@ fn run() -> Result<()> { if let Some(matches) = matches.subcommand_matches("attachments") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); @@ -299,7 +313,7 @@ fn run() -> Result<()> { } else { println!("{} attachment(s) found for message {}", parts.len(), uid); parts.iter().for_each(|(filename, bytes)| { - let filepath = config.downloads_filepath(&filename); + let filepath = config.downloads_filepath(&account, &filename); println!("Downloading {} …", filename); fs::write(filepath, bytes).unwrap() }); @@ -311,16 +325,17 @@ fn run() -> Result<()> { if let Some(_) = matches.subcommand_matches("write") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; - let tpl = Msg::build_new_tpl(&config)?; + let tpl = Msg::build_new_tpl(&config, &account)?; let content = input::open_editor_with_tpl(&tpl.as_bytes())?; let msg = Msg::from(content); input::ask_for_confirmation("Send the message?")?; println!("Sending …"); - smtp::send(&config.smtp, &msg.to_sendable_msg()?)?; + smtp::send(&account, &msg.to_sendable_msg()?)?; imap_conn.append_msg("Sent", &msg.to_vec()?)?; println!("Done!"); @@ -329,16 +344,17 @@ fn run() -> Result<()> { if let Some(matches) = matches.subcommand_matches("reply") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); let tpl = if matches.is_present("reply-all") { - msg.build_reply_all_tpl(&config)? + msg.build_reply_all_tpl(&config, &account)? } else { - msg.build_reply_tpl(&config)? + msg.build_reply_tpl(&config, &account)? }; let content = input::open_editor_with_tpl(&tpl.as_bytes())?; @@ -347,7 +363,7 @@ fn run() -> Result<()> { input::ask_for_confirmation("Send the message?")?; println!("Sending …"); - smtp::send(&config.smtp, &msg.to_sendable_msg()?)?; + smtp::send(&account, &msg.to_sendable_msg()?)?; imap_conn.append_msg("Sent", &msg.to_vec()?)?; println!("Done!"); @@ -356,20 +372,21 @@ fn run() -> Result<()> { if let Some(matches) = matches.subcommand_matches("forward") { let config = Config::new_from_file()?; - let mut imap_conn = ImapConnector::new(&config.imap)?; + let account = config.get_account(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); - let tpl = msg.build_forward_tpl(&config)?; + let tpl = msg.build_forward_tpl(&config, &account)?; let content = input::open_editor_with_tpl(&tpl.as_bytes())?; let msg = Msg::from(content); input::ask_for_confirmation("Send the message?")?; println!("Sending …"); - smtp::send(&config.smtp, &msg.to_sendable_msg()?)?; + smtp::send(&account, &msg.to_sendable_msg()?)?; imap_conn.append_msg("Sent", &msg.to_vec()?)?; println!("Done!"); diff --git a/src/msg.rs b/src/msg.rs index aa2ccbe..8a43a12 100644 --- a/src/msg.rs +++ b/src/msg.rs @@ -2,8 +2,8 @@ use lettre; use mailparse::{self, MailHeaderMap}; use std::{fmt, result}; +use crate::config::{Account, Config}; use crate::table::{self, DisplayRow, DisplayTable}; -use crate::Config; // Error wrapper @@ -208,11 +208,11 @@ impl<'a> Msg { Ok(parts) } - pub fn build_new_tpl(config: &Config) -> Result { + pub fn build_new_tpl(config: &Config, account: &Account) -> Result { let mut tpl = vec![]; // "From" header - tpl.push(format!("From: {}", config.email_full())); + tpl.push(format!("From: {}", config.address(account))); // "To" header tpl.push("To: ".to_string()); @@ -223,13 +223,13 @@ impl<'a> Msg { Ok(tpl.join("\r\n")) } - pub fn build_reply_tpl(&self, config: &Config) -> Result { + pub fn build_reply_tpl(&self, config: &Config, account: &Account) -> Result { let msg = &self.parse()?; let headers = msg.get_headers(); let mut tpl = vec![]; // "From" header - tpl.push(format!("From: {}", config.email_full())); + tpl.push(format!("From: {}", config.address(account))); // "In-Reply-To" header if let Some(msg_id) = headers.get_first_value("message-id") { @@ -263,13 +263,13 @@ impl<'a> Msg { Ok(tpl.join("\r\n")) } - pub fn build_reply_all_tpl(&self, config: &Config) -> Result { + pub fn build_reply_all_tpl(&self, config: &Config, account: &Account) -> Result { let msg = &self.parse()?; let headers = msg.get_headers(); let mut tpl = vec![]; // "From" header - tpl.push(format!("From: {}", config.email_full())); + tpl.push(format!("From: {}", config.address(account))); // "In-Reply-To" header if let Some(msg_id) = headers.get_first_value("message-id") { @@ -278,7 +278,7 @@ impl<'a> Msg { // "To" header // All addresses coming from original "To" … - let email: lettre::Address = config.email.parse().unwrap(); + let email: lettre::Address = account.email.parse().unwrap(); let to = headers .get_all_values("to") .iter() @@ -345,13 +345,13 @@ impl<'a> Msg { Ok(tpl.join("\r\n")) } - pub fn build_forward_tpl(&self, config: &Config) -> Result { + pub fn build_forward_tpl(&self, config: &Config, account: &Account) -> Result { let msg = &self.parse()?; let headers = msg.get_headers(); let mut tpl = vec![]; // "From" header - tpl.push(format!("From: {}", config.email_full())); + tpl.push(format!("From: {}", config.address(account))); // "To" header tpl.push("To: ".to_string()); diff --git a/src/smtp.rs b/src/smtp.rs index fb30604..3c3f0c5 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -1,7 +1,7 @@ use lettre; use std::{fmt, result}; -use crate::config; +use crate::config::Account; // Error wrapper @@ -31,11 +31,11 @@ type Result = result::Result; // Utils -pub fn send(config: &config::ServerInfo, msg: &lettre::Message) -> Result<()> { +pub fn send(account: &Account, msg: &lettre::Message) -> Result<()> { use lettre::Transport; - lettre::transport::smtp::SmtpTransport::relay(&config.host)? - .credentials(config.to_smtp_creds()) + lettre::transport::smtp::SmtpTransport::relay(&account.smtp_host)? + .credentials(account.smtp_creds()) .build() .send(msg) .map(|_| Ok(()))?