diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c778cf..58a7f909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2021-03-10 + +### Added + +- STARTTLS support [#32] +- Flags [#25] + +### Changed + +- JSON support [#18] + ## [0.1.0] - 2021-01-17 ### Added @@ -30,7 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Password from command [#22] - Set up README [#20] -[unreleased]: https://github.com/soywod/himalaya/compare/v0.1.0...HEAD +[unreleased]: https://github.com/soywod/himalaya/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/soywod/himalaya/compare/v0.2.0...v0.1.0 [0.1.0]: https://github.com/soywod/himalaya/releases/tag/v0.1.0 [#1]: https://github.com/soywod/himalaya/issues/1 @@ -48,7 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#15]: https://github.com/soywod/himalaya/issues/15 [#16]: https://github.com/soywod/himalaya/issues/16 [#17]: https://github.com/soywod/himalaya/issues/17 +[#18]: https://github.com/soywod/himalaya/issues/18 [#19]: https://github.com/soywod/himalaya/issues/19 [#20]: https://github.com/soywod/himalaya/issues/20 [#21]: https://github.com/soywod/himalaya/issues/21 [#22]: https://github.com/soywod/himalaya/issues/22 +[#25]: https://github.com/soywod/himalaya/issues/25 +[#32]: https://github.com/soywod/himalaya/issues/32 diff --git a/Cargo.lock b/Cargo.lock index 2a1baa5c..ad7515aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,17 +228,19 @@ dependencies = [ [[package]] name = "himalaya" -version = "0.1.0" +version = "0.2.0" dependencies = [ "clap", "imap", "lettre", "mailparse", "native-tls", + "rfc2047-decoder", "serde", "serde_json", "terminal_size", "toml", + "uuid", ] [[package]] @@ -707,6 +709,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "rfc2047-decoder" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ecf2ba387f446155e26796aabb727e9ae1427dd13ac9cc21773a3fbda19d77" +dependencies = [ + "base64 0.13.0", + "charset", + "quoted_printable", +] + [[package]] name = "ryu" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 3529c02a..7636e37b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "himalaya" description = "📫 Minimalist CLI email client" -version = "0.1.0" +version = "0.2.0" authors = ["soywod <clement.douin@posteo.net>"] edition = "2018" @@ -11,7 +11,9 @@ imap = "2.4.0" lettre = "0.10.0-alpha.4" mailparse = "0.13.1" native-tls = "0.2" +rfc2047-decoder = "0.1.2" serde = { version = "1.0.118", features = ["derive"] } serde_json = "1.0.61" terminal_size = "0.1.15" toml = "0.5.8" +uuid = { version = "0.8", features = ["v4"] } diff --git a/README.md b/README.md index 2769c060..7e2f3c79 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 📫 Himalaya [](https://github.com/soywod/himalaya/actions?query=workflow%3Adeployment) +# 📫 Himalaya [WIP] [](https://github.com/soywod/himalaya/actions?query=workflow%3Adeployment) Minimalist CLI email client, written in Rust. @@ -8,28 +8,30 @@ Minimalist CLI email client, written in Rust. * [Motivation](#motivation) * [Installation](#installation) +* [Configuration](#configuration) * [Usage](#usage) * [List mailboxes](#list-mailboxes) - * [List emails](#list-emails) - * [Search emails](#search-emails) - * [Download email attachments](#download-email-attachments) - * [Read email](#read-email) - * [Reply email](#reply-email) - * [Forward email](#forward-email) + * [List messages](#list-messages) + * [Search messages](#search-messages) + * [Download attachments](#download-attachments) + * [Read a message](#read-a-message) + * [Write a new message](#write-a-new-message) + * [Reply to a message](#reply-to-a-message) + * [Forward a message](#forward-a-message) * [License](https://github.com/soywod/himalaya/blob/master/LICENSE) * [Changelog](https://github.com/soywod/himalaya/blob/master/CHANGELOG.md) * [Credits](#credits) ## Motivation -Bringing emails to your terminal is a pain. The mainstream TUI, (neo)mutt, -takes time to configure. The default mapping is not intuitive when coming from -the Vim environment. It is even scary to use at the beginning, since you are +Bringing emails to the terminal is a pain. The mainstream TUI, (neo)mutt, takes +time to configure. The default mapping is not intuitive when coming from the +Vim environment. It is even scary to use at the beginning, since you are dealing with sensitive data! -The aim of Himalaya is to extract the email logic into a simple CLI API that -can be used either directly for the terminal or from various interfaces. It -gives users more flexibility. +The aim of Himalaya is to extract the email logic into a simple (yet solid) CLI +API that can be used either directly from the terminal or UIs. It gives users +more flexibility. ## Installation @@ -46,7 +48,7 @@ more information.* # ~/.config/himalaya/config.toml name = "Your full name" -downloads_dir = "/abs/path/to/downloads" +downloads-dir = "/abs/path/to/downloads" # Himalaya supports the multi-account # Each account should be inside a TOML section @@ -54,32 +56,32 @@ downloads_dir = "/abs/path/to/downloads" default = true email = "my.email@gmail.com" -imap_host = "imap.gmail.com" -imap_port = 993 -imap_login = "p.durant@gmail.test.com" -imap_passwd_cmd = "pass show gmail" +imap-host = "imap.gmail.com" +imap-port = 993 +imap-login = "test@gmail.com" +imap-passwd_cmd = "pass show gmail" -smtp_host = "smtp.gmail.com" -smtp_port = 487 -smtp_login = "p.durant@gmail.test.com" -smtp_passwd_cmd = "pass show gmail" +smtp-host = "smtp.gmail.com" +smtp-port = 487 +smtp-login = "test@gmail.com" +smtp-passwd_cmd = "pass show gmail" [posteo] name = "Your overriden full name" -downloads_dir = "/abs/path/to/overriden/downloads" -email = "my.email@posteo.net" +downloads-dir = "/abs/path/to/overriden/downloads" +email = "test@posteo.net" -imap_host = "posteo.de" -imap_port = 993 -imap_login = "my.email@posteo.net" -imap_passwd_cmd = "security find-internet-password -gs posteo -w" +imap-host = "posteo.de" +imap-port = 993 +imap-login = "test@posteo.net" +imap-passwd_cmd = "security find-internet-password -gs posteo -w" -smtp_host = "posteo.de" -smtp_port = 487 -smtp_login = "my.email@posteo.net" -smtp_passwd_cmd = "security find-internet-password -gs posteo -w" +smtp-host = "posteo.de" +smtp-port = 487 +smtp-login = "test@posteo.net" +smtp-passwd_cmd = "security find-internet-password -gs posteo -w" -# [other account] +# [other accounts] # ... ``` @@ -89,7 +91,7 @@ more information.* ## Usage ``` -Himalaya 0.1.0 +Himalaya 0.2.0 soywod <clement.douin@posteo.net> 📫 Minimalist CLI email client @@ -101,7 +103,8 @@ FLAGS: -V, --version Prints version information OPTIONS: - -a, --account <STRING> Name of the config file to use + -a, --account <STRING> Name of the account to use + -o, --output <STRING> Format of the output to print [default: text] [possible values: text, json] SUBCOMMANDS: attachments Downloads all attachments from an email @@ -111,7 +114,10 @@ SUBCOMMANDS: mailboxes Lists all available mailboxes read Reads text bodies of an email reply Answers to an email + save Saves a raw message in the given mailbox search Lists emails matching the given IMAP query + send Sends a raw message + template Generates a message template write Writes a new email ``` @@ -120,191 +126,91 @@ information.* ### List mailboxes - - -``` -himalaya-mailboxes -Lists all available mailboxes +Shows mailboxes in a basic table. -USAGE: - himalaya mailboxes - -FLAGS: - -h, --help Prints help information - -V, --version Prints version information -``` + -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:mailboxes) +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:list-mailboxes) for more information.* -### List emails - - - -``` -himalaya-list -Lists emails sorted by arrival date - -USAGE: - himalaya list [OPTIONS] - -FLAGS: - -h, --help Prints help information - -V, --version Prints version information - -OPTIONS: - -m, --mailbox <STRING> Name of the mailbox [default: INBOX] - -p, --page <INT> Page number [default: 0] - -s, --size <INT> Page size [default: 10] -``` - -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:list) for -more information.* +### List messages -### Search emails +Shows messages in a basic table.  -``` -himalaya-search -Lists emails matching the given IMAP query - -USAGE: - himalaya search [OPTIONS] <QUERY>... - -FLAGS: - -h, --help Prints help information - -V, --version Prints version information - -OPTIONS: - -m, --mailbox <STRING> Name of the mailbox [default: INBOX] - -p, --page <INT> Page number [default: 0] - -s, --size <INT> Page size [default: 10] - -ARGS: - <QUERY>... IMAP query (see https://tools.ietf.org/html/rfc3501#section-6.4.4) -``` - -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:search) for +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:list-messages) for more information.* -### Download email attachments +### Search messages - +Shows filtered messages in a basic table. The query should follow the +[RFC-3501](https://tools.ietf.org/html/rfc3501#section-6.4.4). -``` -himalaya-attachments -Downloads all attachments from an email + -USAGE: - himalaya attachments [OPTIONS] <UID> +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:search-messages) for +more information.* -FLAGS: - -h, --help Prints help information - -V, --version Prints version information +### Download attachments -OPTIONS: - -m, --mailbox <STRING> Name of the mailbox [default: INBOX] +Downloads all attachments directly to the [`downloads-dir`](#configuration). -ARGS: - <UID> UID of the email -``` + -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:attachments) +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:download-attachments) for more information.* -### Read email +### Read a message -``` -himalaya-read -Reads text bodies of an email +Shows the text content of a message (`text/plain` if exists, otherwise +`text/html`). Can be overriden by the `--mime-type` option. -USAGE: - himalaya read [OPTIONS] <UID> - -FLAGS: - -h, --help Prints help information - -V, --version Prints version information + -OPTIONS: - -m, --mailbox <STRING> Name of the mailbox [default: INBOX] - -t, --mime-type <STRING> MIME type to use [default: plain] [possible values: plain, html] - -ARGS: - <UID> UID of the email -``` - -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:read) for +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:read-a-message) for more information.* -### Write email - -``` -himalaya-write -Writes a new email +### Write a new message -USAGE: - himalaya write +Opens your default editor (from the `$EDITOR` environment variable) to compose +a new message. -FLAGS: - -h, --help Prints help information - -V, --version Prints version information +```bash +himalaya write ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:write) for +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:write-a-new-message) for more information.* -### Reply email - -``` -himalaya-reply -Answers to an email +### Reply to a message -USAGE: - himalaya reply [FLAGS] [OPTIONS] <UID> +Opens your default editor to reply to a message. -FLAGS: - -h, --help Prints help information - -a, --all Including all recipients - -V, --version Prints version information - -OPTIONS: - -m, --mailbox <STRING> Name of the mailbox [default: INBOX] - -ARGS: - <UID> UID of the email +```bash +himalaya reply --all 5123 ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:reply) for +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:reply-to-a-message) for more information.* -### Forward email +### Forward a message -``` -himalaya-forward -Forwards an email - -USAGE: - himalaya forward [OPTIONS] <UID> - -FLAGS: - -h, --help Prints help information - -V, --version Prints version information +Opens your default editor to forward a message. -OPTIONS: - -m, --mailbox <STRING> Name of the mailbox [default: INBOX] - -ARGS: - <UID> UID of the email +```bash +himalaya forward 5123 ``` -*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:forward) for +*See [wiki section](https://github.com/soywod/himalaya/wiki/Usage:forward-a-message) for more information.* ## Credits - [IMAP RFC3501](https://tools.ietf.org/html/rfc3501) - [Iris](https://github.com/soywod/iris.vim), the himalaya predecessor -- [Neomutt](https://neomutt.org/) -- [Alpine](http://alpine.x10host.com/alpine/alpine-info/) -- [rust-imap](https://github.com/jonhoo/rust-imap) +- [isync](https://isync.sourceforge.io/), an email synchronizer for offline usage +- [NeoMutt](https://neomutt.org/), an email terminal user interface +- [Alpine](http://alpine.x10host.com/alpine/alpine-info/), an other email terminal user interface +- [mutt-wizard](https://github.com/LukeSmithxyz/mutt-wizard), a tool over NeoMutt and isync +- [rust-imap](https://github.com/jonhoo/rust-imap), a rust IMAP lib diff --git a/src/config.rs b/src/config.rs index b8915d8d..0d1b786e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ use std::{ }; use toml; -use crate::io::run_cmd; +use crate::output::{self, run_cmd}; // Error wrapper @@ -23,8 +23,7 @@ pub enum Error { GetPathNotFoundError, GetAccountNotFoundError(String), GetAccountDefaultNotFoundError, - ParseImapPasswdUtf8Error, - ParseSmtpPasswdUtf8Error, + OutputError(output::Error), } impl fmt::Display for Error { @@ -39,8 +38,7 @@ impl fmt::Display for Error { Error::GetPathNotFoundError => write!(f, "path not found"), Error::GetAccountNotFoundError(account) => write!(f, "account {} not found", account), Error::GetAccountDefaultNotFoundError => write!(f, "no default account found"), - Error::ParseImapPasswdUtf8Error => write!(f, "imap passwd invalid utf8"), - Error::ParseSmtpPasswdUtf8Error => write!(f, "smtp passwd invalid utf8"), + Error::OutputError(err) => err.fmt(f), } } } @@ -63,6 +61,12 @@ impl From<env::VarError> for Error { } } +impl From<output::Error> for Error { + fn from(err: output::Error) -> Error { + Error::OutputError(err) + } +} + // Result wrapper type Result<T> = result::Result<T, Error>; @@ -70,6 +74,7 @@ type Result<T> = result::Result<T, Error>; // Account #[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] pub struct Account { // Override pub name: Option<String>, @@ -81,42 +86,41 @@ pub struct Account { pub imap_host: String, pub imap_port: u16, + pub imap_starttls: Option<bool>, pub imap_login: String, pub imap_passwd_cmd: String, pub smtp_host: String, pub smtp_port: u16, + pub smtp_starttls: Option<bool>, pub smtp_login: String, pub smtp_passwd_cmd: String, } impl Account { + pub fn imap_addr(&self) -> (&str, u16) { + (&self.imap_host, self.imap_port) + } + pub fn imap_passwd(&self) -> Result<String> { - let cmd = run_cmd(&self.imap_passwd_cmd)?; - let passwd = String::from_utf8(cmd.stdout); - let passwd = passwd.map_err(|_| Error::ParseImapPasswdUtf8Error)?; + let passwd = run_cmd(&self.imap_passwd_cmd)?; let passwd = passwd.trim_end_matches("\n").to_owned(); Ok(passwd) } pub fn smtp_creds(&self) -> Result<SmtpCredentials> { - let cmd = run_cmd(&self.smtp_passwd_cmd)?; - let passwd = String::from_utf8(cmd.stdout); - let passwd = passwd.map_err(|_| Error::ParseImapPasswdUtf8Error)?; + let passwd = run_cmd(&self.smtp_passwd_cmd)?; let passwd = passwd.trim_end_matches("\n").to_owned(); Ok(SmtpCredentials::new(self.smtp_login.to_owned(), passwd)) } - - pub fn imap_addr(&self) -> (&str, u16) { - (&self.imap_host, self.imap_port) - } } // Config #[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] pub struct Config { pub name: String, pub downloads_dir: Option<PathBuf>, @@ -167,7 +171,7 @@ impl Config { Ok(toml::from_slice(&content)?) } - pub fn get_account(&self, name: Option<&str>) -> Result<&Account> { + pub fn find_account_by_name(&self, name: Option<&str>) -> Result<&Account> { match name { Some(name) => self .accounts diff --git a/src/imap.rs b/src/imap.rs index 2b99c409..4f9e6a2c 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -3,8 +3,8 @@ use native_tls::{self, TlsConnector, TlsStream}; use std::{fmt, net::TcpStream, result}; use crate::config::{self, Account}; -use crate::mbox::Mbox; -use crate::msg::Msg; +use crate::mbox::{Mbox, Mboxes}; +use crate::msg::{Msg, Msgs}; // Error wrapper @@ -80,7 +80,10 @@ pub struct ImapConnector<'a> { impl<'a> ImapConnector<'a> { pub fn new(account: &'a Account) -> Result<Self> { let tls = TlsConnector::new()?; - let client = imap::connect(account.imap_addr(), &account.imap_host, &tls)?; + let client = match account.imap_starttls { + Some(true) => imap::connect_starttls(account.imap_addr(), &account.imap_host, &tls), + _ => imap::connect(account.imap_addr(), &account.imap_host, &tls), + }?; let sess = client .login(&account.imap_login, &account.imap_passwd()?) .map_err(|res| res.0)?; @@ -88,13 +91,13 @@ impl<'a> ImapConnector<'a> { Ok(Self { account, sess }) } - pub fn close(&mut self) { - match self.sess.close() { + pub fn logout(&mut self) { + match self.sess.logout() { _ => (), } } - pub fn list_mboxes(&mut self) -> Result<Vec<Mbox>> { + pub fn list_mboxes(&mut self) -> Result<Mboxes> { let mboxes = self .sess .list(Some(""), Some("*"))? @@ -102,24 +105,24 @@ impl<'a> ImapConnector<'a> { .map(Mbox::from_name) .collect::<Vec<_>>(); - Ok(mboxes) + Ok(Mboxes(mboxes)) } - pub fn list_msgs(&mut self, mbox: &str, page_size: &u32, page: &u32) -> Result<Vec<Msg>> { + pub fn list_msgs(&mut self, mbox: &str, page_size: &u32, page: &u32) -> Result<Msgs> { let last_seq = self.sess.select(mbox)?.exists; - let begin = last_seq - (page * page_size); - let end = begin - (page_size - 1); + let begin = last_seq - page * page_size; + let end = begin - (begin - 1).min(page_size - 1); let range = format!("{}:{}", begin, end); let msgs = self .sess - .fetch(range, "(UID BODY.PEEK[])")? + .fetch(range, "(UID FLAGS ENVELOPE INTERNALDATE)")? .iter() .rev() .map(Msg::from) .collect::<Vec<_>>(); - Ok(msgs) + Ok(Msgs(msgs)) } pub fn search_msgs( @@ -128,7 +131,7 @@ impl<'a> ImapConnector<'a> { query: &str, page_size: &usize, page: &usize, - ) -> Result<Vec<Msg>> { + ) -> Result<Msgs> { self.sess.select(mbox)?; let begin = page * page_size; @@ -143,13 +146,12 @@ impl<'a> ImapConnector<'a> { let msgs = self .sess - .fetch(range, "(UID BODY.PEEK[])")? + .fetch(range, "(UID ENVELOPE INTERNALDATE)")? .iter() - .rev() .map(Msg::from) .collect::<Vec<_>>(); - Ok(msgs) + Ok(Msgs(msgs)) } pub fn read_msg(&mut self, mbox: &str, uid: &str) -> Result<Vec<u8>> { diff --git a/src/io.rs b/src/input.rs similarity index 87% rename from src/io.rs rename to src/input.rs index 80de57d1..e59418cb 100644 --- a/src/io.rs +++ b/src/input.rs @@ -2,7 +2,7 @@ use std::{ env, fmt, fs::{remove_file, File}, io::{self, Read, Write}, - process::{Command, Output}, + process::Command, result, }; @@ -78,11 +78,3 @@ pub fn ask_for_confirmation(prompt: &str) -> Result<()> { _ => Err(Error::AskForConfirmationDeniedError), } } - -pub fn run_cmd(cmd: &str) -> io::Result<Output> { - if cfg!(target_os = "windows") { - Command::new("cmd").args(&["/C", cmd]).output() - } else { - Command::new("sh").arg("-c").arg(cmd).output() - } -} diff --git a/src/main.rs b/src/main.rs index 14559907..dc9eb803 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ mod config; mod imap; -mod io; +mod input; mod mbox; mod msg; +mod output; mod smtp; mod table; @@ -11,8 +12,8 @@ use std::{fmt, fs, process::exit, result}; use crate::config::Config; use crate::imap::ImapConnector; -use crate::msg::Msg; -use crate::table::DisplayTable; +use crate::msg::{Attachments, Msg, ReadableMsg}; +use crate::output::print; const DEFAULT_PAGE_SIZE: usize = 10; const DEFAULT_PAGE: usize = 0; @@ -20,7 +21,8 @@ const DEFAULT_PAGE: usize = 0; #[derive(Debug)] pub enum Error { ConfigError(config::Error), - IoError(io::Error), + InputError(input::Error), + OutputError(output::Error), MsgError(msg::Error), ImapError(imap::Error), SmtpError(smtp::Error), @@ -30,7 +32,8 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Error::ConfigError(err) => err.fmt(f), - Error::IoError(err) => err.fmt(f), + Error::InputError(err) => err.fmt(f), + Error::OutputError(err) => err.fmt(f), Error::MsgError(err) => err.fmt(f), Error::ImapError(err) => err.fmt(f), Error::SmtpError(err) => err.fmt(f), @@ -44,9 +47,15 @@ impl From<config::Error> for Error { } } -impl From<crate::io::Error> for Error { - fn from(err: crate::io::Error) -> Error { - Error::IoError(err) +impl From<input::Error> for Error { + fn from(err: input::Error) -> Error { + Error::InputError(err) + } +} + +impl From<output::Error> for Error { + fn from(err: output::Error) -> Error { + Error::OutputError(err) } } @@ -90,6 +99,13 @@ fn uid_arg() -> Arg<'static, 'static> { .required(true) } +fn reply_all_arg() -> Arg<'static, 'static> { + Arg::with_name("reply-all") + .help("Includes all recipients") + .short("a") + .long("all") +} + fn page_size_arg<'a>(default: &'a str) -> Arg<'a, 'a> { Arg::with_name("size") .help("Page size") @@ -113,20 +129,29 @@ fn run() -> Result<()> { let default_page_str = &DEFAULT_PAGE.to_string(); let matches = App::new("Himalaya") - .version("0.1.0") + .version("0.2.0") .about("📫 Minimalist CLI email client") .author("soywod <clement.douin@posteo.net>") .setting(AppSettings::ArgRequiredElseHelp) + .arg( + Arg::with_name("output") + .long("output") + .short("o") + .help("Format of the output to print") + .value_name("STRING") + .possible_values(&["text", "json"]) + .default_value("text"), + ) .arg( Arg::with_name("account") .long("account") .short("a") - .help("Name of the config file to use") + .help("Name of the account to use") .value_name("STRING"), ) .subcommand( SubCommand::with_name("mailboxes") - .aliases(&["mboxes", "mb", "m"]) + .aliases(&["mboxes", "mbox", "mb", "m"]) .about("Lists all available mailboxes"), ) .subcommand( @@ -170,7 +195,7 @@ fn run() -> Result<()> { ) .subcommand( SubCommand::with_name("attachments") - .aliases(&["attach", "a"]) + .aliases(&["attach", "att", "a"]) .about("Downloads all attachments from an email") .arg(uid_arg()) .arg(mailbox_arg()), @@ -182,12 +207,7 @@ fn run() -> Result<()> { .about("Answers to an email") .arg(uid_arg()) .arg(mailbox_arg()) - .arg( - Arg::with_name("reply-all") - .help("Including all recipients") - .short("a") - .long("all"), - ), + .arg(reply_all_arg()), ) .subcommand( SubCommand::with_name("forward") @@ -196,24 +216,62 @@ fn run() -> Result<()> { .arg(uid_arg()) .arg(mailbox_arg()), ) + .subcommand( + SubCommand::with_name("send") + .about("Sends a raw message") + .arg(Arg::with_name("message").raw(true)), + ) + .subcommand( + SubCommand::with_name("save") + .about("Saves a raw message in the given mailbox") + .arg(mailbox_arg()) + .arg(Arg::with_name("message").raw(true)), + ) + .subcommand( + SubCommand::with_name("template") + .aliases(&["tpl", "t"]) + .about("Generates a message template") + .subcommand( + SubCommand::with_name("new") + .aliases(&["n"]) + .about("Generates a new message template") + .arg(mailbox_arg()), + ) + .subcommand( + SubCommand::with_name("reply") + .aliases(&["rep", "r"]) + .about("Generates a reply message template") + .arg(uid_arg()) + .arg(mailbox_arg()) + .arg(reply_all_arg()), + ) + .subcommand( + SubCommand::with_name("forward") + .aliases(&["fwd", "fw", "f"]) + .about("Generates a forward message template") + .arg(uid_arg()) + .arg(mailbox_arg()), + ), + ) .get_matches(); let account_name = matches.value_of("account"); + let output_type = matches.value_of("output").unwrap().to_owned(); if let Some(_) = matches.subcommand_matches("mailboxes") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; let mboxes = imap_conn.list_mboxes()?; - println!("{}", mboxes.to_table()); + print(&output_type, mboxes)?; - imap_conn.close(); + imap_conn.logout(); } if let Some(matches) = matches.subcommand_matches("list") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); @@ -229,16 +287,15 @@ fn run() -> Result<()> { .unwrap_or(DEFAULT_PAGE as u32); let msgs = imap_conn.list_msgs(&mbox, &page_size, &page)?; - println!("{}", msgs.to_table()); + print(&output_type, msgs)?; - imap_conn.close(); + imap_conn.logout(); } if let Some(matches) = matches.subcommand_matches("search") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; - let mbox = matches.value_of("mailbox").unwrap(); let page_size: usize = matches .value_of("size") @@ -276,75 +333,124 @@ fn run() -> Result<()> { .join(" "); let msgs = imap_conn.search_msgs(&mbox, &query, &page_size, &page)?; - println!("{}", msgs.to_table()); + print(&output_type, msgs)?; - imap_conn.close(); + imap_conn.logout(); } if let Some(matches) = matches.subcommand_matches("read") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); let uid = matches.value_of("uid").unwrap(); let mime = format!("text/{}", matches.value_of("mime-type").unwrap()); - let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); - let text_bodies = msg.text_bodies(&mime)?; - println!("{}", text_bodies); + let msg = imap_conn.read_msg(&mbox, &uid)?; + let msg = ReadableMsg::from_bytes(&mime, &msg)?; - imap_conn.close(); + print(&output_type, msg)?; + imap_conn.logout(); } if let Some(matches) = matches.subcommand_matches("attachments") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(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 parts = msg.extract_attachments()?; - - if parts.is_empty() { - println!("No attachment found for message {}", uid); - } else { - println!("{} attachment(s) found for message {}", parts.len(), uid); - parts.iter().for_each(|(filename, bytes)| { - let filepath = config.downloads_filepath(&account, &filename); - println!("Downloading {} …", filename); - fs::write(filepath, bytes).unwrap() - }); - println!("Done!"); + let msg = imap_conn.read_msg(&mbox, &uid)?; + let attachments = Attachments::from_bytes(&msg)?; + + match output_type.as_str() { + "text" => { + println!( + "{} attachment(s) found for message {}", + attachments.0.len(), + uid + ); + + attachments.0.iter().for_each(|attachment| { + let filepath = config.downloads_filepath(&account, &attachment.filename); + println!("Downloading {}…", &attachment.filename); + fs::write(filepath, &attachment.raw).unwrap() + }); + + println!("Done!"); + } + "json" => { + attachments.0.iter().for_each(|attachment| { + let filepath = config.downloads_filepath(&account, &attachment.filename); + fs::write(filepath, &attachment.raw).unwrap() + }); + + print!("{{}}"); + } + _ => (), } - imap_conn.close(); + imap_conn.logout(); } if let Some(_) = matches.subcommand_matches("write") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; - let tpl = Msg::build_new_tpl(&config, &account)?; - let content = io::open_editor_with_tpl(&tpl.as_bytes())?; + let content = input::open_editor_with_tpl(tpl.to_string().as_bytes())?; let msg = Msg::from(content); - io::ask_for_confirmation("Send the message?")?; + input::ask_for_confirmation("Send the message?")?; - println!("Sending …"); + println!("Sending…"); smtp::send(&account, &msg.to_sendable_msg()?)?; imap_conn.append_msg("Sent", &msg.to_vec()?)?; println!("Done!"); - imap_conn.close(); + imap_conn.logout(); + } + + if let Some(matches) = matches.subcommand_matches("template") { + let config = Config::new_from_file()?; + let account = config.find_account_by_name(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; + + if let Some(_) = matches.subcommand_matches("new") { + let tpl = Msg::build_new_tpl(&config, &account)?; + print(&output_type, &tpl)?; + } + + if let Some(matches) = matches.subcommand_matches("reply") { + let uid = matches.value_of("uid").unwrap(); + let mbox = matches.value_of("mailbox").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, &account)? + } else { + msg.build_reply_tpl(&config, &account)? + }; + + print(&output_type, &tpl)?; + } + + if let Some(matches) = matches.subcommand_matches("forward") { + let uid = matches.value_of("uid").unwrap(); + let mbox = matches.value_of("mailbox").unwrap(); + + let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); + let tpl = msg.build_forward_tpl(&config, &account)?; + + print(&output_type, &tpl)?; + } } if let Some(matches) = matches.subcommand_matches("reply") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); @@ -357,22 +463,22 @@ fn run() -> Result<()> { msg.build_reply_tpl(&config, &account)? }; - let content = io::open_editor_with_tpl(&tpl.as_bytes())?; + let content = input::open_editor_with_tpl(&tpl.to_string().as_bytes())?; let msg = Msg::from(content); - io::ask_for_confirmation("Send the message?")?; + input::ask_for_confirmation("Send the message?")?; - println!("Sending …"); + println!("Sending…"); smtp::send(&account, &msg.to_sendable_msg()?)?; imap_conn.append_msg("Sent", &msg.to_vec()?)?; println!("Done!"); - imap_conn.close(); + imap_conn.logout(); } if let Some(matches) = matches.subcommand_matches("forward") { let config = Config::new_from_file()?; - let account = config.get_account(account_name)?; + let account = config.find_account_by_name(account_name)?; let mut imap_conn = ImapConnector::new(&account)?; let mbox = matches.value_of("mailbox").unwrap(); @@ -380,17 +486,43 @@ fn run() -> Result<()> { let msg = Msg::from(imap_conn.read_msg(&mbox, &uid)?); let tpl = msg.build_forward_tpl(&config, &account)?; - let content = io::open_editor_with_tpl(&tpl.as_bytes())?; + let content = input::open_editor_with_tpl(&tpl.to_string().as_bytes())?; let msg = Msg::from(content); - io::ask_for_confirmation("Send the message?")?; + input::ask_for_confirmation("Send the message?")?; - println!("Sending …"); + println!("Sending…"); smtp::send(&account, &msg.to_sendable_msg()?)?; imap_conn.append_msg("Sent", &msg.to_vec()?)?; println!("Done!"); - imap_conn.close(); + imap_conn.logout(); + } + + if let Some(matches) = matches.subcommand_matches("send") { + let config = Config::new_from_file()?; + let account = config.find_account_by_name(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; + + let msg = matches.value_of("message").unwrap(); + let msg = Msg::from(msg.to_string()); + + smtp::send(&account, &msg.to_sendable_msg()?)?; + imap_conn.append_msg("Sent", &msg.to_vec()?)?; + imap_conn.logout(); + } + + if let Some(matches) = matches.subcommand_matches("save") { + let config = Config::new_from_file()?; + let account = config.find_account_by_name(account_name)?; + let mut imap_conn = ImapConnector::new(&account)?; + + let mbox = matches.value_of("mailbox").unwrap(); + let msg = matches.value_of("message").unwrap(); + let msg = Msg::from(msg.to_string()); + + imap_conn.append_msg(mbox, &msg.to_vec()?)?; + imap_conn.logout(); } Ok(()) diff --git a/src/mbox.rs b/src/mbox.rs index 8f6290e0..2cb894e9 100644 --- a/src/mbox.rs +++ b/src/mbox.rs @@ -1,7 +1,12 @@ use imap; +use serde::Serialize; +use std::fmt; use crate::table::{self, DisplayRow, DisplayTable}; +// Mbox + +#[derive(Debug, Serialize)] pub struct Mbox { pub delim: String, pub name: String, @@ -30,7 +35,12 @@ impl DisplayRow for Mbox { } } -impl<'a> DisplayTable<'a, Mbox> for Vec<Mbox> { +// Mboxes + +#[derive(Debug, Serialize)] +pub struct Mboxes(pub Vec<Mbox>); + +impl<'a> DisplayTable<'a, Mbox> for Mboxes { fn header_row() -> Vec<table::Cell> { use crate::table::*; @@ -42,6 +52,12 @@ impl<'a> DisplayTable<'a, Mbox> for Vec<Mbox> { } fn rows(&self) -> &Vec<Mbox> { - self + &self.0 + } +} + +impl fmt::Display for Mboxes { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.to_table()) } } diff --git a/src/msg.rs b/src/msg.rs index 8a43a127..b7dfbad0 100644 --- a/src/msg.rs +++ b/src/msg.rs @@ -1,6 +1,12 @@ use lettre; use mailparse::{self, MailHeaderMap}; +use rfc2047_decoder; +use serde::{ + ser::{self, SerializeStruct}, + Serialize, +}; use std::{fmt, result}; +use uuid::Uuid; use crate::config::{Account, Config}; use crate::table::{self, DisplayRow, DisplayTable}; @@ -39,41 +45,234 @@ impl From<lettre::error::Error> for Error { type Result<T> = result::Result<T, Error>; -// Msg +// Template #[derive(Debug)] -pub struct Msg { - pub uid: u32, - pub flags: Vec<String>, - raw: Vec<u8>, +pub struct Tpl(String); + +impl fmt::Display for Tpl { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } } -impl From<String> for Msg { - fn from(item: String) -> Self { +impl Serialize for Tpl { + fn serialize<S>(&self, serializer: S) -> result::Result<S::Ok, S::Error> + where + S: ser::Serializer, + { + let mut state = serializer.serialize_struct("Tpl", 1)?; + state.serialize_field("template", &self.0)?; + state.end() + } +} + +// Attachments + +#[derive(Debug)] +pub struct Attachment { + pub filename: String, + pub raw: Vec<u8>, +} + +impl<'a> Attachment { + // TODO: put in common with ReadableMsg + pub fn from_part(part: &'a mailparse::ParsedMail) -> Self { Self { - uid: 0, - flags: vec![], - raw: item.as_bytes().to_vec(), + filename: part + .get_content_disposition() + .params + .get("filename") + .unwrap_or(&Uuid::new_v4().to_simple().to_string()) + .to_owned(), + raw: part.get_body_raw().unwrap_or_default(), + } + } +} + +#[derive(Debug)] +pub struct Attachments(pub Vec<Attachment>); + +impl<'a> Attachments { + fn extract_from_part(&'a mut self, part: &'a mailparse::ParsedMail) { + if part.subparts.is_empty() { + let ctype = part + .get_headers() + .get_first_value("content-type") + .unwrap_or_default(); + + if !ctype.starts_with("text") { + self.0.push(Attachment::from_part(part)); + } + } else { + part.subparts + .iter() + .for_each(|part| self.extract_from_part(part)); + } + } + + pub fn from_bytes(bytes: &[u8]) -> Result<Self> { + let msg = mailparse::parse_mail(bytes)?; + let mut attachments = Self(vec![]); + attachments.extract_from_part(&msg); + Ok(attachments) + } +} + +// Readable message + +#[derive(Debug)] +pub struct ReadableMsg { + pub content: String, + pub has_attachment: bool, +} + +impl Serialize for ReadableMsg { + fn serialize<S>(&self, serializer: S) -> result::Result<S::Ok, S::Error> + where + S: ser::Serializer, + { + let mut state = serializer.serialize_struct("ReadableMsg", 2)?; + state.serialize_field("content", &self.content)?; + state.serialize_field("hasAttachment", if self.has_attachment { &1 } else { &0 })?; + state.end() + } +} + +impl fmt::Display for ReadableMsg { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.content) + } +} + +impl<'a> ReadableMsg { + fn flatten_parts(part: &'a mailparse::ParsedMail) -> Vec<&'a mailparse::ParsedMail<'a>> { + if part.subparts.is_empty() { + vec![part] + } else { + part.subparts + .iter() + .flat_map(Self::flatten_parts) + .collect::<Vec<_>>() } } + + pub fn from_bytes(mime: &str, bytes: &[u8]) -> Result<Self> { + let msg = mailparse::parse_mail(bytes)?; + let (text_part, html_part, has_attachment) = Self::flatten_parts(&msg).into_iter().fold( + (None, None, false), + |(mut text_part, mut html_part, mut has_attachment), part| { + let ctype = part + .get_headers() + .get_first_value("content-type") + .unwrap_or_default(); + + if text_part.is_none() && ctype.starts_with("text/plain") { + text_part = part.get_body().ok(); + } else { + if html_part.is_none() && ctype.starts_with("text/html") { + html_part = part.get_body().ok(); + } else { + has_attachment = true + }; + }; + + (text_part, html_part, has_attachment) + }, + ); + + let content = if mime == "text/plain" { + text_part.or(html_part).unwrap_or_default() + } else { + html_part.or(text_part).unwrap_or_default() + }; + + Ok(Self { + content, + has_attachment, + }) + } +} + +// Message + +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Flag { + Seen, + Answered, + Flagged, +} + +impl Flag { + fn from_imap_flag(flag: &imap::types::Flag<'_>) -> Option<Self> { + match flag { + imap::types::Flag::Seen => Some(Self::Seen), + imap::types::Flag::Answered => Some(Self::Answered), + imap::types::Flag::Flagged => Some(Self::Flagged), + _ => None, + } + } +} + +#[derive(Debug, Serialize)] +pub struct Msg { + pub uid: u32, + pub flags: Vec<Flag>, + pub subject: String, + pub sender: String, + pub date: String, + + #[serde(skip_serializing)] + raw: Vec<u8>, } impl From<Vec<u8>> for Msg { - fn from(item: Vec<u8>) -> Self { + fn from(raw: Vec<u8>) -> Self { Self { uid: 0, flags: vec![], - raw: item, + subject: String::from(""), + sender: String::from(""), + date: String::from(""), + raw, } } } +impl From<String> for Msg { + fn from(raw: String) -> Self { + Self::from(raw.as_bytes().to_vec()) + } +} + impl From<&imap::types::Fetch> for Msg { fn from(fetch: &imap::types::Fetch) -> Self { - Self { - uid: fetch.uid.unwrap_or_default(), - flags: vec![], - raw: fetch.body().unwrap_or_default().to_vec(), + match fetch.envelope() { + None => Self::from(fetch.body().unwrap_or_default().to_vec()), + Some(envelope) => Self { + uid: fetch.uid.unwrap_or_default(), + flags: fetch + .flags() + .into_iter() + .filter_map(Flag::from_imap_flag) + .collect::<Vec<_>>(), + subject: envelope + .subject + .and_then(|subj| rfc2047_decoder::decode(subj).ok()) + .unwrap_or_default(), + sender: envelope + .from + .as_ref() + .and_then(|addrs| addrs.first()?.name) + .and_then(|name| rfc2047_decoder::decode(name).ok()) + .unwrap_or_default(), + date: fetch + .internal_date() + .map(|date| date.naive_local().to_string()) + .unwrap_or_default(), + raw: fetch.body().unwrap_or_default().to_vec(), + }, } } } @@ -174,43 +373,13 @@ impl<'a> Msg { Ok(text_bodies.join("\r\n")) } - fn extract_attachments_into(part: &mailparse::ParsedMail, parts: &mut Vec<(String, Vec<u8>)>) { - match part.subparts.len() { - 0 => { - let content_disp = part.get_content_disposition(); - let content_type = part - .get_headers() - .get_first_value("content-type") - .unwrap_or_default(); - - let default_attachment_name = format!("attachment-{}", parts.len()); - let attachment_name = content_disp - .params - .get("filename") - .unwrap_or(&default_attachment_name) - .to_owned(); - - if !content_type.starts_with("text") { - parts.push((attachment_name, part.get_body_raw().unwrap_or_default())) - } - } - _ => { - part.subparts - .iter() - .for_each(|part| Self::extract_attachments_into(part, parts)); - } - } - } - - pub fn extract_attachments(&self) -> Result<Vec<(String, Vec<u8>)>> { - let mut parts = vec![]; - Self::extract_attachments_into(&self.parse()?, &mut parts); - Ok(parts) - } - - pub fn build_new_tpl(config: &Config, account: &Account) -> Result<String> { + pub fn build_new_tpl(config: &Config, account: &Account) -> Result<Tpl> { let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -220,14 +389,18 @@ impl<'a> Msg { // "Subject" header tpl.push("Subject: ".to_string()); - Ok(tpl.join("\r\n")) + Ok(Tpl(tpl.join("\r\n"))) } - pub fn build_reply_tpl(&self, config: &Config, account: &Account) -> Result<String> { + pub fn build_reply_tpl(&self, config: &Config, account: &Account) -> Result<Tpl> { let msg = &self.parse()?; let headers = msg.get_headers(); let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -251,23 +424,27 @@ impl<'a> Msg { tpl.push(String::new()); // Original msg prepend with ">" - let thread = msg - .get_body() - .unwrap() - .split("\r\n") + let thread = self + .text_bodies("text/plain")? + .replace("\r", "") + .split("\n") .map(|line| format!(">{}", line)) .collect::<Vec<String>>() .join("\r\n"); tpl.push(thread); - Ok(tpl.join("\r\n")) + Ok(Tpl(tpl.join("\r\n"))) } - pub fn build_reply_all_tpl(&self, config: &Config, account: &Account) -> Result<String> { + pub fn build_reply_all_tpl(&self, config: &Config, account: &Account) -> Result<Tpl> { let msg = &self.parse()?; let headers = msg.get_headers(); let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -333,23 +510,26 @@ impl<'a> Msg { tpl.push(String::new()); // Original msg prepend with ">" - let thread = msg - .get_body() - .unwrap() + let thread = self + .text_bodies("text/plain")? .split("\r\n") .map(|line| format!(">{}", line)) .collect::<Vec<String>>() .join("\r\n"); tpl.push(thread); - Ok(tpl.join("\r\n")) + Ok(Tpl(tpl.join("\r\n"))) } - pub fn build_forward_tpl(&self, config: &Config, account: &Account) -> Result<String> { + pub fn build_forward_tpl(&self, config: &Config, account: &Account) -> Result<Tpl> { let msg = &self.parse()?; let headers = msg.get_headers(); let mut tpl = vec![]; + // "Content" headers + tpl.push("Content-Type: text/plain; charset=utf-8".to_string()); + tpl.push("Content-Transfer-Encoding: 8bit".to_string()); + // "From" header tpl.push(format!("From: {}", config.address(account))); @@ -365,54 +545,36 @@ impl<'a> Msg { // Original msg tpl.push("-------- Forwarded Message --------".to_string()); - tpl.push(msg.get_body().unwrap_or(String::new())); + tpl.push(self.text_bodies("text/plain")?); - Ok(tpl.join("\r\n")) + Ok(Tpl(tpl.join("\r\n"))) } } impl DisplayRow for Msg { fn to_row(&self) -> Vec<table::Cell> { - match self.parse() { - Err(_) => vec![], - Ok(parsed) => { - let headers = parsed.get_headers(); - - let uid = &self.uid.to_string(); - let flags = match self.extract_attachments().map(|vec| vec.is_empty()) { - Ok(false) => "", - _ => " ", - }; - let sender = headers - .get_first_value("reply-to") - .or(headers.get_first_value("from")) - .unwrap_or_default(); - let subject = headers.get_first_value("subject").unwrap_or_default(); - let date = headers.get_first_value("date").unwrap_or_default(); - - { - use crate::table::*; - - vec![ - Cell::new(&[RED], &uid), - Cell::new(&[WHITE], &flags), - Cell::new(&[BLUE], &sender), - FlexCell::new(&[GREEN], &subject), - Cell::new(&[YELLOW], &date), - ] - } - } - } + use crate::table::*; + + vec![ + Cell::new(&[RED], &self.uid.to_string()), + Cell::new(&[BLUE], &self.sender), + FlexCell::new(&[GREEN], &self.subject), + Cell::new(&[YELLOW], &self.date), + ] } } -impl<'a> DisplayTable<'a, Msg> for Vec<Msg> { +// Msgs + +#[derive(Debug, Serialize)] +pub struct Msgs(pub Vec<Msg>); + +impl<'a> DisplayTable<'a, Msg> for Msgs { fn header_row() -> Vec<table::Cell> { use crate::table::*; vec![ Cell::new(&[BOLD, UNDERLINE, WHITE], "UID"), - Cell::new(&[BOLD, UNDERLINE, WHITE], "FLAGS"), Cell::new(&[BOLD, UNDERLINE, WHITE], "SENDER"), FlexCell::new(&[BOLD, UNDERLINE, WHITE], "SUBJECT"), Cell::new(&[BOLD, UNDERLINE, WHITE], "DATE"), @@ -420,6 +582,12 @@ impl<'a> DisplayTable<'a, Msg> for Vec<Msg> { } fn rows(&self) -> &Vec<Msg> { - self + &self.0 + } +} + +impl fmt::Display for Msgs { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.to_table()) } } diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 00000000..70636129 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,71 @@ +use serde::Serialize; +use std::{ + fmt::{self, Display}, + io, + process::Command, + result, string, +}; + +// Error wrapper + +#[derive(Debug)] +pub enum Error { + IoError(io::Error), + ParseUtf8Error(string::FromUtf8Error), + SerializeJsonError(serde_json::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "input: ")?; + + match self { + Error::IoError(err) => err.fmt(f), + Error::ParseUtf8Error(err) => err.fmt(f), + Error::SerializeJsonError(err) => err.fmt(f), + } + } +} + +impl From<io::Error> for Error { + fn from(err: io::Error) -> Error { + Error::IoError(err) + } +} + +impl From<string::FromUtf8Error> for Error { + fn from(err: string::FromUtf8Error) -> Error { + Error::ParseUtf8Error(err) + } +} + +impl From<serde_json::Error> for Error { + fn from(err: serde_json::Error) -> Error { + Error::SerializeJsonError(err) + } +} + +// Result wrapper + +type Result<T> = result::Result<T, Error>; + +// Utils + +pub fn run_cmd(cmd: &str) -> Result<String> { + let output = if cfg!(target_os = "windows") { + Command::new("cmd").args(&["/C", cmd]).output()? + } else { + Command::new("sh").arg("-c").arg(cmd).output()? + }; + + Ok(String::from_utf8(output.stdout)?) +} + +pub fn print<T: Display + Serialize>(output_type: &str, item: T) -> Result<()> { + match output_type { + "json" => print!("{}", serde_json::to_string(&item)?), + "text" | _ => println!("{}", item.to_string()), + } + + Ok(()) +} diff --git a/src/smtp.rs b/src/smtp.rs index fd4a338e..8da58a03 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -42,6 +42,9 @@ type Result<T> = result::Result<T, Error>; pub fn send(account: &Account, msg: &lettre::Message) -> Result<()> { use lettre::Transport; + // TODO + // lettre::transport::smtp::SmtpTransport::starttls_relay + lettre::transport::smtp::SmtpTransport::relay(&account.smtp_host)? .credentials(account.smtp_creds()?) .build() diff --git a/src/table.rs b/src/table.rs index 5fce500e..6eb28a0a 100644 --- a/src/table.rs +++ b/src/table.rs @@ -60,15 +60,11 @@ impl Cell { let style_end = "\x1b[0m"; if col_size > 0 && self.printable_value_len() > col_size { - let col_size = self - .value - .char_indices() - .map(|(i, _)| i) - .nth(col_size) - .unwrap() - - 2; - - String::from(style_begin + &self.value[0..=col_size] + "… " + style_end) + let value: String = self.value.chars().collect::<Vec<_>>()[0..=col_size - 2] + .into_iter() + .collect(); + + String::from(style_begin + &value + "… " + style_end) } else { let padding = if col_size == 0 { "".to_string()