diff --git a/Cargo.lock b/Cargo.lock index 1345b5d..f360485 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,27 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b" +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if 1.0.0", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "encoding_rs" version = "0.8.26" @@ -200,6 +221,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "1.1.0" @@ -217,6 +248,17 @@ dependencies = [ "wasi 0.9.0+wasi-snapshot-preview1", ] +[[package]] +name = "getrandom" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4060f4657be78b8e766215b02b18a2e862d83745545de804638e2b545e81aee6" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", +] + [[package]] name = "hermit-abi" version = "0.1.17" @@ -236,6 +278,7 @@ dependencies = [ "mailparse", "native-tls", "rfc2047-decoder", + "rustyline", "serde", "toml", ] @@ -468,6 +511,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ccba0cfe4fdf15982d1674c69b1fd80bad427d293849982668dfe454bd61f2" +dependencies = [ + "bitflags", + "cc", + "cfg-if 1.0.0", + "libc", +] + [[package]] name = "nom" version = "5.1.2" @@ -568,7 +623,7 @@ dependencies = [ "cfg-if 1.0.0", "instant", "libc", - "redox_syscall", + "redox_syscall 0.1.57", "smallvec", "winapi", ] @@ -638,7 +693,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" dependencies = [ - "getrandom", + "getrandom 0.1.15", "libc", "rand_chacha", "rand_core", @@ -661,7 +716,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" dependencies = [ - "getrandom", + "getrandom 0.1.15", ] [[package]] @@ -679,6 +734,25 @@ version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" +[[package]] +name = "redox_syscall" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec8ca9416c5ea37062b502703cd7fcb207736bc294f6e0cf367ac6fc234570" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom 0.2.1", + "redox_syscall 0.2.4", +] + [[package]] name = "regex" version = "1.4.2" @@ -717,6 +791,27 @@ dependencies = [ "quoted_printable", ] +[[package]] +name = "rustyline" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8227301bfc717136f0ecbd3d064ba8199e44497a0bdd46bb01ede4387cfd2cec" +dependencies = [ + "bitflags", + "cfg-if 1.0.0", + "dirs-next", + "fs2", + "libc", + "log", + "memchr", + "nix", + "scopeguard", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "winapi", +] + [[package]] name = "ryu" version = "1.0.5" @@ -846,7 +941,7 @@ dependencies = [ "cfg-if 0.1.10", "libc", "rand", - "redox_syscall", + "redox_syscall 0.1.57", "remove_dir_all", "winapi", ] @@ -931,6 +1026,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + [[package]] name = "unicode-width" version = "0.1.8" @@ -943,6 +1044,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" +[[package]] +name = "utf8parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" + [[package]] name = "uuid" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index c814f3f..05e0608 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,5 +12,6 @@ lettre = "0.10.0-alpha.4" mailparse = "0.13.1" native-tls = "0.2" rfc2047-decoder = "0.1.2" +rustyline = "7.1.0" serde = { version = "1.0.118", features = ["derive"] } toml = "0.5.8" diff --git a/src/config.rs b/src/config.rs index 6c7abf4..b83c931 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use lettre::transport::smtp::authentication::Credentials; use serde::Deserialize; use std::{ env, fmt, @@ -66,6 +67,10 @@ impl ServerInfo { pub fn get_addr(&self) -> (&str, u16) { (&self.host, self.port) } + + pub fn to_smtp_creds(&self) -> Credentials { + Credentials::new(self.login.to_owned(), self.password.to_owned()) + } } #[derive(Debug, Deserialize)] diff --git a/src/editor.rs b/src/editor.rs deleted file mode 100644 index 1106485..0000000 --- a/src/editor.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::env::temp_dir; -use std::fs::{remove_file, File}; -use std::io::{self, Read, Write}; -use std::process::Command; -use std::{fmt, result}; - -// Error wrapper - -#[derive(Debug)] -pub enum Error { - IoError(io::Error), -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - Error::IoError(err) => err.fmt(f), - } - } -} - -impl From for Error { - fn from(err: io::Error) -> Error { - Error::IoError(err) - } -} - -// Result wrapper - -type Result = result::Result; - -// Editor utils - -fn open_with_template(template: &[u8]) -> Result { - // Create temporary draft - let mut draft_path = temp_dir(); - draft_path.push("himalaya-draft.mail"); - File::create(&draft_path)?.write(template)?; - - // Open editor and save user input to draft - Command::new(env!("EDITOR")).arg(&draft_path).status()?; - - // Read draft - let mut draft = String::new(); - File::open(&draft_path)?.read_to_string(&mut draft)?; - remove_file(&draft_path)?; - - Ok(draft) -} - -pub fn open_with_new_template() -> Result { - let template = ["To: ", "Subject: ", ""].join("\r\n"); - open_with_template(template.as_bytes()) -} diff --git a/src/imap.rs b/src/imap.rs index 06d0aa9..e60a980 100644 --- a/src/imap.rs +++ b/src/imap.rs @@ -6,6 +6,7 @@ use std::{fmt, net::TcpStream, result}; use crate::config; use crate::email::{self, Email}; use crate::mailbox::Mailbox; +use crate::msg::Msg; // Error wrapper @@ -76,7 +77,7 @@ impl ImapConnector { Ok(Self { config, sess }) } - pub fn list_mailboxes(&mut self) -> Result>> { + pub fn list_mboxes(&mut self) -> Result>> { let mboxes = self .sess .list(Some(""), Some("*"))? @@ -131,4 +132,10 @@ impl ImapConnector { } } } + + pub fn append_msg(&mut self, mbox: &str, msg: &Msg) -> Result<()> { + use imap::types::Flag::*; + self.sess.append_with_flags(mbox, msg.to_vec(), &[Seen])?; + Ok(()) + } } diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..0443844 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,81 @@ +use std::{ + env::temp_dir, + fmt, + fs::{remove_file, File}, + io::{self, Read, Write}, + process::Command, + result, +}; + +use crate::config::Config; + +// Error wrapper + +#[derive(Debug)] +pub enum Error { + IoError(io::Error), + AskForSendingConfirmationError, +} + +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::AskForSendingConfirmationError => write!(f, "action cancelled"), + } + } +} + +impl From for Error { + fn from(err: io::Error) -> Error { + Error::IoError(err) + } +} + +// Result wrapper + +type Result = result::Result; + +// Utils + +fn open_editor_with_tpl(tpl: &[u8]) -> Result { + // Creates draft file + let mut draft_path = temp_dir(); + draft_path.push("himalaya-draft.mail"); + File::create(&draft_path)?.write(tpl)?; + + // Opens editor and saves user input to draft file + Command::new(env!("EDITOR")).arg(&draft_path).status()?; + + // Extracts draft file content + let mut draft = String::new(); + File::open(&draft_path)?.read_to_string(&mut draft)?; + remove_file(&draft_path)?; + + Ok(draft) +} + +pub fn open_editor_with_new_tpl(config: &Config) -> Result { + let from = &format!("From: {}", config.email_full()); + let to = "To: "; + let subject = "Subject: "; + let headers = [from, to, subject, ""].join("\r\n"); + + Ok(open_editor_with_tpl(headers.as_bytes())?) +} + +pub fn ask_for_confirmation(prompt: &str) -> Result<()> { + print!("{} (y/n) ", prompt); + io::stdout().flush()?; + + match io::stdin() + .bytes() + .next() + .and_then(|res| res.ok()) + .map(|bytes| bytes as char) + { + Some('y') | Some('Y') => Ok(()), + _ => Err(Error::AskForSendingConfirmationError), + } +} diff --git a/src/main.rs b/src/main.rs index a81c195..2ca92da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ mod config; -mod editor; mod email; mod imap; +mod input; mod mailbox; +mod msg; mod smtp; mod table; @@ -11,21 +12,26 @@ use std::{fmt, process::exit, result}; use crate::config::Config; use crate::imap::ImapConnector; +use crate::msg::Msg; use crate::table::DisplayTable; #[derive(Debug)] pub enum Error { ConfigError(config::Error), + InputError(input::Error), + MsgError(msg::Error), ImapError(imap::Error), - EditorError(editor::Error), + SmtpError(smtp::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Error::ConfigError(err) => err.fmt(f), + Error::InputError(err) => err.fmt(f), + Error::MsgError(err) => err.fmt(f), Error::ImapError(err) => err.fmt(f), - Error::EditorError(err) => err.fmt(f), + Error::SmtpError(err) => err.fmt(f), } } } @@ -36,15 +42,27 @@ impl From for Error { } } +impl From for Error { + fn from(err: input::Error) -> Error { + Error::InputError(err) + } +} + +impl From for Error { + fn from(err: msg::Error) -> Error { + Error::MsgError(err) + } +} + impl From for Error { fn from(err: imap::Error) -> Error { Error::ImapError(err) } } -impl From for Error { - fn from(err: editor::Error) -> Error { - Error::EditorError(err) +impl From for Error { + fn from(err: smtp::Error) -> Error { + Error::SmtpError(err) } } @@ -60,7 +78,6 @@ fn mailbox_arg() -> Arg<'static, 'static> { .long("mailbox") .help("Name of the targeted mailbox") .value_name("STRING") - .default_value("INBOX") } fn uid_arg() -> Arg<'static, 'static> { @@ -80,7 +97,7 @@ fn run() -> Result<()> { .subcommand( SubCommand::with_name("search") .about("Lists emails matching the given IMAP query") - .arg(mailbox_arg()) + .arg(mailbox_arg().default_value("INBOX")) .arg( Arg::with_name("query") .help("IMAP query (see https://tools.ietf.org/html/rfc3501#section-6.4.4)") @@ -93,7 +110,7 @@ fn run() -> Result<()> { SubCommand::with_name("read") .about("Reads an email by its UID") .arg(uid_arg()) - .arg(mailbox_arg()) + .arg(mailbox_arg().default_value("INBOX")) .arg( Arg::with_name("mime-type") .help("MIME type to use") @@ -104,16 +121,12 @@ fn run() -> Result<()> { .default_value("text/plain"), ), ) - .subcommand( - SubCommand::with_name("write") - .about("Writes a new email") - .arg(mailbox_arg()), - ) + .subcommand(SubCommand::with_name("write").about("Writes a new email")) .subcommand( SubCommand::with_name("reply") .about("Replies to an email by its UID") .arg(uid_arg()) - .arg(mailbox_arg()) + .arg(mailbox_arg().default_value("INBOX")) .arg( Arg::with_name("reply all") .help("Replies to all recipients") @@ -125,20 +138,13 @@ fn run() -> Result<()> { SubCommand::with_name("forward") .about("Forwards an email by its UID") .arg(uid_arg()) - .arg(mailbox_arg()), - ) - .subcommand( - SubCommand::with_name("send") - .about("Send a draft by its UID") - .arg(uid_arg()), + .arg(mailbox_arg().default_value("INBOX")), ) .get_matches(); if let Some(_) = matches.subcommand_matches("list") { let config = Config::new_from_file()?; - let mboxes = ImapConnector::new(config.imap)? - .list_mailboxes()? - .to_table(); + let mboxes = ImapConnector::new(config.imap)?.list_mboxes()?.to_table(); println!("{}", mboxes); } @@ -191,12 +197,15 @@ fn run() -> Result<()> { if let Some(_) = matches.subcommand_matches("write") { let config = Config::new_from_file()?; - let draft = editor::open_with_new_template()?; + let content = input::open_editor_with_new_tpl(&config)?; + let msg = Msg::from_raw(content.as_bytes())?; - // TODO: save as draft instead (IMAP) - println!("Sending ..."); - smtp::send(&config, draft.as_bytes()); - println!("Done!"); + input::ask_for_confirmation("Would you like to send this email?")?; + + println!("Sending …"); + smtp::send(&config.smtp, &msg)?; + ImapConnector::new(config.imap)?.append_msg("Sent", &msg)?; + println!("Sent!"); } if let Some(_) = matches.subcommand_matches("reply") { @@ -207,10 +216,6 @@ fn run() -> Result<()> { // TODO } - if let Some(_) = matches.subcommand_matches("send") { - // TODO - } - Ok(()) } diff --git a/src/msg.rs b/src/msg.rs new file mode 100644 index 0000000..806605b --- /dev/null +++ b/src/msg.rs @@ -0,0 +1,85 @@ +use lettre; +use mailparse; +use std::{fmt, result}; + +// Error wrapper + +#[derive(Debug)] +pub enum Error { + ParseMsgError(mailparse::MailParseError), + BuildEmailError(lettre::error::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "(msg): ")?; + match self { + Error::ParseMsgError(err) => err.fmt(f), + Error::BuildEmailError(err) => err.fmt(f), + } + } +} + +impl From for Error { + fn from(err: mailparse::MailParseError) -> Error { + Error::ParseMsgError(err) + } +} + +impl From for Error { + fn from(err: lettre::error::Error) -> Error { + Error::BuildEmailError(err) + } +} + +// Result wrapper + +type Result = result::Result; + +// Wrapper around mailparse::ParsedMail and lettre::Message + +pub struct Msg(lettre::Message); + +impl Msg { + pub fn from_raw(bytes: &[u8]) -> Result { + use lettre::message::header::{ContentTransferEncoding, ContentType}; + use lettre::message::{Message, SinglePart}; + + let parsed_msg = mailparse::parse_mail(bytes)?; + let built_msg = parsed_msg + .headers + .iter() + .fold(Message::builder(), |msg, h| { + match h.get_key().to_lowercase().as_str() { + "from" => msg.from(h.get_value().parse().unwrap()), + "to" => msg.to(h.get_value().parse().unwrap()), + "cc" => match h.get_value().parse() { + Err(_) => msg, + Ok(addr) => msg.cc(addr), + }, + "bcc" => match h.get_value().parse() { + Err(_) => msg, + Ok(addr) => msg.bcc(addr), + }, + "subject" => msg.subject(h.get_value()), + _ => msg, + } + }) + .singlepart( + SinglePart::builder() + .header(ContentType("text/plain; charset=utf-8".parse().unwrap())) + .header(ContentTransferEncoding::Base64) + .body(parsed_msg.get_body_raw()?), + )?; + + Ok(Msg(built_msg)) + } + + pub fn as_sendable_msg(&self) -> &lettre::Message { + &self.0 + } + + pub fn to_vec(&self) -> Vec { + self.0.formatted() + } +} diff --git a/src/smtp.rs b/src/smtp.rs index fde2898..91a9b9e 100644 --- a/src/smtp.rs +++ b/src/smtp.rs @@ -1,52 +1,43 @@ -use lettre::{ - message::{header, Message, SinglePart}, - transport::smtp::{authentication::Credentials, SmtpTransport}, - Transport, -}; -use mailparse; +use lettre; +use std::{fmt, result}; use crate::config; +use crate::msg::Msg; -// TODO: improve error management -pub fn send(config: &config::Config, bytes: &[u8]) { - let email_origin = mailparse::parse_mail(bytes).unwrap(); - let email = email_origin - .headers - .iter() - .fold(Message::builder(), |msg, h| { - match h.get_key().to_lowercase().as_str() { - "to" => msg.to(h.get_value().parse().unwrap()), - "cc" => match h.get_value().parse() { - Err(_) => msg, - Ok(addr) => msg.cc(addr), - }, - "bcc" => match h.get_value().parse() { - Err(_) => msg, - Ok(addr) => msg.bcc(addr), - }, - "subject" => msg.subject(h.get_value()), - _ => msg, - } - }) - .from(config.email_full().parse().unwrap()) - .singlepart( - SinglePart::builder() - .header(header::ContentType( - "text/plain; charset=utf-8".parse().unwrap(), - )) - .header(header::ContentTransferEncoding::Base64) - .body(email_origin.get_body_raw().unwrap()), - ) - .unwrap(); +// Error wrapper - let creds = Credentials::new(config.smtp.login.clone(), config.smtp.password.clone()); - let mailer = SmtpTransport::relay(&config.smtp.host) - .unwrap() - .credentials(creds) - .build(); +#[derive(Debug)] +pub enum Error { + TransportError(lettre::transport::smtp::Error), +} - match mailer.send(&email) { - Ok(_) => (), - Err(e) => panic!("Could not send email: {:?}", e), +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "(smtp): ")?; + match self { + Error::TransportError(err) => err.fmt(f), + } } } + +impl From for Error { + fn from(err: lettre::transport::smtp::Error) -> Error { + Error::TransportError(err) + } +} + +// Result wrapper + +type Result = result::Result; + +// Utils + +pub fn send(config: &config::ServerInfo, msg: &Msg) -> Result<()> { + use lettre::Transport; + + lettre::transport::smtp::SmtpTransport::relay(&config.host)? + .credentials(config.to_smtp_creds()) + .build() + .send(msg.as_sendable_msg()) + .map(|_| Ok(()))? +}