/* * meli * * Copyright 2017-2018 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::{ collections::HashSet, convert::TryFrom, fmt::Write as _, io::Write, process::{Command, Stdio}, }; use melib::{ email::attachment_types::ContentType, list_management, mailto::Mailto, parser::BytesExt, utils::datetime, Card, Draft, HeaderName, SpecialUsageMailbox, }; use smallvec::SmallVec; use super::*; use crate::{conf::accounts::JobRequest, jobs::JobId}; mod utils; pub use utils::*; mod html; pub use html::*; mod thread; pub use thread::*; mod types; pub use types::*; mod state; use state::*; pub mod envelope; pub use envelope::EnvelopeView; /// Contains an Envelope view, with sticky headers, a pager for the body, and /// subviews for more menus #[derive(Debug, Default)] pub struct MailView { coordinates: Option<(AccountHash, MailboxHash, EnvelopeHash)>, dirty: bool, contact_selector: Option>>, forward_dialog: Option>>>, theme_default: ThemeAttribute, active_jobs: HashSet, state: MailViewState, id: ComponentId, } impl Clone for MailView { fn clone(&self) -> Self { MailView { contact_selector: None, forward_dialog: None, state: MailViewState::default(), active_jobs: self.active_jobs.clone(), ..*self } } } impl fmt::Display for MailView { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "view mail") } } impl MailView { pub fn new( coordinates: Option<(AccountHash, MailboxHash, EnvelopeHash)>, context: &mut Context, ) -> Self { let mut ret = MailView { coordinates, dirty: true, contact_selector: None, forward_dialog: None, theme_default: crate::conf::value(context, "mail.view.body"), active_jobs: Default::default(), state: MailViewState::default(), id: ComponentId::default(), }; ret.init_futures(context); ret } fn init_futures(&mut self, context: &mut Context) { log::trace!("MailView::init_futures"); self.theme_default = crate::conf::value(context, "mail.view.body"); let mut pending_action = None; let Some(coordinates) = self.coordinates else { return; }; let account = &mut context.accounts[&coordinates.0]; if account.contains_key(coordinates.2) { { match account .operation(coordinates.2) .and_then(|mut op| op.as_bytes()) { Ok(fut) => { let mut handle = account .main_loop_handler .job_executor .spawn_specialized(fut); let job_id = handle.job_id; pending_action = if let MailViewState::Init { ref mut pending_action, } = self.state { pending_action.take() } else { None }; if let Ok(Some(bytes_result)) = try_recv_timeout!(&mut handle.chan) { match bytes_result { Ok(bytes) => { MailViewState::load_bytes(self, bytes, context); } Err(err) => { self.state = MailViewState::Error { err }; } } } else { self.state = MailViewState::LoadingBody { handle, pending_action: pending_action.take(), }; self.active_jobs.insert(job_id); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::NewJob(job_id))); } } Err(err) => { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!("Could not get message: {}", err)), )); } } } } if let Some(p) = pending_action { self.perform_action(p, context); } } fn perform_action(&mut self, action: PendingReplyAction, context: &mut Context) { let Some(coordinates) = self.coordinates else { return; }; let (bytes, reply_body, env) = match self.state { MailViewState::Init { ref mut pending_action, .. } | MailViewState::LoadingBody { ref mut pending_action, .. } => { *pending_action = Some(action); return; } MailViewState::Loaded { ref bytes, ref env, ref env_view, .. } => ( bytes, EnvelopeView::attachment_displays_to_text(&env_view.display, false), env, ), MailViewState::Error { .. } => { return; } }; let composer = match action { PendingReplyAction::Reply => { Box::new(Composer::reply_to_select(coordinates, reply_body, context)) } PendingReplyAction::ReplyToAuthor => { Box::new(Composer::reply_to_author(coordinates, reply_body, context)) } PendingReplyAction::ReplyToAll => { Box::new(Composer::reply_to_all(coordinates, reply_body, context)) } PendingReplyAction::ForwardAttachment => { Box::new(Composer::forward(coordinates, bytes, env, true, context)) } PendingReplyAction::ForwardInline => { Box::new(Composer::forward(coordinates, bytes, env, false, context)) } }; context .replies .push_back(UIEvent::Action(Tab(New(Some(composer))))); } pub fn update( &mut self, new_coordinates: (AccountHash, MailboxHash, EnvelopeHash), context: &mut Context, ) { if self.coordinates != Some(new_coordinates) { self.coordinates = Some(new_coordinates); self.init_futures(context); self.set_dirty(true); } } fn start_contact_selector(&mut self, context: &mut Context) { let Some(coordinates) = self.coordinates else { return; }; let account = &context.accounts[&coordinates.0]; if !account.contains_key(coordinates.2) { context .replies .push_back(UIEvent::StatusEvent(StatusEvent::DisplayMessage( "Email not found".into(), ))); return; } let envelope: EnvelopeRef = account.collection.get_env(coordinates.2); let mut entries = Vec::new(); for addr in envelope.from().iter().chain(envelope.to().iter()) { let mut new_card: Card = Card::new(); new_card.set_email(addr.get_email()); if let Some(display_name) = addr.get_display_name() { new_card.set_name(display_name); } entries.push((new_card, format!("{}", addr))); } drop(envelope); self.contact_selector = Some(Box::new(Selector::new( "select contacts to add", entries, false, Some(Box::new(move |id: ComponentId, results: &[Card]| { Some(UIEvent::FinishedUIDialog(id, Box::new(results.to_vec()))) })), context, ))); self.dirty = true; } } impl Component for MailView { fn draw(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { if !self.is_dirty() { return; } let Some(coordinates) = self.coordinates else { return; }; { let account = &context.accounts[&coordinates.0]; if !account.contains_key(coordinates.2) { /* The envelope has been renamed or removed, so wait for the appropriate * event to arrive */ return; } } if let MailViewState::Loaded { ref mut env_view, .. } = self.state { { let account = &mut context.accounts[&coordinates.0]; if !account.collection.get_env(coordinates.2).is_seen() { let job = account.backend.write().unwrap().set_flags( coordinates.2.into(), coordinates.1, smallvec::smallvec![(Ok(Flag::SEEN), true)], ); match job { Ok(fut) => { let handle = account .main_loop_handler .job_executor .spawn_specialized(fut); account.insert_job( handle.job_id, JobRequest::SetFlags { env_hashes: coordinates.2.into(), handle, }, ); } Err(err) => { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( "Could not set message as seen: {err}", )), )); } } } } env_view.draw(grid, area, context); } else if let MailViewState::Error { ref err } = self.state { clear_area(grid, area, self.theme_default); context.dirty_areas.push_back(area); context.replies.push_back(UIEvent::Notification( Some("Failed to open e-mail".to_string()), err.to_string(), Some(NotificationType::Error(err.kind)), )); log::error!("Failed to open envelope: {err}"); self.init_futures(context); return; } else { clear_area(grid, area, self.theme_default); context.dirty_areas.push_back(area); return; }; if let Some(ref mut s) = self.contact_selector.as_mut() { s.draw(grid, area, context); } else if let Some(ref mut s) = self.forward_dialog.as_mut() { s.draw(grid, area, context); } self.dirty = false; } fn process_event(&mut self, mut event: &mut UIEvent, context: &mut Context) -> bool { let Some(coordinates) = self.coordinates else { return false; }; if coordinates.0.is_null() || coordinates.1.is_null() { return false; } if let Some(ref mut s) = self.contact_selector { if s.process_event(event, context) { return true; } } if let Some(ref mut s) = self.forward_dialog { if s.process_event(event, context) { return true; } } /* If envelope data is loaded, pass it to envelope views */ if self.state.process_event(event, context) { return true; } match ( &mut self.contact_selector, &mut self.forward_dialog, &mut event, ) { (Some(ref s), _, UIEvent::FinishedUIDialog(id, results)) if *id == s.id() => { if let Some(results) = results.downcast_ref::>() { let account = &mut context.accounts[&coordinates.0]; { for card in results.iter() { account.address_book.add_card(card.clone()); } } self.contact_selector = None; } self.set_dirty(true); return true; } (_, Some(ref s), UIEvent::FinishedUIDialog(id, result)) if *id == s.id() => { if let Some(result) = result.downcast_ref::>() { self.forward_dialog = None; if let Some(result) = *result { self.perform_action(result, context); } } self.set_dirty(true); return true; } _ => {} } match &event { UIEvent::StatusEvent(StatusEvent::JobFinished(ref job_id)) if self.active_jobs.contains(job_id) => { match self.state { MailViewState::LoadingBody { ref mut handle, pending_action: _, } if handle.job_id == *job_id => { match handle.chan.try_recv() { Err(_) => { /* Job was canceled */ } Ok(None) => { /* something happened, perhaps a worker * thread panicked */ } Ok(Some(Ok(bytes))) => { MailViewState::load_bytes(self, bytes, context); } Ok(Some(Err(err))) => { self.state = MailViewState::Error { err }; } } } MailViewState::Init { .. } => { self.init_futures(context); } MailViewState::Loaded { .. } => { log::debug!( "MailView.active_jobs contains job id {:?} but MailViewState is \ already loaded; what job was this and why was it in active_jobs?", job_id ); } _ => {} } self.active_jobs.remove(job_id); self.set_dirty(true); } _ => {} } let shortcuts = &self.shortcuts(context); match *event { UIEvent::ConfigReload { old_settings: _ } => { self.theme_default = crate::conf::value(context, "theme_default"); self.set_dirty(true); } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["reply"]) => { self.perform_action(PendingReplyAction::Reply, context); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["reply_to_all"]) => { self.perform_action(PendingReplyAction::ReplyToAll, context); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["reply_to_author"]) => { self.perform_action(PendingReplyAction::ReplyToAuthor, context); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["forward"]) => { match mailbox_settings!( context[coordinates.0][&coordinates.1] .composing .forward_as_attachment ) { f if f.is_ask() => { self.forward_dialog = Some(Box::new(UIDialog::new( "How do you want the email to be forwarded?", vec![ ( Some(PendingReplyAction::ForwardInline), "inline".to_string(), ), ( Some(PendingReplyAction::ForwardAttachment), "as attachment".to_string(), ), ], true, Some(Box::new( move |id: ComponentId, result: &[Option]| { Some(UIEvent::FinishedUIDialog( id, Box::new(result.get(0).cloned()), )) }, )), context, ))); } f if f.is_true() => { self.perform_action(PendingReplyAction::ForwardAttachment, context); } _ => { self.perform_action(PendingReplyAction::ForwardInline, context); } } return true; } UIEvent::FinishedUIDialog(id, ref result) if id == self.id() => { if let Some(result) = result.downcast_ref::() { self.perform_action(*result, context); } return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::ENVELOPE_VIEW]["edit"]) => { let account_hash = coordinates.0; let env_hash = coordinates.2; let (sender, mut receiver) = crate::jobs::oneshot::channel(); let operation = context.accounts[&account_hash].operation(env_hash); let bytes_job = async move { let _ = sender.send(operation?.as_bytes()?.await); Ok(()) }; let handle = if context.accounts[&account_hash] .backend_capabilities .is_async { context .main_loop_handler .job_executor .spawn_specialized(bytes_job) } else { context .main_loop_handler .job_executor .spawn_blocking(bytes_job) }; context.accounts[&account_hash].insert_job( handle.job_id, crate::conf::accounts::JobRequest::Generic { name: "fetch envelope".into(), handle, on_finish: Some(CallbackFn(Box::new(move |context: &mut Context| { match receiver.try_recv() { Err(_) => { /* Job was canceled */ } Ok(None) => { /* something happened, perhaps a worker * thread panicked */ } Ok(Some(result)) => { match result.and_then(|bytes| { Composer::edit(account_hash, env_hash, &bytes, context) }) { Ok(composer) => { context.replies.push_back(UIEvent::Action(Tab(New( Some(Box::new(composer)), )))); } Err(err) => { let err_string = format!( "Failed to open envelope {}: {}", context.accounts[&account_hash] .collection .envelopes .read() .unwrap() .get(&env_hash) .map(|env| env.message_id_display()) .unwrap_or_else(|| "Not found".into()), err ); log::error!("{err_string}"); context.replies.push_back(UIEvent::Notification( Some("Failed to open e-mail".to_string()), err_string, Some(NotificationType::Error(err.kind)), )); } } } } }))), log_level: LogLevel::DEBUG, }, ); return true; } UIEvent::Action(View(ViewAction::AddAddressesToContacts)) => { self.start_contact_selector(context); return true; } UIEvent::Input(ref key) if self.contact_selector.is_none() && shortcut!( key == shortcuts[Shortcuts::ENVELOPE_VIEW]["add_addresses_to_contacts"] ) => { self.start_contact_selector(context); return true; } UIEvent::Input(Key::Esc) | UIEvent::Input(Key::Alt('')) if self.contact_selector.is_some() || self.forward_dialog.is_some() => { self.contact_selector = None; self.forward_dialog = None; self.set_dirty(true); return true; } UIEvent::EnvelopeRename(old_hash, new_hash) if coordinates.2 == old_hash => { self.coordinates.as_mut().unwrap().2 = new_hash; } UIEvent::Action(MailingListAction(ref e)) => { let account = &context.accounts[&coordinates.0]; if !account.contains_key(coordinates.2) { /* The envelope has been renamed or removed, so wait for the appropriate * event to arrive */ return true; } let envelope: EnvelopeRef = account.collection.get_env(coordinates.2); let detect = list_management::ListActions::detect(&envelope); if let Some(ref actions) = detect { match e { MailingListAction::ListPost if actions.post.is_some() => { /* open composer */ let mut failure = true; if let list_management::ListAction::Email(list_post_addr) = actions.post.as_ref().unwrap()[0] { if let Ok(mailto) = Mailto::try_from(list_post_addr) { let draft: Draft = mailto.into(); let mut composer = Composer::with_account(coordinates.0, context); composer.set_draft(draft, context); context.replies.push_back(UIEvent::Action(Tab(New(Some( Box::new(composer), ))))); failure = false; } } if failure { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(String::from( "Couldn't parse List-Post header value", )), )); } return true; } MailingListAction::ListUnsubscribe if actions.unsubscribe.is_some() => { /* autosend or open unsubscribe option */ let unsubscribe = actions.unsubscribe.as_ref().unwrap(); for option in unsubscribe.iter() { /* TODO: Ask for confirmation before proceding with an action */ match option { list_management::ListAction::Email(email) => { if let Ok(mailto) = Mailto::try_from(*email) { let mut draft: Draft = mailto.into(); draft.set_header( HeaderName::FROM, context.accounts[&coordinates.0] .settings .account() .make_display_name(), ); /* Manually drop stuff because borrowck doesn't do it * on its own */ drop(detect); drop(envelope); if let Err(err) = super::compose::send_draft( ToggleFlag::False, context, coordinates.0, draft, SpecialUsageMailbox::Sent, Flag::SEEN, true, ) { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( "Couldn't send unsubscribe e-mail: {}", err )), )); } return true; } } list_management::ListAction::Url(url) => { let url_launcher = mailbox_settings!( context[coordinates.0][&coordinates.1] .pager .url_launcher ) .as_ref() .map(|s| s.as_str()) .unwrap_or( #[cfg(target_os = "macos")] { "open" }, #[cfg(not(target_os = "macos"))] { "xdg-open" }, ); match Command::new(url_launcher) .arg(String::from_utf8_lossy(url).into_owned()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() { Ok(child) => { context.children.push(child); } Err(err) => { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( "Couldn't launch {:?}: {}", url_launcher, err )), )); } } return true; } list_management::ListAction::No => {} } } } MailingListAction::ListArchive if actions.archive.is_some() => { /* open archive url with url_launcher */ let url_launcher = mailbox_settings!( context[coordinates.0][&coordinates.1].pager.url_launcher ) .as_ref() .map(|s| s.as_str()) .unwrap_or( #[cfg(target_os = "macos")] { "open" }, #[cfg(not(target_os = "macos"))] { "xdg-open" }, ); match Command::new(url_launcher) .arg(actions.archive.unwrap()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() { Ok(child) => context.children.push(child), Err(err) => { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( "Couldn't launch {:?}: {}", url_launcher, err )), )); } } return true; } _ => { /* error print message to user */ } } }; } UIEvent::Action(Listing(OpenInNewTab)) => { context .replies .push_back(UIEvent::Action(Tab(New(Some(Box::new(self.clone())))))); return true; } _ => {} } false } fn is_dirty(&self) -> bool { self.dirty || self.state.is_dirty() || self .contact_selector .as_ref() .map(|s| s.is_dirty()) .unwrap_or(false) || self .forward_dialog .as_ref() .map(|s| s.is_dirty()) .unwrap_or(false) } fn set_dirty(&mut self, value: bool) { self.dirty = value; if let Some(ref mut s) = self.contact_selector { s.set_dirty(value); } else if let Some(ref mut s) = self.forward_dialog { s.set_dirty(value); } self.state.set_dirty(value); } fn shortcuts(&self, context: &Context) -> ShortcutMaps { self.state.shortcuts(context) } fn id(&self) -> ComponentId { self.id } fn kill(&mut self, id: ComponentId, context: &mut Context) { if self.id == id { context .replies .push_back(UIEvent::Action(Tab(Kill(self.id)))); } } }