diff --git a/meli/docs/meli.conf.5 b/meli/docs/meli.conf.5 index c0cd7540..72c14681 100644 --- a/meli/docs/meli.conf.5 +++ b/meli/docs/meli.conf.5 @@ -1261,6 +1261,9 @@ See .Xr meli 1 FILES for the mailcap file locations. .Pq Em m \" default value +.It Ic open_html +Opens html attachment in the default browser. +.Pq Em v \" default value .It Ic reply Reply to envelope. .Pq Em R \" default value diff --git a/meli/src/conf/shortcuts.rs b/meli/src/conf/shortcuts.rs index 184a501b..ea4f9e01 100644 --- a/meli/src/conf/shortcuts.rs +++ b/meli/src/conf/shortcuts.rs @@ -245,6 +245,7 @@ shortcut_key_values! { "envelope-view", go_to_url |> "Go to url of given index." |> Key::Char('g'), open_attachment |> "Opens selected attachment with xdg-open." |> Key::Char('a'), open_mailcap |> "Opens selected attachment according to its mailcap entry." |> Key::Char('m'), + open_html |> "Opens html attachment in the default browser." |> Key::Char('v'), reply |> "Reply to envelope." |> Key::Char('R'), reply_to_author |> "Reply to author." |> Key::Ctrl('r'), reply_to_all |> "Reply to all/Reply to list/Follow up." |> Key::Ctrl('g'), diff --git a/meli/src/mail/view.rs b/meli/src/mail/view.rs index 5fec9b77..06d5e468 100644 --- a/meli/src/mail/view.rs +++ b/meli/src/mail/view.rs @@ -39,8 +39,6 @@ use crate::{accounts::JobRequest, jobs::JobId}; mod utils; pub use utils::*; -mod html; -pub use html::*; mod thread; pub use thread::*; mod types; @@ -51,6 +49,9 @@ use state::*; pub mod envelope; pub use envelope::EnvelopeView; +pub mod filters; +pub use filters::*; + /// Contains an Envelope view, with sticky headers, a pager for the body, and /// subviews for more menus #[derive(Debug)] diff --git a/meli/src/mail/view/envelope.rs b/meli/src/mail/view/envelope.rs index a1a08cd7..a9ae0ef0 100644 --- a/meli/src/mail/view/envelope.rs +++ b/meli/src/mail/view/envelope.rs @@ -21,7 +21,6 @@ use std::process::{Command, Stdio}; -use linkify::LinkFinder; use melib::utils::xdg::query_default_app; use super::*; @@ -36,15 +35,17 @@ use crate::ThreadEvent; #[derive(Debug)] pub struct EnvelopeView { pub pager: Pager, - pub subview: Option>, + pub subview: Option>, pub dirty: bool, pub initialised: bool, pub force_draw_headers: bool, - pub mode: ViewMode, + pub options: ViewOptions, pub mail: Mail, pub body: Box, pub display: Vec, pub body_text: String, + pub html_filter: Option>, + pub filters: Vec, pub links: Vec, pub attachment_tree: String, pub attachment_paths: Vec>, @@ -80,7 +81,7 @@ impl EnvelopeView { pub fn new( mail: Mail, pager: Option, - subview: Option>, + subview: Option>, view_settings: Option, main_loop_handler: MainLoopHandler, ) -> Self { @@ -92,7 +93,7 @@ impl EnvelopeView { dirty: true, initialised: false, force_draw_headers: false, - mode: ViewMode::Normal, + options: ViewOptions::default(), force_charset: ForceCharset::None, attachment_tree: String::new(), attachment_paths: vec![], @@ -100,6 +101,8 @@ impl EnvelopeView { display: vec![], links: vec![], body_text: String::new(), + html_filter: None, + filters: vec![], view_settings, headers_no: 5, headers_cursor: 0, @@ -719,7 +722,7 @@ impl Component for EnvelopeView { let hdr_area_theme = crate::conf::value(context, "mail.view.headers_area"); let y: usize = { - if self.mode.is_source() { + if self.options.contains(ViewOptions::SOURCE) { grid.clear_area(area, self.view_settings.theme_default); context.dirty_areas.push_back(area); 0 @@ -987,223 +990,114 @@ impl Component for EnvelopeView { } }; - if !self.initialised { - self.initialised = true; + if self.filters.is_empty() || self.body_text.is_empty() { let body = self.mail.body(); - match self.mode { - ViewMode::Attachment(aidx) if body.attachments()[aidx].is_html() => { - let attachment = &body.attachments()[aidx]; - self.subview = Some(Box::new(HtmlView::new(attachment, context))); - } - ViewMode::Attachment(aidx) => { - let mut text = format!( - "Viewing attachment. Press {} to return \n", - self.shortcuts(context) - .get(Shortcuts::ENVELOPE_VIEW) - .and_then(|m| m.get("return_to_normal_view")) - .unwrap_or( - &context - .settings - .shortcuts - .envelope_view - .return_to_normal_view - ) - ); - let attachment = &body.attachments()[aidx]; - text.push_str(&attachment.text()); - self.pager = Pager::from_string( - text, - Some(context), - Some(0), - None, - self.view_settings.theme_default, - ); - if let Some(ref filter) = self.view_settings.pager_filter { - self.pager.filter(filter); - } - self.subview = None; + if body.is_html() { + let attachment = if let Some(sub) = match body.content_type { + ContentType::Multipart { + kind: MultipartType::Alternative, + ref parts, + .. + } => parts.iter().find(|p| p.is_html()), + _ => None, + } { + sub + } else { + &body + }; + if let Ok(filter) = ViewFilter::new_html(attachment, context) { + self.filters.push(filter); } - ViewMode::Normal if body.is_html() => { - self.subview = Some(Box::new(HtmlView::new(&body, context))); - self.mode = ViewMode::Subview; + } else if self.view_settings.auto_choose_multipart_alternative + && match body.content_type { + ContentType::Multipart { + kind: MultipartType::Alternative, + ref parts, + .. + } => parts + .iter() + .all(|p| p.is_html() || (p.is_text() && p.body().trim().is_empty())), + _ => false, } - ViewMode::Normal - if self.view_settings.auto_choose_multipart_alternative - && match body.content_type { - ContentType::Multipart { - kind: MultipartType::Alternative, - ref parts, - .. - } => parts.iter().all(|p| { - p.is_html() || (p.is_text() && p.body().trim().is_empty()) - }), - _ => false, - } => - { - let subview = Box::new(HtmlView::new( - body.content_type - .parts() - .unwrap() - .iter() - .find(|a| a.is_html()) - .unwrap_or(&body), - context, - )); - self.subview = Some(subview); - self.mode = ViewMode::Subview; - } - ViewMode::Subview => {} - ViewMode::Source(Source::Raw) => { - let text = String::from_utf8_lossy(self.mail.bytes()).into_owned(); - self.view_settings.body_theme = crate::conf::value(context, "mail.view.body"); - self.pager = Pager::from_string( - text, - Some(context), - None, - None, - self.view_settings.body_theme, - ); - if let Some(ref filter) = self.view_settings.pager_filter { - self.pager.filter(filter); - } - } - ViewMode::Source(Source::Decoded) => { - let text = { - /* Decode each header value */ - let mut ret = String::new(); - match melib::email::parser::headers::headers(self.mail.bytes()) - .map(|(_, v)| v) - { - Ok(headers) => { - for (h, v) in headers { - _ = match melib::email::parser::encodings::phrase(v, true) { - Ok((_, v)) => ret.write_fmt(format_args!( - "{h}: {}\n", - String::from_utf8_lossy(&v) - )), - Err(err) => ret.write_fmt(format_args!("{h}: {err}\n")), - }; - } - } - Err(err) => { - _ = write!(&mut ret, "{err}"); - } - } - if !ret.ends_with("\n\n") { - if ret.ends_with('\n') { - ret.pop(); - } - ret.push_str("\n\n"); - } - ret.push_str(&self.body_text); - if !ret.ends_with("\n\n") { - if ret.ends_with('\n') { - ret.pop(); - } - ret.push_str("\n\n"); - } - // ret.push_str(&self.attachment_tree); - ret - }; - self.view_settings.body_theme = crate::conf::value(context, "mail.view.body"); - self.pager = Pager::from_string( - text, - Some(context), - None, - None, - self.view_settings.body_theme, - ); - if let Some(ref filter) = self.view_settings.pager_filter { - self.pager.filter(filter); - } - } - ViewMode::Url => { - let mut text = self.body_text.clone(); - if self.links.is_empty() { - let finder = LinkFinder::new(); - self.links = finder - .links(&text) - .filter_map(|l| { - if *l.kind() == linkify::LinkKind::Url { - Some(Link { - start: l.start(), - end: l.end(), - kind: LinkKind::Url, - }) - } else if *l.kind() == linkify::LinkKind::Email { - Some(Link { - start: l.start(), - end: l.end(), - kind: LinkKind::Email, - }) - } else { - None - } - }) - .collect::>(); - } - for (lidx, l) in self.links.iter().enumerate().rev() { - text.insert_str(l.start, &format!("[{}]", lidx)); - } - if !text.ends_with("\n\n") { - text.push_str("\n\n"); - } - text.push_str(&self.attachment_tree); - - let cursor_pos = self.pager.cursor_pos(); - self.view_settings.body_theme = crate::conf::value(context, "mail.view.body"); - self.pager = Pager::from_string( - text, - Some(context), - Some(cursor_pos), - None, - self.view_settings.body_theme, - ); - if let Some(ref filter) = self.view_settings.pager_filter { - self.pager.filter(filter); - } - self.subview = None; + { + if let Ok(filter) = ViewFilter::new_html( + body.content_type + .parts() + .unwrap() + .iter() + .find(|a| a.is_html()) + .unwrap_or(&body), + context, + ) { + self.filters.push(filter); } - _ => { - let mut text = self.body_text.clone(); - if !text.ends_with("\n\n") { - text.push_str("\n\n"); - } - text.push_str(&self.attachment_tree); - let cursor_pos = if self.mode.is_attachment() { - 0 + } else if let Ok(filter) = ViewFilter::new_attachment(&body, context) { + self.filters.push(filter); + } + self.body_text = String::from_utf8_lossy( + &body.decode(Option::::from(&self.force_charset).into()), + ) + .to_string(); + } + if !self.initialised { + self.initialised = true; + let mut text = if let Some(ViewFilter { + filter_invocation, + body_text, + notice, + .. + }) = self.filters.last() + { + let mut text = if self.filters.len() == 1 { + if filter_invocation.is_empty() { + String::new() } else { - self.pager.cursor_pos() - }; - self.view_settings.body_theme = crate::conf::value(context, "mail.view.body"); - self.pager = Pager::from_string( - text, - Some(context), - Some(cursor_pos), - None, - self.view_settings.body_theme, - ); - if let Some(ref filter) = self.view_settings.pager_filter { - self.pager.filter(filter); + format!("Text piped through `{filter_invocation}`\n\n") } - self.subview = None; - } + } else { + notice + .as_ref() + .map(|s| s.to_string()) + .or_else(|| { + if filter_invocation.is_empty() { + None + } else { + Some(format!("Text piped through `{filter_invocation}`\n\n")) + } + }) + .unwrap_or_default() + }; + text.push_str(&self.options.convert(&mut self.links, &self.body, body_text)); + text + } else { + self.options + .convert(&mut self.links, &self.body, &self.body_text) }; - self.dirty = false; + if !text.trim().is_empty() { + text.push_str("\n\n"); + } + text.push_str(&self.attachment_tree); + let cursor_pos = self.pager.cursor_pos(); + self.view_settings.body_theme = crate::conf::value(context, "mail.view.body"); + self.pager = Pager::from_string( + text, + Some(context), + Some(cursor_pos), + None, + self.view_settings.body_theme, + ); + if let Some(ref filter) = self.view_settings.pager_filter { + self.pager.filter(filter); + } } - match self.mode { - ViewMode::Subview if self.subview.is_some() => { - if let Some(s) = self.subview.as_mut() { - if !s.is_dirty() { - s.set_dirty(true); - } - s.draw(grid, area.skip_rows(y), context); - } - } - _ => { - self.pager.draw(grid, area.skip_rows(y), context); + if let Some(s) = self.subview.as_mut() { + if !s.is_dirty() { + s.set_dirty(true); } + s.draw(grid, area.skip_rows(y), context); + } else { + self.pager.draw(grid, area.skip_rows(y), context); } if let ForceCharset::Dialog(ref mut s) = self.force_charset { s.draw(grid, area, context); @@ -1259,8 +1153,16 @@ impl Component for EnvelopeView { _ => {} } + let shortcuts = &self.shortcuts(context); + if let Some(ref mut sub) = self.subview { - if sub.process_event(event, context) { + if matches!(event, UIEvent::Input(ref key) if shortcut!( + key == shortcuts[Shortcuts::ENVELOPE_VIEW]["return_to_normal_view"] + )) { + if sub.process_event(event, context) && !sub.filters.is_empty() { + return true; + } + } else if sub.process_event(event, context) { return true; } } else { @@ -1289,13 +1191,17 @@ impl Component for EnvelopeView { } } - if self.pager.process_event(event, context) { + if self.pager.process_event(event, context) + || self + .filters + .last_mut() + .map(|f| f.process_event(event, context)) + .unwrap_or(false) + { return true; } } - let shortcuts = &self.shortcuts(context); - match *event { UIEvent::Resize | UIEvent::VisibilityChange(true) => { self.set_dirty(true); @@ -1315,44 +1221,46 @@ impl Component for EnvelopeView { return true; } UIEvent::Input(ref key) - if matches!( - self.mode, - ViewMode::Normal - | ViewMode::Subview - | ViewMode::Source(Source::Decoded) - | ViewMode::Source(Source::Raw) - ) && shortcut!( - key == shortcuts[Shortcuts::ENVELOPE_VIEW]["view_raw_source"] - ) => + if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["view_raw_source"]) => { - self.mode = match self.mode { - ViewMode::Source(Source::Decoded) => ViewMode::Source(Source::Raw), - _ => ViewMode::Source(Source::Decoded), - }; + if self.options.contains(ViewOptions::SOURCE) { + self.options.toggle(ViewOptions::SOURCE_RAW); + } else { + self.options.toggle(ViewOptions::SOURCE); + } self.set_dirty(true); self.initialised = false; return true; } UIEvent::Input(ref key) - if matches!( - self.mode, - ViewMode::Attachment(_) - | ViewMode::Subview - | ViewMode::Url - | ViewMode::Source(Source::Decoded) - | ViewMode::Source(Source::Raw) - ) && shortcut!( + if self.options != ViewOptions::DEFAULT + && shortcut!( + key == shortcuts[Shortcuts::ENVELOPE_VIEW]["return_to_normal_view"] + ) => + { + self.options.remove(ViewOptions::SOURCE | ViewOptions::URL); + self.set_dirty(true); + self.initialised = false; + return true; + } + UIEvent::Input(ref key) + if shortcut!( key == shortcuts[Shortcuts::ENVELOPE_VIEW]["return_to_normal_view"] ) => { - self.mode = ViewMode::Normal; + if self.subview.take().is_some() { + self.initialised = false; + } else if self.filters.is_empty() { + return false; + } else { + self.filters.pop(); + self.initialised = false; + } self.set_dirty(true); - self.initialised = false; return true; } UIEvent::Input(ref key) - if (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview) - && !self.cmd_buf.is_empty() + if !self.cmd_buf.is_empty() && shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["open_mailcap"]) => { let lidx = self.cmd_buf.parse::().unwrap(); @@ -1491,8 +1399,7 @@ impl Component for EnvelopeView { } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["open_attachment"]) - && !self.cmd_buf.is_empty() - && (self.mode == ViewMode::Normal || self.mode == ViewMode::Subview) => + && !self.cmd_buf.is_empty() => { let lidx = self.cmd_buf.parse::().unwrap(); self.cmd_buf.clear(); @@ -1504,7 +1411,6 @@ impl Component for EnvelopeView { ContentType::MessageRfc822 => { match Mail::new(attachment.body().to_vec(), Some(Flag::SEEN)) { Ok(wrapper) => { - self.mode = ViewMode::Subview; self.subview = Some(Box::new(EnvelopeView::new( wrapper, None, @@ -1521,19 +1427,15 @@ impl Component for EnvelopeView { } } } - ContentType::Text { .. } + ContentType::Multipart { .. } + | ContentType::Text { .. } | ContentType::PGPSignature | ContentType::CMSSignature => { - self.mode = ViewMode::Attachment(lidx); + if let Ok(filter) = ViewFilter::new_attachment(attachment, context) { + self.filters.push(filter); + } self.initialised = false; - self.dirty = true; - } - ContentType::Multipart { .. } => { - context.replies.push_back(UIEvent::StatusEvent( - StatusEvent::DisplayMessage( - "Multipart attachments are not supported yet.".to_string(), - ), - )); + self.set_dirty(true); } ContentType::Other { .. } => { let attachment_type = attachment.mime_type(); @@ -1598,8 +1500,9 @@ impl Component for EnvelopeView { } => { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( - "Failed to open {}. application/octet-stream isn't supported \ - yet", + "Failed to open {}. application/octet-stream is a stream of \ + bytes of unknown type. Try saving it as a file and opening \ + it manually.", name.as_ref().map(|n| n.as_str()).unwrap_or("file") )), )); @@ -1609,10 +1512,9 @@ impl Component for EnvelopeView { return true; } UIEvent::Input(ref key) - if (self.mode == ViewMode::Normal || self.mode == ViewMode::Url) - && shortcut!( - key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_expand_headers"] - ) => + if shortcut!( + key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_expand_headers"] + ) => { self.view_settings.expand_headers = !self.view_settings.expand_headers; self.set_dirty(true); @@ -1620,7 +1522,7 @@ impl Component for EnvelopeView { } UIEvent::Input(ref key) if !self.cmd_buf.is_empty() - && self.mode == ViewMode::Url + && self.options.contains(ViewOptions::URL) && shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["go_to_url"]) => { let lidx = self.cmd_buf.parse::().unwrap(); @@ -1675,14 +1577,9 @@ impl Component for EnvelopeView { return true; } UIEvent::Input(ref key) - if (self.mode == ViewMode::Normal || self.mode == ViewMode::Url) - && shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_url_mode"]) => + if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["toggle_url_mode"]) => { - match self.mode { - ViewMode::Normal => self.mode = ViewMode::Url, - ViewMode::Url => self.mode = ViewMode::Normal, - _ => {} - } + self.options.toggle(ViewOptions::URL); self.initialised = false; self.dirty = true; return true; @@ -1846,20 +1743,16 @@ impl Component for EnvelopeView { let mut our_map = self.view_settings.env_view_shortcuts.clone(); - if !(self.mode.is_attachment() - || self.mode == ViewMode::Subview - || self.mode == ViewMode::Source(Source::Decoded) - || self.mode == ViewMode::Source(Source::Raw) - || self.mode == ViewMode::Url) - { - our_map.remove("return_to_normal_view"); - } - if self.mode != ViewMode::Url { + //if !self + // .options + // .contains(ViewOptions::SOURCE | ViewOptions::URL) + // || self.filters.is_empty() + //{ + // our_map.remove("return_to_normal_view"); + //} + if !self.options.contains(ViewOptions::URL) { our_map.remove("go_to_url"); } - if !(self.mode == ViewMode::Normal || self.mode == ViewMode::Url) { - our_map.remove("toggle_url_mode"); - } map.insert(Shortcuts::ENVELOPE_VIEW, our_map); map diff --git a/meli/src/mail/view/filters.rs b/meli/src/mail/view/filters.rs new file mode 100644 index 00000000..afae80aa --- /dev/null +++ b/meli/src/mail/view/filters.rs @@ -0,0 +1,394 @@ +/* + * meli + * + * Copyright 2023 Manos Pitsidianakis + * + * This file is part of meli. + * + * meli is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * meli is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with meli. If not, see . + */ + +use std::{ + borrow::Cow, + io::Write, + process::{Command, Stdio}, + sync::Arc, +}; + +type ProcessEventFn = fn(&mut ViewFilter, &mut UIEvent, &mut Context) -> bool; + +use melib::{ + attachment_types::{ContentType, MultipartType, Text}, + error::*, + parser::BytesExt, + text_processing::Truncate, + utils::xdg::query_default_app, + Attachment, Result, +}; + +use crate::{ + components::*, + desktop_exec_to_command, + terminal::{Area, CellBuffer}, + Context, ErrorKind, File, StatusEvent, UIEvent, +}; + +#[derive(Clone)] +pub struct ViewFilter { + pub filter_invocation: String, + pub content_type: ContentType, + pub notice: Option>, + pub body_text: String, + pub unfiltered: Vec, + pub event_handler: Option, + pub id: ComponentId, +} + +impl std::fmt::Debug for ViewFilter { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.debug_struct(stringify!(ViewFilter)) + .field("filter_invocation", &self.filter_invocation) + .field("content_type", &self.content_type) + .field("notice", &self.notice) + .field("body_text", &self.body_text.trim_at_boundary(18)) + .field("body_text_len", &self.body_text.len()) + .field("event_handler", &self.event_handler.is_some()) + .field("id", &self.id) + .finish() + } +} + +impl std::fmt::Display for ViewFilter { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.filter_invocation.trim_at_boundary(5)) + } +} + +impl ViewFilter { + pub fn new_html(body: &Attachment, context: &mut Context) -> Result { + fn run(cmd: &str, args: &[&str], bytes: &[u8]) -> Result { + let mut html_filter = Command::new(cmd) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?; + html_filter + .stdin + .as_mut() + .ok_or("Failed to write to html filter stdin")? + .write_all(bytes) + .chain_err_summary(|| "Failed to write to html filter stdin")?; + Ok(String::from_utf8_lossy( + &html_filter + .wait_with_output() + .chain_err_summary(|| "Could not wait for process output")? + .stdout, + ) + .into()) + } + + let mut att = body; + let mut stack = vec![body]; + while let Some(a) = stack.pop() { + match a.content_type { + ContentType::Text { + kind: Text::Html, .. + } => { + att = a; + break; + } + ContentType::Text { .. } + | ContentType::PGPSignature + | ContentType::CMSSignature => { + continue; + } + ContentType::Multipart { + kind: MultipartType::Related, + ref parts, + ref parameters, + .. + } => { + if let Some(main_attachment) = parameters + .iter() + .find_map(|(k, v)| if k == b"type" { Some(v) } else { None }) + .and_then(|t| parts.iter().find(|a| a.content_type == t.as_slice())) + { + stack.push(main_attachment); + } else { + for a in parts { + if let ContentType::Text { + kind: Text::Html, .. + } = a.content_type + { + att = a; + break; + } + } + stack.extend(parts); + } + } + ContentType::Multipart { + kind: MultipartType::Alternative, + ref parts, + .. + } => { + for a in parts { + if let ContentType::Text { + kind: Text::Html, .. + } = a.content_type + { + att = a; + break; + } + } + stack.extend(parts); + } + ContentType::Multipart { + kind: _, ref parts, .. + } => { + for a in parts { + if let ContentType::Text { + kind: Text::Html, .. + } = a.content_type + { + att = a; + break; + } + } + stack.extend(parts); + } + _ => {} + } + } + let bytes: Vec = att.decode(Default::default()); + + let settings = &context.settings; + if let Some(filter_invocation) = settings.pager.html_filter.as_ref() { + match run("sh", &["-c", filter_invocation], &bytes) { + Err(err) => { + return Err(Error::new(format!( + "Failed to start html filter process `{}`", + filter_invocation, + )) + .set_source(Some(Arc::new(err))) + .set_kind(ErrorKind::External)); + } + Ok(body_text) => { + let notice = + Some(format!("Text piped through `{}`.\n\n", filter_invocation).into()); + return Ok(Self { + filter_invocation: filter_invocation.clone(), + content_type: att.content_type.clone(), + notice, + body_text, + unfiltered: bytes, + event_handler: Some(Self::html_process_event), + id: ComponentId::default(), + }); + } + } + } + if let Ok(body_text) = run("w3m", &["-I", "utf-8", "-T", "text/html"], &bytes) { + return Ok(Self { + filter_invocation: "w3m -I utf-8 -T text/html".into(), + content_type: att.content_type.clone(), + notice: Some("Text piped through `w3m -I utf-8 -T text/html`.\n\n".into()), + body_text, + unfiltered: bytes, + event_handler: Some(Self::html_process_event), + id: ComponentId::default(), + }); + } + + Err( + Error::new("Failed to find any application to use as html filter") + .set_kind(ErrorKind::Configuration), + ) + } + + pub fn new_attachment(att: &Attachment, context: &mut Context) -> Result { + if matches!( + att.content_type, + ContentType::Other { .. } | ContentType::OctetStream { .. } + ) { + return Err(Error::new(format!( + "Cannot view {} attachment as text.", + att.content_type, + )) + .set_kind(ErrorKind::ValueError)); + } + if let ContentType::Multipart { + kind: MultipartType::Alternative, + ref parts, + .. + } = att.content_type + { + if let Some(Ok(v)) = parts + .iter() + .find(|p| p.is_text() && !p.body().trim().is_empty()) + .map(|p| Self::new_attachment(p, context)) + { + return Ok(v); + } + } else if let ContentType::Multipart { + kind: MultipartType::Related, + ref parts, + .. + } = att.content_type + { + if let Some(v @ Ok(_)) = parts.iter().find_map(|p| { + if let v @ Ok(_) = Self::new_attachment(p, context) { + Some(v) + } else { + None + } + }) { + return v; + } + } + if att.is_html() { + return Self::new_html(att, context); + } + if matches!( + att.content_type, + ContentType::Multipart { + kind: MultipartType::Digest, + .. + } + ) { + return Ok(Self { + filter_invocation: String::new(), + content_type: att.content_type.clone(), + notice: None, + body_text: String::new(), + unfiltered: vec![], + event_handler: None, + id: ComponentId::default(), + }); + } + if let ContentType::Multipart { + kind: MultipartType::Mixed, + ref parts, + .. + } = att.content_type + { + if let Some(Ok(res)) = + parts + .iter() + .find_map(|part| match Self::new_attachment(part, context) { + v @ Ok(_) => Some(v), + Err(_) => None, + }) + { + return Ok(res); + } + } + let notice = Some("Viewing attachment.\n\n".into()); + Ok(Self { + filter_invocation: String::new(), + content_type: att.content_type.clone(), + notice, + body_text: att.text(), + unfiltered: att.decode(Default::default()), + event_handler: None, + id: ComponentId::default(), + }) + } + + fn html_process_event( + _self: &mut ViewFilter, + event: &mut UIEvent, + context: &mut Context, + ) -> bool { + if matches!(event, UIEvent::Input(key) if *key == context.settings.shortcuts.envelope_view.open_html) + { + let command = context + .settings + .pager + .html_open + .as_ref() + .map(|s| s.to_string()) + .or_else(|| query_default_app("text/html").ok()); + let command = if cfg!(target_os = "macos") { + command.or_else(|| Some("open".into())) + } else if cfg!(target_os = "linux") { + command.or_else(|| Some("xdg-open".into())) + } else { + command + }; + if let Some(command) = command { + match File::create_temp_file(&_self.unfiltered, None, None, Some("html"), true) + .and_then(|p| { + let exec_cmd = desktop_exec_to_command( + &command, + p.path().display().to_string(), + false, + ); + + Ok(( + p, + Command::new("sh") + .args(["-c", &exec_cmd]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn()?, + )) + }) { + Ok((p, child)) => { + context.replies.push_back(UIEvent::StatusEvent( + StatusEvent::UpdateSubStatus(command.to_string()), + )); + context.temp_files.push(p); + context.children.push(child); + } + Err(err) => { + context.replies.push_back(UIEvent::StatusEvent( + StatusEvent::DisplayMessage(format!( + "Failed to start `{command}`: {err}", + )), + )); + } + } + } else { + context + .replies + .push_back(UIEvent::StatusEvent(StatusEvent::DisplayMessage( + "Couldn't find a default application for html files.".to_string(), + ))); + } + return true; + } + false + } +} + +impl Component for ViewFilter { + fn draw(&mut self, _: &mut CellBuffer, _: Area, _: &mut Context) {} + fn process_event(&mut self, event: &mut UIEvent, context: &mut Context) -> bool { + if let Some(ref mut f) = self.event_handler { + return f(self, event, context); + } + false + } + + fn is_dirty(&self) -> bool { + false + } + + fn set_dirty(&mut self, _: bool) {} + + fn id(&self) -> ComponentId { + self.id + } +} diff --git a/meli/src/mail/view/html.rs b/meli/src/mail/view/html.rs index be8a09c3..03b3a842 100644 --- a/meli/src/mail/view/html.rs +++ b/meli/src/mail/view/html.rs @@ -49,7 +49,7 @@ impl std::fmt::Debug for HtmlView { impl HtmlView { pub fn new(body: &Attachment, context: &mut Context) -> Self { let id = ComponentId::default(); - let bytes: Vec = body.decode_rec(Default::default()); + let bytes: Vec = body.decode(Default::default()); let settings = &context.settings; let mut display_text = if let Some(filter_invocation) = settings.pager.html_filter.as_ref() diff --git a/meli/src/mail/view/types.rs b/meli/src/mail/view/types.rs index 5ff82822..5ac19743 100644 --- a/meli/src/mail/view/types.rs +++ b/meli/src/mail/view/types.rs @@ -19,7 +19,9 @@ * along with meli. If not, see . */ -use melib::{attachment_types::Charset, pgp::DecryptionMetadata, Attachment, Error, Result}; +use std::fmt::Write as IoWrite; + +use melib::{attachment_types::Charset, error::*, pgp::DecryptionMetadata, Attachment, Result}; use crate::{ conf::shortcuts::EnvelopeViewShortcuts, @@ -101,31 +103,97 @@ pub enum Source { Raw, } -#[derive(PartialEq, Debug, Default)] -pub enum ViewMode { - #[default] - Normal, - Url, - Attachment(usize), - Source(Source), - Subview, +bitflags::bitflags! { + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] + pub struct ViewOptions: u8 { + const DEFAULT = 0; + const URL = 1; + const SOURCE = Self::URL.bits() << 1; + const SOURCE_RAW = Self::SOURCE.bits() << 1; + } } -macro_rules! is_variant { - ($n:ident, $($var:tt)+) => { - #[inline] - pub fn $n(&self) -> bool { - matches!(self, Self::$($var)*) - } - }; +impl Default for ViewOptions { + fn default() -> Self { + Self::DEFAULT + } } -impl ViewMode { - is_variant! { is_normal, Normal } - is_variant! { is_url, Url } - is_variant! { is_attachment, Attachment(_) } - is_variant! { is_source, Source(_) } - is_variant! { is_subview, Subview } +impl ViewOptions { + pub fn convert( + &self, + links: &mut Vec, + attachment: &melib::Attachment, + text: &str, + ) -> String { + let mut text = if self.contains(Self::SOURCE) { + if self.contains(Self::SOURCE_RAW) { + String::from_utf8_lossy(attachment.raw()).into_owned() + } else { + /* Decode each header value */ + let mut ret = String::new(); + match melib::email::parser::headers::headers(attachment.raw()).map(|(_, v)| v) { + Ok(headers) => { + for (h, v) in headers { + _ = match melib::email::parser::encodings::phrase(v, true) { + Ok((_, v)) => ret.write_fmt(format_args!( + "{h}: {}\n", + String::from_utf8_lossy(&v) + )), + Err(err) => ret.write_fmt(format_args!("{h}: {err}\n")), + }; + } + } + Err(err) => { + _ = write!(&mut ret, "{err}"); + } + } + if !ret.ends_with("\n\n") { + ret.push_str("\n\n"); + } + ret.push_str(text); + ret + } + } else { + text.to_string() + }; + + while text.ends_with("\n\n") { + text.pop(); + text.pop(); + } + + if self.contains(Self::URL) { + if links.is_empty() { + let finder = linkify::LinkFinder::new(); + *links = finder + .links(&text) + .filter_map(|l| { + if *l.kind() == linkify::LinkKind::Url { + Some(Link { + start: l.start(), + end: l.end(), + kind: LinkKind::Url, + }) + } else if *l.kind() == linkify::LinkKind::Email { + Some(Link { + start: l.start(), + end: l.end(), + kind: LinkKind::Email, + }) + } else { + None + } + }) + .collect::>(); + } + for (lidx, l) in links.iter().enumerate().rev() { + text.insert_str(l.start, &format!("[{}]", lidx)); + } + } + + text + } } #[derive(Debug)] diff --git a/melib/src/email/attachments.rs b/melib/src/email/attachments.rs index c99ec1b4..d8073ff9 100644 --- a/melib/src/email/attachments.rs +++ b/melib/src/email/attachments.rs @@ -711,16 +711,15 @@ impl Attachment { ContentType::Text { kind: Text::Plain, .. } => false, + ContentType::Multipart { + kind: MultipartType::Digest, + .. + } => false, ContentType::Multipart { kind: MultipartType::Alternative, ref parts, .. } => parts.iter().all(Self::is_html), - ContentType::Multipart { - kind: MultipartType::Related, - .. - } => false, - ContentType::Multipart { ref parts, .. } => parts.iter().any(Self::is_html), _ => false, } }