From dce1c39b489040a399d5a2515a24275ae9e84b20 Mon Sep 17 00:00:00 2001 From: Manos Pitsidianakis Date: Mon, 11 Nov 2019 22:20:16 +0200 Subject: [PATCH] ui: add mailcap support --- meli.1 | 77 ++++++++-- melib/src/email/attachments.rs | 21 +++ ui/src/components/mail/view.rs | 107 ++++++++++++-- ui/src/lib.rs | 2 + ui/src/mailcap.rs | 249 +++++++++++++++++++++++++++++++++ 5 files changed, 428 insertions(+), 28 deletions(-) create mode 100644 ui/src/mailcap.rs diff --git a/meli.1 b/meli.1 index f3c555aec..fee7edd55 100644 --- a/meli.1 +++ b/meli.1 @@ -24,7 +24,7 @@ .Nm meli .Nd Meli Mail User Agent. meli is the Greek word for honey. .Sh SYNOPSIS -.Nm meli +.Nm .Op Fl -help | h .Op Fl -version | v .Op Fl -create-config Op Ar path @@ -45,7 +45,9 @@ if given, or at Start meli with given configuration file. .El .Sh STARTING WITH meli -When launched for the first time, meli will search for its configuration directory, +When launched for the first time, +.Nm +will search for its configuration directory, .Pa $XDG_CONFIG_HOME/meli/ Ns \&. If it doesn't exist, you will be asked if you want to create one along with a sample configuration. The sample configuration .Pa $XDG_CONFIG_HOME/meli/config @@ -83,6 +85,21 @@ section of your configuration. ^^ (`-=-=-=-=-`) `-=-=-=-=-` ^^ .Ed +.Sh VIEWING MAIL +Open attachments by typing their index in the attachments list and then +.Cm a Ns +\&. +.Ns +.Nm +will attempt to open text inside its pager and other content via +.Cm xdg-open Ns +\&. Press +.Cm m +instead to use the mailcap entry for the MIME type of the attachment, if any. See +.Sx FILES +for the location of the mailcap files and +.Xr mailcap 5 +for their syntax. .Sh COMPOSING To send mail, press .Cm m @@ -112,7 +129,9 @@ will send your message by piping it into a binary of your choosing (see .Cm close and select 'save as draft'. .Pp -If there is no Draft or Sent folder, meli tries first saving mail in your INBOX and then at any other folder. On complete failure to save your draft or sent message it will be saved in your +If there is no Draft or Sent folder, +.Nm +tries first saving mail in your INBOX and then at any other folder. On complete failure to save your draft or sent message it will be saved in your .Em tmp directory instead and you will be notified of its location. .Pp @@ -120,7 +139,8 @@ To open a draft for editing later, select your draft in the mail listing and pre .Cm e Ns \&. .Sh SEARCH -meli, if built with sqlite3, includes the ability to perform full text search on the following fields: From, To, Cc, Bcc, In-Reply-To, References, Subject and Date. The message body (in plain text human readable form) and the flags can also be queried. To create the sqlite3 index issue command +.Nm Ns +, if built with sqlite3, includes the ability to perform full text search on the following fields: From, To, Cc, Bcc, In-Reply-To, References, Subject and Date. The message body (in plain text human readable form) and the flags can also be queried. To create the sqlite3 index issue command .Ic index Ar ACCOUNT_NAME Ns \&. To search in the message body type your keywords without any special formatting. @@ -150,7 +170,9 @@ To prevent downloading all your messages from your IMAP server, don't set .Em cache_type to .Em sqlite3 Ns -\&. meli will relay your queries to the IMAP server. Expect a delay between query and response. Sqlite3 on the contrary at reasonable mailbox sizes should have a non noticable delay. +\&. +.Nm +will relay your queries to the IMAP server. Expect a delay between query and response. Sqlite3 on the contrary at reasonable mailbox sizes should have a non noticable delay. .Sh EXECUTE mode Commands are issued in EXECUTE mode, by default started with the space character and exited with Escape key. .Pp @@ -198,7 +220,7 @@ delete folder .El .Pp envelope view commands: -.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" -offset indent +.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" .It Cm pipe Ar EXECUTABLE Ar ARGS pipe pager contents to binary .It Cm list-post @@ -211,7 +233,7 @@ open list archive with .El .Pp composing mail commands: -.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" -offset indent +.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" .It Ic add-attachment Ar PATH in composer, add .Ar PATH @@ -223,7 +245,7 @@ toggle between signing and not signing this message. If the gpg invocation fails .El .Pp generic commands: -.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" -offset indent +.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" .It Cm open-in-tab opens envelope view in new tab .It Ic close @@ -239,7 +261,7 @@ print environment variable .El .Sh SHORTCUTS Non-complete list of shortcuts and their default values. -.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" -offset indent +.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" .It Cm open_thread \&'\\n' .It Cm exit_thread @@ -275,7 +297,7 @@ PageDown .It Cm select \&'v' .El -.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" -offset indent +.Bl -tag -width "rename-folder ACCOUNT FOLDER_PATH_SRC FOLDER_PATH_DEST" .It Cm ` toggles hiding of sidebar in mail listings .It Cm \&? @@ -296,6 +318,11 @@ opens the .Ar n Ns th attachment. +.It Ar n Ns Cm m +opens the +.Ar n Ns +th +attachment according to its mailcap entry. .It Cm v (un)selects mail entries in mail listings .El @@ -314,7 +341,8 @@ Specifies the editor to use Override the configuration file .El .Sh FILES -meli uses the following parts of the XDG standard: +.Nm +uses the following parts of the XDG standard: .Bl -tag -width "$XDG_CONFIG_HOME/meli/plugins/*" -offset indent .It Ev XDG_CONFIG_HOME defaults to @@ -343,16 +371,37 @@ Internal data used by meli. .It Pa $XDG_DATA_HOME/meli/meli.log Operation log. .It Pa /tmp/meli/* -Temporary files generated by meli. +Temporary files generated by +.Nm Ns +\&. +.El +.Pp +Mailcap entries are searched for in the following files, in this order: +.Pp +.Bl -enum -compact -offset indent +.It +.Pa $XDG_CONFIG_HOME/meli/mailcap +.It +.Pa $XDG_CONFIG_HOME/.mailcap +.It +.Pa $HOME/.mailcap +.It +.Pa /etc/mailcap +.It +.Pa /usr/etc/mailcap +.It +.Pa /usr/local/etc/mailcap .El .Sh SEE ALSO +.Xr meli.conf 5 , .Xr xdg-open 1 , -.Xr meli.conf 5 +.Xr mailcap 5 .Sh CONFORMING TO XDG Standard .Aq https://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html Ns , maildir -.Aq https://cr.yp.to/proto/maildir.html +.Aq https://cr.yp.to/proto/maildir.html Ns +, IMAPv4rev1 RFC3501. .Sh AUTHORS Copyright 2017-2019 .An Manos Pitsidianakis Aq epilys@nessuent.xyz diff --git a/melib/src/email/attachments.rs b/melib/src/email/attachments.rs index 88475d20a..fa1fea2ec 100644 --- a/melib/src/email/attachments.rs +++ b/melib/src/email/attachments.rs @@ -642,6 +642,27 @@ impl Attachment { into_raw_helper(self, &mut ret); ret } + + pub fn parameters(&self) -> Vec<(&[u8], &[u8])> { + let mut ret = Vec::new(); + let (headers, _) = match parser::attachment(&self.raw).to_full_result() { + Ok(v) => v, + Err(_) => return ret, + }; + for (name, value) in headers { + if name.eq_ignore_ascii_case(b"content-type") { + match parser::content_type(value).to_full_result() { + Ok((_, _, params)) => { + ret = params; + } + _ => {} + } + break; + } + } + + ret + } } pub fn interpret_format_flowed(_t: &str) -> String { diff --git a/ui/src/components/mail/view.rs b/ui/src/components/mail/view.rs index b3551b748..97b79739f 100644 --- a/ui/src/components/mail/view.rs +++ b/ui/src/components/mail/view.rs @@ -701,8 +701,12 @@ impl Component for MailView { } } + let shortcuts = &self.get_shortcuts(context)[MailView::DESCRIPTION]; match *event { - UIEvent::Input(Key::Char('c')) if !self.mode.is_contact_selector() => { + UIEvent::Input(ref key) + if !self.mode.is_contact_selector() + && *key == shortcuts["add_addresses_to_contacts"] => + { let account = &mut context.accounts[self.coordinates.0]; let envelope: EnvelopeRef = account.collection.get_env(self.coordinates.2); @@ -746,25 +750,87 @@ impl Component for MailView { ))); return true; } - UIEvent::Input(Key::Alt('r')) - if self.mode == ViewMode::Normal || self.mode == ViewMode::Subview => + UIEvent::Input(ref key) + if (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview) + && *key == shortcuts["view_raw_source"] => { self.mode = ViewMode::Raw; self.set_dirty(); return true; } - UIEvent::Input(Key::Char('r')) - if self.mode.is_attachment() + UIEvent::Input(ref key) + if (self.mode.is_attachment() || self.mode == ViewMode::Subview || self.mode == ViewMode::Url - || self.mode == ViewMode::Raw => + || self.mode == ViewMode::Raw) + && *key == shortcuts["return_to_normal_view"] => { self.mode = ViewMode::Normal; self.set_dirty(); return true; } - UIEvent::Input(Key::Char('a')) - if !self.cmd_buf.is_empty() + UIEvent::Input(ref key) + if (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview) + && !self.cmd_buf.is_empty() + && *key == shortcuts["open_mailcap"] => + { + let lidx = self.cmd_buf.parse::().unwrap(); + self.cmd_buf.clear(); + context + .replies + .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); + + { + let account = &mut context.accounts[self.coordinates.0]; + let envelope: EnvelopeRef = account.collection.get_env(self.coordinates.2); + let op = account.operation(envelope.hash()); + + let attachments = match envelope.body(op) { + Ok(body) => body.attachments(), + Err(e) => { + context.replies.push_back(UIEvent::Notification( + Some("Failed to open e-mail".to_string()), + e.to_string(), + Some(NotificationType::ERROR), + )); + log( + format!( + "Failed to open envelope {}: {}", + envelope.message_id_display(), + e.to_string() + ), + ERROR, + ); + return true; + } + }; + drop(envelope); + drop(account); + if let Some(u) = attachments.get(lidx) { + if let Ok(()) = crate::mailcap::MailcapEntry::execute(u, context) { + self.set_dirty(); + } else { + context.replies.push_back(UIEvent::StatusEvent( + StatusEvent::DisplayMessage(format!( + "no mailcap entry found for {}", + u.content_type() + )), + )); + } + } else { + context.replies.push_back(UIEvent::StatusEvent( + StatusEvent::DisplayMessage(format!( + "Attachment `{}` not found.", + lidx + )), + )); + } + return true; + } + } + UIEvent::Input(ref key) + if *key == shortcuts["open_attachment"] + && !self.cmd_buf.is_empty() && (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview) => { let lidx = self.cmd_buf.parse::().unwrap(); @@ -895,13 +961,15 @@ impl Component for MailView { } }; } - UIEvent::Input(Key::Char('h')) => { + UIEvent::Input(ref key) if *key == shortcuts["toggle_expand_headers"] => { self.expand_headers = !self.expand_headers; self.dirty = true; return true; } - UIEvent::Input(Key::Char('g')) - if !self.cmd_buf.is_empty() && self.mode == ViewMode::Url => + UIEvent::Input(ref key) + if !self.cmd_buf.is_empty() + && self.mode == ViewMode::Url + && *key == shortcuts["go_to_url"] => { let lidx = self.cmd_buf.parse::().unwrap(); self.cmd_buf.clear(); @@ -943,15 +1011,24 @@ impl Component for MailView { } }; - Command::new("xdg-open") + if let Err(e) = Command::new("xdg-open") .arg(url) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() - .expect("Failed to start xdg_open"); + { + context.replies.push_back(UIEvent::Notification( + Some("Failed to launch xdg-open".to_string()), + e.to_string(), + Some(NotificationType::ERROR), + )); + } return true; } - UIEvent::Input(Key::Char('u')) => { + UIEvent::Input(ref key) + if (self.mode == ViewMode::Normal || self.mode == ViewMode::Url) + && *key == shortcuts["toggle_url_mode"] => + { match self.mode { ViewMode::Normal => self.mode = ViewMode::Url, ViewMode::Url => self.mode = ViewMode::Normal, @@ -1220,12 +1297,14 @@ impl Component for MailView { our_map.insert("return_to_normal_view", Key::Char('r')); } our_map.insert("open_attachment", Key::Char('a')); + our_map.insert("open_mailcap", Key::Char('m')); if self.mode == ViewMode::Url { our_map.insert("go_to_url", Key::Char('g')); } if self.mode == ViewMode::Normal || self.mode == ViewMode::Url { our_map.insert("toggle_url_mode", Key::Char('u')); } + our_map.insert("toggle_expand_headers", Key::Char('h')); map.insert(MailView::DESCRIPTION.to_string(), our_map); map diff --git a/ui/src/lib.rs b/ui/src/lib.rs index cabedc71b..92c6f5ade 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -74,6 +74,8 @@ pub mod sqlite3; pub mod cache; +pub mod mailcap; + pub use crate::username::*; pub mod username { use libc; diff --git a/ui/src/mailcap.rs b/ui/src/mailcap.rs new file mode 100644 index 000000000..dc559b995 --- /dev/null +++ b/ui/src/mailcap.rs @@ -0,0 +1,249 @@ +use crate::split_command; +use crate::state::Context; +use crate::types::{create_temp_file, ForkType, UIEvent}; +use fnv::FnvHashMap; +use melib::attachments::decode; +use melib::{email::Attachment, MeliError, Result}; +use std::io::Read; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +pub struct MailcapEntry { + command: String, + /* Pass to pager */ + copiousoutput: bool, +} + +trait GlobMatch { + fn matches_glob(&self, s: &str) -> bool; +} + +impl GlobMatch for str { + fn matches_glob(&self, s: &str) -> bool { + let parts = self.split("*"); + + let mut ptr = 0; + let mut part_no = 0; + + for p in parts { + if ptr >= s.len() { + return false; + } + if part_no > 0 { + while !&s[ptr..].starts_with(p) { + ptr += 1; + if ptr >= s.len() { + return false; + } + } + } + if !&s[ptr..].starts_with(p) { + return false; + } + ptr += p.len(); + part_no += 1; + } + true + } +} + +impl MailcapEntry { + pub fn execute(a: &Attachment, context: &mut Context) -> Result<()> { + /* lookup order: + * $XDG_CONFIG_HOME/meli/mailcap:$XDG_CONFIG_HOME/.mailcap:$HOME/.mailcap:/etc/mailcap:/usr/etc/mailcap:/usr/local/etc/mailcap + */ + let xdg_dirs = + xdg::BaseDirectories::with_prefix("meli").map_err(|e| MeliError::new(e.to_string()))?; + let mut mailcap_path = xdg_dirs + .place_config_file("mailcap") + .map_err(|e| MeliError::new(e.to_string()))?; + if !mailcap_path.exists() { + mailcap_path = xdg::BaseDirectories::new() + .map_err(|e| MeliError::new(e.to_string()))? + .place_config_file("mailcap")?; + if !mailcap_path.exists() { + if let Ok(home) = std::env::var("HOME") { + mailcap_path = PathBuf::from(format!("{}/.mailcap", home)); + } + if !mailcap_path.exists() { + mailcap_path = PathBuf::from("/etc/mailcap"); + if !mailcap_path.exists() { + mailcap_path = PathBuf::from("/usr/etc/mailcap"); + if !mailcap_path.exists() { + mailcap_path = PathBuf::from("/usr/local/etc/mailcap"); + } + if !mailcap_path.exists() { + return Err(MeliError::new("No mailcap file found.")); + } + } + } + } + } + + let mut hash_map = FnvHashMap::default(); + let mut content = String::new(); + + std::fs::File::open(mailcap_path.as_path())?.read_to_string(&mut content)?; + let content_type = a.content_type().to_string(); + + let mut result = None; + let mut lines_iter = content.lines(); + while let Some(l) = lines_iter.next() { + let l = l.trim(); + if l.starts_with("#") { + continue; + } + if l.is_empty() { + continue; + } + + if l.ends_with("\\") { + let l = format!("{}{}", &l[..l.len() - 2], lines_iter.next().unwrap()); + let mut parts_iter = l.split(";"); + let key = parts_iter.next().unwrap(); + let cmd = parts_iter.next().unwrap(); + //let flags = parts_iter.next().unwrap(); + if key.starts_with(&content_type) || key.matches_glob(&content_type) { + let mut copiousoutput = false; + while let Some(flag) = parts_iter.next() { + if flag.trim() == "copiousoutput" { + copiousoutput = true; + } else { + debug!("unknown mailcap flag: {}", flag); + } + } + + result = Some(MailcapEntry { + command: cmd.to_string(), + copiousoutput, + }); + break; + } + hash_map.insert(key.to_string(), cmd.to_string()); + } else { + let mut parts_iter = l.split(";"); + let key = parts_iter.next().unwrap(); + let cmd = parts_iter.next().unwrap(); + //let flags = parts_iter.next().unwrap(); + if key.starts_with(&content_type) || key.matches_glob(&content_type) { + let mut copiousoutput = false; + while let Some(flag) = parts_iter.next() { + if flag.trim() == "copiousoutput" { + copiousoutput = true; + } else { + debug!("unknown mailcap flag: {}", flag); + } + } + + result = Some(MailcapEntry { + command: cmd.to_string(), + copiousoutput, + }); + break; + } + hash_map.insert(key.to_string(), cmd.to_string()); + } + } + + match result { + None => Err(MeliError::new("Not found".to_string())), + Some(MailcapEntry { + command, + copiousoutput, + }) => { + let parts = split_command!(command); + let (cmd, args) = (parts[0], &parts[1..]); + let mut f = None; + let mut needs_stdin = true; + let params = a.parameters(); + /* TODO: See mailcap(5) + * - replace "\%" with "%" and unescape other blackslash uses. + * - "%n" and "%F". + * - test=xxx field. + */ + let args = args + .iter() + .map(|arg| match *arg { + "%s" => { + needs_stdin = false; + let _f = create_temp_file(&decode(a, None), None, None, true); + let p = _f.path().display().to_string(); + f = Some(_f); + p + } + "%t" => a.content_type().to_string(), + param if param.starts_with("%{") && param.ends_with("}") => { + let param = ¶m["%{".len()..param.len() - 1]; + if let Some(v) = params.iter().find(|(k, _)| *k == param.as_bytes()) { + String::from_utf8_lossy(v.1).into() + } else if param == "charset" { + String::from("utf-8") + } else { + String::new() + } + } + a => a.to_string(), + }) + .collect::>(); + { + context.input_kill(); + } + if copiousoutput { + let out = if needs_stdin { + let mut child = Command::new(cmd) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + child.stdin.as_mut().unwrap().write_all(&decode(a, None))?; + child.wait_with_output()?.stdout + } else { + let child = Command::new(cmd) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + + child.wait_with_output()?.stdout + }; + let pager_cmd = if let Ok(v) = std::env::var("PAGER") { + std::borrow::Cow::from(v) + } else { + std::borrow::Cow::from("less") + }; + + let mut pager = Command::new(pager_cmd.as_ref()) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .spawn()?; + pager.stdin.as_mut().unwrap().write_all(&out)?; + debug!(pager.wait_with_output()?.stdout); + } else { + if needs_stdin { + let mut child = Command::new(cmd) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .spawn()?; + + child.stdin.as_mut().unwrap().write_all(&decode(a, None))?; + debug!(child.wait_with_output()?.stdout); + } else { + let child = Command::new(cmd) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .spawn()?; + + debug!(child.wait_with_output()?.stdout); + } + } + context.replies.push_back(UIEvent::Fork(ForkType::Finished)); + context.restore_input(); + Ok(()) + } + } + } +}