/* * 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::{ convert::TryInto, future::Future, io::Write, pin::Pin, process::{Command, Stdio}, sync::{Arc, Mutex}, }; use indexmap::IndexSet; use melib::{ email::attachment_types::{ContentType, MultipartType}, list_management, Address, AddressBook, Draft, HeaderName, SpecialUsageMailbox, SubjectPrefix, UnixTimestamp, }; use nix::sys::wait::WaitStatus; use super::*; use crate::{accounts::JobRequest, jobs::JoinHandle, terminal::embed::EmbedTerminal}; #[cfg(feature = "gpgme")] pub mod gpg; //pub mod edit_attachments; //use edit_attachments::*; pub mod hooks; #[derive(Debug, PartialEq, Eq)] enum Cursor { Headers, Body, Sign, Encrypt, Attachments, } #[derive(Debug)] enum EmbedStatus { Stopped(Arc>, File), Running(Arc>, File), } impl EmbedStatus { #[inline(always)] fn is_stopped(&self) -> bool { matches!(self, Self::Stopped(_, _)) } } impl std::ops::Deref for EmbedStatus { type Target = Arc>; fn deref(&self) -> &Self::Target { match self { Self::Stopped(ref e, _) | Self::Running(ref e, _) => e, } } } impl std::ops::DerefMut for EmbedStatus { fn deref_mut(&mut self) -> &mut Self::Target { match self { Self::Stopped(ref mut e, _) | Self::Running(ref mut e, _) => e, } } } #[derive(Debug)] struct Embedded { status: EmbedStatus, } #[derive(Debug)] pub struct Composer { reply_context: Option<(MailboxHash, EnvelopeHash)>, account_hash: AccountHash, cursor: Cursor, pager: Pager, draft: Draft, form: FormWidget, mode: ViewMode, embedded: Option, embed_dimensions: (usize, usize), #[cfg(feature = "gpgme")] gpg_state: gpg::GpgComposeState, dirty: bool, has_changes: bool, initialized: bool, hooks: Vec, id: ComponentId, } #[derive(Debug)] enum ViewMode { Discard(ComponentId, UIDialog), //EditAttachments { // widget: EditAttachments, //}, Edit, Embed, SelectRecipients(UIDialog
), #[cfg(feature = "gpgme")] SelectEncryptKey(bool, gpg::KeySelection), Send(UIConfirmationDialog), WaitingForSendResult(UIDialog, JoinHandle>), } impl ViewMode { fn is_edit(&self) -> bool { matches!(self, ViewMode::Edit) } //fn is_edit_attachments(&self) -> bool { // matches!(self, ViewMode::EditAttachments { .. }) //} } impl std::fmt::Display for Composer { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { let subject = self.draft.headers().get(HeaderName::SUBJECT); if let Some(ref val) = subject.filter(|s| !s.is_empty()) { val.trim_at_boundary(4); write!(f, "{}", val) } else if let Some(ref val) = self .draft .headers() .get(HeaderName::TO) .filter(|s| !s.is_empty()) { val.trim_at_boundary(4); write!(f, "to {}", val) } else { write!(f, "draft") } } } impl Composer { pub fn new(context: &Context) -> Self { let mut pager = Pager::new(context); pager.set_show_scrollbar(true); let mut form = FormWidget::default(); form.set_cursor(2); Composer { reply_context: None, account_hash: AccountHash::default(), cursor: Cursor::Headers, pager, draft: Draft::default(), hooks: vec![ hooks::HEADERWARN, hooks::PASTDATEWARN, hooks::MISSINGATTACHMENTWARN, hooks::EMPTYDRAFTWARN, ], form, mode: ViewMode::Edit, #[cfg(feature = "gpgme")] gpg_state: gpg::GpgComposeState::default(), dirty: true, has_changes: false, embedded: None, embed_dimensions: (80, 20), initialized: false, id: ComponentId::default(), } } pub fn with_account(account_hash: AccountHash, context: &Context) -> Self { let mut ret = Composer { account_hash, ..Composer::new(context) }; // Add user's custom hooks. for hook in account_settings!(context[account_hash].composing.custom_compose_hooks) .iter() .cloned() .map(Into::into) { ret.hooks.push(hook); } ret.hooks.retain(|h| { !account_settings!(context[account_hash].composing.disabled_compose_hooks) .iter() .any(|hn| hn.as_str() == h.name()) }); for h in context.accounts[&account_hash] .backend_capabilities .extra_submission_headers { ret.draft.set_header(h.clone(), String::new()); } for (h, v) in account_settings!(context[account_hash].composing.default_header_values).iter() { if v.is_empty() { continue; } ret.draft.set_header(h.into(), v.into()); } if *account_settings!(context[account_hash].composing.insert_user_agent) { ret.draft.set_header( HeaderName::USER_AGENT, format!("meli {}", option_env!("CARGO_PKG_VERSION").unwrap_or("0.0")), ); } if *account_settings!(context[account_hash].composing.format_flowed) { ret.pager .set_reflow(melib::text_processing::Reflow::FormatFlowed); } ret } pub fn edit( account_hash: AccountHash, env_hash: EnvelopeHash, bytes: &[u8], context: &Context, ) -> Result { let mut ret = Composer::with_account(account_hash, context); // Add user's custom hooks. for hook in account_settings!(context[account_hash].composing.custom_compose_hooks) .iter() .cloned() .map(Into::into) { ret.hooks.push(hook); } ret.hooks.retain(|h| { !account_settings!(context[account_hash].composing.disabled_compose_hooks) .iter() .any(|hn| hn.as_str() == h.name()) }); let envelope: EnvelopeRef = context.accounts[&account_hash].collection.get_env(env_hash); ret.draft = Draft::edit(&envelope, bytes)?; ret.account_hash = account_hash; Ok(ret) } pub fn reply_to( coordinates @ (account_hash, _, _): (AccountHash, MailboxHash, EnvelopeHash), reply_body: String, context: &mut Context, reply_to_all: bool, ) -> Self { let mut ret = Composer::with_account(account_hash, context); // Add user's custom hooks. for hook in account_settings!(context[account_hash].composing.custom_compose_hooks) .iter() .cloned() .map(Into::into) { ret.hooks.push(hook); } ret.hooks.retain(|h| { !account_settings!(context[account_hash].composing.disabled_compose_hooks) .iter() .any(|hn| hn.as_str() == h.name()) }); let account = &context.accounts[&account_hash]; let envelope = account.collection.get_env(coordinates.2); let subject = { let subject = envelope.subject(); let prefix_list = account_settings!( context[ret.account_hash] .composing .reply_prefix_list_to_strip ) .as_ref() .map(|v| v.iter().map(String::as_str).collect::>()) .unwrap_or_default(); let subject_stripped = subject.as_ref().strip_prefixes_from_list( if prefix_list.is_empty() { <&str>::USUAL_PREFIXES } else { &prefix_list }, Some(1), ) == &subject.as_ref(); let prefix = account_settings!(context[ret.account_hash].composing.reply_prefix).as_str(); if subject_stripped { format!("{prefix} {subject}", prefix = prefix, subject = subject) } else { subject.to_string() } }; ret.draft.set_header(HeaderName::SUBJECT, subject); ret.draft.set_header( HeaderName::REFERENCES, format!( "{} {}", envelope .references() .iter() .fold(String::new(), |mut acc, x| { if !acc.is_empty() { acc.push(' '); } acc.push_str(&x.to_string()); acc }), envelope.message_id_display() ), ); ret.draft.set_header( HeaderName::IN_REPLY_TO, envelope.message_id_display().into(), ); if let Some(reply_to) = envelope.other_headers().get(HeaderName::TO) { let to: &str = reply_to; let extra_identities = &account.settings.account.extra_identities; if let Some(extra) = extra_identities .iter() .find(|extra| to.contains(extra.as_str())) { ret.draft.set_header(HeaderName::FROM, extra.into()); } } // "Mail-Followup-To/(To+Cc+(Mail-Reply-To/Reply-To/From)) for follow-up, // Mail-Reply-To/Reply-To/From for reply-to-author." // source: https://cr.yp.to/proto/replyto.html if reply_to_all { let mut to = IndexSet::new(); if let Some(actions) = list_management::ListActions::detect(&envelope) { if let Some(post) = actions.post { if let list_management::ListAction::Email(list_post_addr) = post[0] { if let Ok(list_address) = melib::email::parser::generic::mailto(list_post_addr) .map(|(_, m)| m.address) { to.extend(list_address); } } } } if let Some(reply_to) = envelope .other_headers() .get(HeaderName::MAIL_FOLLOWUP_TO) .and_then(|v| v.try_into().ok()) { to.insert(reply_to); } else if let Some(reply_to) = envelope .other_headers() .get(HeaderName::REPLY_TO) .and_then(|v| v.try_into().ok()) { to.insert(reply_to); } else { to.extend(envelope.from().iter().cloned()); } to.extend(envelope.to().iter().cloned()); if let Ok(ours) = TryInto::
::try_into( context.accounts[&coordinates.0] .settings .account() .make_display_name() .as_str(), ) { to.remove(&ours); } ret.draft.set_header(HeaderName::TO, { let mut ret: String = to.into_iter() .fold(String::new(), |mut s: String, n: Address| { s.push_str(&n.to_string()); s.push_str(", "); s }); ret.pop(); ret.pop(); ret }); ret.draft .set_header(HeaderName::CC, envelope.field_cc_to_string()); } else if let Some(reply_to) = envelope.other_headers().get(HeaderName::MAIL_REPLY_TO) { ret.draft.set_header(HeaderName::TO, reply_to.to_string()); } else if let Some(reply_to) = envelope.other_headers().get(HeaderName::REPLY_TO) { ret.draft.set_header(HeaderName::TO, reply_to.to_string()); } else { ret.draft .set_header(HeaderName::TO, envelope.field_from_to_string()); } ret.draft.body = { let mut ret = attribution_string( account_settings!( context[ret.account_hash] .composing .attribution_format_string ) .as_ref() .map(|s| s.as_str()), envelope.from().get(0), envelope.date(), *account_settings!( context[ret.account_hash] .composing .attribution_use_posix_locale ), ); for l in reply_body.lines() { ret.push('>'); ret.push_str(l); ret.push('\n'); } ret }; ret.account_hash = coordinates.0; ret.reply_context = Some((coordinates.1, coordinates.2)); ret } pub fn reply_to_select( coordinates @ (account_hash, _, _): (AccountHash, MailboxHash, EnvelopeHash), reply_body: String, context: &mut Context, ) -> Self { let mut ret = Composer::reply_to(coordinates, reply_body, context, false); let account = &context.accounts[&account_hash]; let parent_message = account.collection.get_env(coordinates.2); /* If message is from a mailing list and we detect a List-Post header, ask * user if they want to reply to the mailing list or the submitter of * the message */ if let Some(actions) = list_management::ListActions::detect(&parent_message) { if let Some(post) = actions.post { if let list_management::ListAction::Email(list_post_addr) = post[0] { if let Ok((_, mailto)) = melib::email::parser::generic::mailto(list_post_addr) { let mut addresses = vec![( parent_message.from()[0].clone(), parent_message.field_from_to_string(), )]; for add in mailto.address { let add_s = add.to_string(); addresses.push((add, add_s)); } ret.mode = ViewMode::SelectRecipients(UIDialog::new( "select recipients", addresses, false, Some(Box::new(move |id: ComponentId, results: &[Address]| { Some(UIEvent::FinishedUIDialog( id, Box::new( results .iter() .map(|a| a.to_string()) .collect::>() .join(", "), ), )) })), context, )); } } } } ret } pub fn reply_to_author( coordinates: (AccountHash, MailboxHash, EnvelopeHash), reply_body: String, context: &mut Context, ) -> Self { Composer::reply_to(coordinates, reply_body, context, false) } pub fn reply_to_all( coordinates: (AccountHash, MailboxHash, EnvelopeHash), reply_body: String, context: &mut Context, ) -> Self { Composer::reply_to(coordinates, reply_body, context, true) } pub fn forward( coordinates: (AccountHash, MailboxHash, EnvelopeHash), bytes: &[u8], env: &Envelope, as_attachment: bool, context: &mut Context, ) -> Self { let mut composer = Composer::with_account(coordinates.0, context); let mut draft: Draft = Draft::default(); draft.set_header(HeaderName::SUBJECT, format!("Fwd: {}", env.subject())); let preamble = format!( r#" ---------- Forwarded message --------- From: {} Date: {} Subject: {} To: {} "#, env.field_from_to_string(), env.date_as_str(), env.subject(), env.field_to_to_string() ); if as_attachment { let mut attachment = AttachmentBuilder::new(b""); let mut disposition: ContentDisposition = ContentDispositionKind::Attachment.into(); { disposition.filename = Some(format!("{}.eml", env.message_id_raw())); } attachment .set_raw(bytes.to_vec()) .set_body_to_raw() .set_content_type(ContentType::MessageRfc822) .set_content_transfer_encoding(ContentTransferEncoding::_8Bit) .set_content_disposition(disposition); draft.attachments.push(attachment); draft.body = preamble; } else { let content_type = ContentType::default(); let preamble: AttachmentBuilder = Attachment::new(content_type, Default::default(), preamble.into_bytes()).into(); draft.attachments.push(preamble); draft.attachments.push(env.body_bytes(bytes).into()); } composer.set_draft(draft, context); composer } pub fn set_draft(&mut self, draft: Draft, context: &Context) { self.draft = draft; self.update_form(context); } fn update_draft(&mut self) { let header_values = self.form.values_mut(); let draft_header_map = self.draft.headers_mut(); for (k, v) in draft_header_map.iter_mut() { if let Some(vn) = header_values.get(k.as_str()) { *v = vn.as_str().to_string(); } } } fn update_form(&mut self, context: &Context) { let old_cursor = self.form.cursor(); let shortcuts = self.shortcuts(context); self.form = FormWidget::new( ("Save".into(), true), /* cursor_up_shortcut */ shortcuts .get(Shortcuts::COMPOSING) .and_then(|c| c.get("scroll_up").cloned()) .unwrap_or_else(|| context.settings.shortcuts.composing.scroll_up.clone()), /* cursor_down_shortcut */ shortcuts .get(Shortcuts::COMPOSING) .and_then(|c| c.get("scroll_down").cloned()) .unwrap_or_else(|| context.settings.shortcuts.composing.scroll_down.clone()), ); self.form.hide_buttons(); self.form.set_cursor(old_cursor); let headers = self.draft.headers(); let account_hash = self.account_hash; for k in context.accounts[&account_hash] .backend_capabilities .extra_submission_headers { if matches!(*k, HeaderName::NEWSGROUPS) { self.form.push_cl(( k.into(), headers[k].to_string(), Box::new(move |c, term| { c.accounts[&account_hash] .mailbox_entries .values() .filter_map(|v| { if v.path.starts_with(term) { Some(v.path.to_string()) } else { None } }) .map(AutoCompleteEntry::from) .collect::>() }), )); } else { self.form.push((k.into(), headers[k].to_string())); } } for k in &[ HeaderName::DATE, HeaderName::FROM, HeaderName::TO, HeaderName::CC, HeaderName::BCC, HeaderName::SUBJECT, ] { if matches!(*k, HeaderName::TO | HeaderName::CC | HeaderName::BCC) { self.form.push_cl(( k.into(), headers[k].to_string(), Box::new(move |c, term| { let book: &AddressBook = &c.accounts[&account_hash].address_book; let results: Vec = book.search(term); results .into_iter() .map(AutoCompleteEntry::from) .collect::>() }), )); } else if k == HeaderName::FROM { self.form.push_cl(( k.into(), headers[k].to_string(), Box::new(move |c, _term| { c.accounts .values() .map(|acc| { let addr = acc.settings.account.make_display_name(); let desc = match account_settings!(c[acc.hash()].composing.send_mail) { crate::conf::composing::SendMail::ShellCommand(ref cmd) => { let mut cmd = cmd.as_str(); cmd.truncate_at_boundary(10); format!("{} [exec: {}]", acc.name(), cmd) } #[cfg(feature = "smtp")] crate::conf::composing::SendMail::Smtp(ref inner) => { let mut hostname = inner.hostname.as_str(); hostname.truncate_at_boundary(10); format!("{} [smtp: {}]", acc.name(), hostname) } crate::conf::composing::SendMail::ServerSubmission => { format!("{} [server submission]", acc.name()) } }; (addr, desc) }) .map(AutoCompleteEntry::from) .collect::>() }), )); } else { self.form.push((k.into(), headers[k].to_string())); } } } fn draw_attachments(&self, grid: &mut CellBuffer, area: Area, context: &Context) { let attachments_no = self.draft.attachments().len(); let theme_default = crate::conf::value(context, "theme_default"); grid.clear_area(area, theme_default); #[cfg(feature = "gpgme")] if self.gpg_state.sign_mail.is_true() { let key_list = self .gpg_state .sign_keys .iter() .map(|k| k.fingerprint()) .collect::>() .join(", "); grid.write_string( &format!( "☑ sign with {}", if self.gpg_state.sign_keys.is_empty() { "default key" } else { key_list.as_str() } ), theme_default.fg, if self.cursor == Cursor::Sign { crate::conf::value(context, "highlight").bg } else { theme_default.bg }, theme_default.attrs, area.skip_rows(1), None, ); } else { grid.write_string( "☐ don't sign", theme_default.fg, if self.cursor == Cursor::Sign { crate::conf::value(context, "highlight").bg } else { theme_default.bg }, theme_default.attrs, area.skip_rows(1), None, ); } #[cfg(feature = "gpgme")] if self.gpg_state.encrypt_mail.is_true() { let key_list = self .gpg_state .encrypt_keys .iter() .map(|k| k.fingerprint()) .collect::>() .join(", "); grid.write_string( &format!( "{}{}", if self.gpg_state.encrypt_keys.is_empty() { "☐ no keys to encrypt with!" } else { "☑ encrypt with " }, if self.gpg_state.encrypt_keys.is_empty() { "" } else { key_list.as_str() } ), theme_default.fg, if self.cursor == Cursor::Encrypt { crate::conf::value(context, "highlight").bg } else { theme_default.bg }, theme_default.attrs, area.skip_rows(2), None, ); } else { grid.write_string( "☐ don't encrypt", theme_default.fg, if self.cursor == Cursor::Encrypt { crate::conf::value(context, "highlight").bg } else { theme_default.bg }, theme_default.attrs, area.skip_rows(2), None, ); } if attachments_no == 0 { grid.write_string( "no attachments", theme_default.fg, if self.cursor == Cursor::Attachments { crate::conf::value(context, "highlight").bg } else { theme_default.bg }, theme_default.attrs, area.skip_rows(3), None, ); } else { grid.write_string( &format!("{} attachments ", attachments_no), theme_default.fg, if self.cursor == Cursor::Attachments { crate::conf::value(context, "highlight").bg } else { theme_default.bg }, theme_default.attrs, area.skip_rows(3), None, ); for (i, a) in self.draft.attachments().iter().enumerate() { grid.write_string( &if let Some(name) = a.content_type().name() { format!( "[{}] \"{}\", {} {}", i, name, a.content_type(), melib::BytesDisplay(a.raw.len()) ) } else { format!( "[{}] {} {}", i, a.content_type(), melib::BytesDisplay(a.raw.len()) ) }, theme_default.fg, theme_default.bg, theme_default.attrs, area.skip_rows(4 + i), None, ); } } } fn update_from_file(&mut self, file: File, context: &mut Context) -> bool { match file.read_to_string().and_then(|res| { self.draft.update(res.as_str()).map_err(|err| { self.draft.set_body(res); err }) }) { Ok(has_changes) => { self.has_changes = has_changes; true } Err(err) => { context.replies.push_back(UIEvent::Notification( Some("Could not parse draft headers correctly.".to_string()), format!("{err}\nThe invalid text has been set as the body of your draft",), Some(NotificationType::Error(melib::error::ErrorKind::None)), )); self.has_changes = true; false } } } } impl Component for Composer { fn draw(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { if area.height() < 4 { return; } if !self.initialized { #[cfg(feature = "gpgme")] if self.gpg_state.sign_mail.is_unset() { self.gpg_state.sign_mail = ToggleFlag::InternalVal(*account_settings!( context[self.account_hash].pgp.auto_sign )); } if !self.draft.headers().contains_key(HeaderName::FROM) || self.draft.headers()[HeaderName::FROM].is_empty() { self.draft.set_header( HeaderName::FROM, context.accounts[&self.account_hash] .settings .account() .make_display_name(), ); } self.pager.update_from_str(self.draft.body(), Some(77)); self.update_form(context); self.initialized = true; } let header_height = self.form.len(); let theme_default = crate::conf::value(context, "theme_default"); let mid = 0; /* let mid = if width > 80 { let width = width - 80; let mid = width / 2; if self.dirty { for i in get_y(upper_left)..=get_y(bottom_right) { //set_and_join_box(grid, (mid, i), VERT_BOUNDARY); grid[(mid, i)] .set_fg(theme_default.fg) .set_bg(theme_default.bg); //set_and_join_box(grid, (mid + 80, i), VERT_BOUNDARY); grid[(mid + 80, i)] .set_fg(theme_default.fg) .set_bg(theme_default.bg); } } mid } else { 0 }; */ let header_area = area .take_rows(header_height) .skip_cols(mid + 1) .skip_cols_from_end(mid); let attachments_no = self.draft.attachments().len(); let attachment_area = area .skip_rows(header_height) .skip_rows( area.height() .saturating_sub(header_area.height() + 4 + attachments_no), ) .skip_cols(mid + 1); let body_area = area .skip_rows(header_height) .skip_rows_from_end(attachment_area.height()); grid.clear_area(area.nth_row(0), crate::conf::value(context, "highlight")); grid.write_string( if self.reply_context.is_some() { "COMPOSING REPLY" } else { "COMPOSING MESSAGE" }, crate::conf::value(context, "highlight").fg, crate::conf::value(context, "highlight").bg, crate::conf::value(context, "highlight").attrs, area.nth_row(0), None, ); /* grid.change_theme( ( set_x(pos_dec(header_area.upper_left(), (0, 1)), x), set_y(header_area.bottom_right(), y), ), crate::conf::value(context, "highlight"), ); grid.clear_area( ( pos_dec(upper_left, (0, 1)), set_x(bottom_right, get_x(upper_left) + mid), ), theme_default, ); grid.clear_area( ( ( get_x(bottom_right).saturating_sub(mid), get_y(upper_left).saturating_sub(1), ), bottom_right, ), theme_default, ); */ /* Regardless of view mode, do the following */ self.form.draw(grid, header_area, context); if let Some(ref mut embedded) = self.embedded { let embed_pty = &mut embedded.status; let embed_area = area; match embed_pty { EmbedStatus::Running(_, _) => { let mut guard = embed_pty.lock().unwrap(); grid.clear_area(embed_area, theme_default); grid.copy_area(guard.grid.buffer(), embed_area, guard.grid.area()); guard.set_terminal_size((embed_area.width(), embed_area.height())); context.dirty_areas.push_back(area); self.dirty = false; return; } EmbedStatus::Stopped(_, _) => { let guard = embed_pty.lock().unwrap(); grid.copy_area(guard.grid.buffer(), embed_area, guard.grid.buffer().area()); grid.change_colors(embed_area, Color::Byte(8), theme_default.bg); let our_map: ShortcutMap = account_settings!(context[self.account_hash].shortcuts.composing) .key_values(); let mut shortcuts: ShortcutMaps = Default::default(); shortcuts.insert(Shortcuts::COMPOSING, our_map); let stopped_message: String = format!("Process with PID {} has stopped.", guard.child_pid); let stopped_message_2: String = format!( "-press '{}' (edit shortcut) to re-activate.", shortcuts[Shortcuts::COMPOSING]["edit"] ); const STOPPED_MESSAGE_3: &str = "-press Ctrl-C to forcefully kill it and return to editor."; let max_len = std::cmp::max( stopped_message.len(), std::cmp::max(stopped_message_2.len(), STOPPED_MESSAGE_3.len()), ); let inner_area = create_box(grid, area.center_inside((max_len + 5, 5))); grid.clear_area(inner_area, theme_default); for (i, l) in [ stopped_message.as_str(), stopped_message_2.as_str(), STOPPED_MESSAGE_3, ] .iter() .enumerate() { grid.write_string( l, theme_default.fg, theme_default.bg, theme_default.attrs, inner_area.skip_rows(i), None, //Some(get_x(inner_area.upper_left())), ); } context.dirty_areas.push_back(area); self.dirty = false; return; } } } else { self.embed_dimensions = (area.width(), area.height()); } if self.pager.size().0 > body_area.width() { self.pager.set_initialised(false); } // Force clean pager area, because if body height is less than body_area it will // might leave draw artifacts in the remaining area. grid.clear_area(body_area, theme_default); self.set_dirty(true); self.pager.draw(grid, body_area, context); match self.cursor { Cursor::Headers => { /* grid.change_theme( ( pos_dec(body_area.upper_left(), (1, 0)), pos_dec( set_y(body_area.upper_left(), get_y(body_area.bottom_right())), (1, 0), ), ), theme_default, ); */ } Cursor::Body => { /* grid.change_theme( ( pos_dec(body_area.upper_left(), (1, 0)), pos_dec( set_y(body_area.upper_left(), get_y(body_area.bottom_right())), (1, 0), ), ), ThemeAttribute { fg: theme_default.fg, bg: crate::conf::value(context, "highlight").bg, attrs: if grid.use_color { crate::conf::value(context, "highlight").attrs } else { crate::conf::value(context, "highlight").attrs | Attr::REVERSE }, }, ); */ } Cursor::Sign | Cursor::Encrypt | Cursor::Attachments => {} } //if !self.mode.is_edit_attachments() { self.draw_attachments(grid, attachment_area, context); //} match self.mode { ViewMode::Edit | ViewMode::Embed => {} //ViewMode::EditAttachments { ref mut widget } => { // let inner_area = create_box(grid, area); // (EditAttachmentsRefMut { // inner: widget, // draft: &mut self.draft, // }) // .draw(grid, inner_area, context); //} ViewMode::Send(ref mut s) => { s.draw(grid, body_area, context); } #[cfg(feature = "gpgme")] ViewMode::SelectEncryptKey( _, gpg::KeySelection::Loaded { ref mut widget, keys: _, }, ) => { widget.draw(grid, body_area, context); } #[cfg(feature = "gpgme")] ViewMode::SelectEncryptKey(_, _) => {} ViewMode::SelectRecipients(ref mut s) => { s.draw(grid, body_area, context); } ViewMode::Discard(_, ref mut s) => { /* Let user choose whether to quit with/without saving or cancel */ s.draw(grid, body_area, context); } ViewMode::WaitingForSendResult(ref mut s, _) => { /* Let user choose whether to wait for success or cancel */ s.draw(grid, body_area, context); } } self.dirty = false; context.dirty_areas.push_back(area); } fn process_event(&mut self, mut event: &mut UIEvent, context: &mut Context) -> bool { if let UIEvent::VisibilityChange(_) = event { self.pager.process_event(event, context); } let shortcuts = self.shortcuts(context); // Process scrolling first, since in my infinite wisdom I made this so // unnecessarily complex match &event { UIEvent::Input(ref key) if self.mode.is_edit() && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["scroll_up"]) => { self.set_dirty(true); self.cursor = match self.cursor { // match order is evaluation order, so it matters here because of the if guard // process_event side effects Cursor::Attachments => Cursor::Encrypt, Cursor::Encrypt => Cursor::Sign, Cursor::Sign => Cursor::Body, Cursor::Body if !self.pager.process_event(event, context) => { self.form.process_event(event, context); Cursor::Headers } Cursor::Body => Cursor::Body, Cursor::Headers if self.form.process_event(event, context) => Cursor::Headers, Cursor::Headers => Cursor::Headers, }; return true; } UIEvent::Input(ref key) if self.mode.is_edit() && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["scroll_down"]) => { self.set_dirty(true); self.cursor = match self.cursor { Cursor::Headers if self.form.process_event(event, context) => Cursor::Headers, Cursor::Headers => Cursor::Body, Cursor::Body if self.pager.process_event(event, context) => Cursor::Body, Cursor::Body => Cursor::Sign, Cursor::Sign => Cursor::Encrypt, Cursor::Encrypt => Cursor::Attachments, Cursor::Attachments => Cursor::Attachments, }; return true; } _ => {} } if self.cursor == Cursor::Headers && self.mode.is_edit() && self.form.process_event(event, context) { if let UIEvent::InsertInput(_) = event { self.update_draft(); self.has_changes = true; } self.set_dirty(true); return true; } match (&mut self.mode, &mut event) { (ViewMode::Edit, _) => { if self.pager.process_event(event, context) { return true; } } //(ViewMode::EditAttachments { ref mut widget }, _) => { // if (EditAttachmentsRefMut { // inner: widget, // draft: &mut self.draft, // }) // .process_event(event, context) // { // if matches!( // widget.buttons.result(), // Some(FormButtonActions::Cancel | FormButtonActions::Accept) // ) { self.mode = ViewMode::Edit; // } // self.set_dirty(true); // return true; // } //} (ViewMode::Send(ref selector), UIEvent::FinishedUIDialog(id, result)) if selector.id() == *id => { if let Some(true) = result.downcast_ref::() { self.update_draft(); match send_draft_async( #[cfg(feature = "gpgme")] self.gpg_state.clone(), context, self.account_hash, self.draft.clone(), SpecialUsageMailbox::Sent, Flag::SEEN, ) { Ok(job) => { let handle = context .main_loop_handler .job_executor .spawn_blocking("compose::submit".into(), job); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::NewJob( handle.job_id, ))); self.mode = ViewMode::WaitingForSendResult( UIDialog::new( "Waiting for confirmation.. The tab will close automatically \ on successful submission.", vec![ ('c', "force close tab".to_string()), ( 'n', "close this message and return to edit mode" .to_string(), ), ], true, Some(Box::new(move |id: ComponentId, results: &[char]| { Some(UIEvent::FinishedUIDialog( id, Box::new(results.first().cloned().unwrap_or('c')), )) })), context, ), handle, ); } Err(err) => { context.replies.push_back(UIEvent::Notification( None, err.to_string(), Some(NotificationType::Error(err.kind)), )); save_draft( self.draft.clone().finalise().unwrap().as_bytes(), context, SpecialUsageMailbox::Drafts, Flag::SEEN | Flag::DRAFT, self.account_hash, ); self.mode = ViewMode::Edit; } } } self.set_dirty(true); return true; } (ViewMode::Send(ref dialog), UIEvent::ComponentUnrealize(ref id)) if *id == dialog.id() => { self.mode = ViewMode::Edit; self.set_dirty(true); } (ViewMode::SelectRecipients(ref dialog), UIEvent::ComponentUnrealize(ref id)) if *id == dialog.id() => { self.mode = ViewMode::Edit; self.set_dirty(true); } (ViewMode::Discard(_, ref dialog), UIEvent::ComponentUnrealize(ref id)) if *id == dialog.id() => { self.mode = ViewMode::Edit; self.set_dirty(true); } #[cfg(feature = "gpgme")] ( ViewMode::SelectEncryptKey(_, ref mut selector), UIEvent::ComponentUnrealize(ref id), ) if *id == selector.id() => { self.mode = ViewMode::Edit; self.set_dirty(true); return true; } (ViewMode::Send(ref mut selector), _) => { if selector.process_event(event, context) { self.set_dirty(true); return true; } } ( ViewMode::SelectRecipients(ref selector), UIEvent::FinishedUIDialog(id, ref mut result), ) if selector.id() == *id => { if let Some(to_val) = result.downcast_mut::() { self.draft .set_header(HeaderName::TO, std::mem::take(to_val)); self.update_form(context); } self.mode = ViewMode::Edit; self.set_dirty(true); return true; } (ViewMode::SelectRecipients(ref mut selector), _) => { if selector.process_event(event, context) { self.set_dirty(true); return true; } } (ViewMode::Discard(u, ref selector), UIEvent::FinishedUIDialog(id, ref mut result)) if selector.id() == *id => { if let Some(key) = result.downcast_mut::() { match key { 'x' => { context.replies.push_back(UIEvent::Action(Tab(Kill(*u)))); return true; } 'n' => {} 'y' => { save_draft( self.draft.clone().finalise().unwrap().as_bytes(), context, SpecialUsageMailbox::Drafts, Flag::SEEN | Flag::DRAFT, self.account_hash, ); context.replies.push_back(UIEvent::Action(Tab(Kill(*u)))); return true; } _ => {} } } self.mode = ViewMode::Edit; self.set_dirty(true); return true; } (ViewMode::Discard(_, ref mut selector), _) => { if selector.process_event(event, context) { self.set_dirty(true); return true; } } ( ViewMode::WaitingForSendResult(ref selector, _), UIEvent::FinishedUIDialog(id, result), ) if selector.id() == *id => { if let Some(key) = result.downcast_mut::() { match key { 'c' => { context .replies .push_back(UIEvent::Action(Tab(Kill(self.id)))); self.set_dirty(true); return true; } 'n' => { self.set_dirty(true); if let ViewMode::WaitingForSendResult(_, handle) = std::mem::replace(&mut self.mode, ViewMode::Edit) { context.accounts[&self.account_hash].active_jobs.insert( handle.job_id, JobRequest::SendMessageBackground { handle }, ); } } _ => {} } } return true; } ( ViewMode::WaitingForSendResult(_, ref mut handle), UIEvent::StatusEvent(StatusEvent::JobFinished(ref job_id)), ) if handle.job_id == *job_id => { match handle .chan .try_recv() .map_err(|_: futures::channel::oneshot::Canceled| { Error::new("Job was canceled") }) { Err(err) | Ok(Some(Err(err))) => { self.mode = ViewMode::Edit; context.replies.push_back(UIEvent::Notification( None, err.to_string(), Some(NotificationType::Error(err.kind)), )); self.set_dirty(true); } Ok(None) | Ok(Some(Ok(()))) => { context .replies .push_back(UIEvent::Action(Tab(Kill(self.id)))); } } return false; } (ViewMode::WaitingForSendResult(ref mut selector, _), _) => { if selector.process_event(event, context) { self.set_dirty(true); return true; } } #[cfg(feature = "gpgme")] ( ViewMode::SelectEncryptKey(is_encrypt, ref mut selector), UIEvent::FinishedUIDialog(id, result), ) if *id == selector.id() => { if let Some(Some(key)) = result.downcast_mut::>() { if *is_encrypt { self.gpg_state.encrypt_keys.clear(); self.gpg_state.encrypt_keys.push(key.clone()); } else { self.gpg_state.sign_keys.clear(); self.gpg_state.sign_keys.push(key.clone()); } } self.mode = ViewMode::Edit; self.set_dirty(true); return true; } #[cfg(feature = "gpgme")] (ViewMode::SelectEncryptKey(_, ref mut selector), _) => { if selector.process_event(event, context) { self.set_dirty(true); return true; } } _ => {} } match *event { UIEvent::ConfigReload { old_settings: _ } => { self.set_dirty(true); } UIEvent::Resize => { self.set_dirty(true); } UIEvent::Input(ref key) if self.mode.is_edit() && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["scroll_up"]) => { self.set_dirty(true); self.cursor = match self.cursor { Cursor::Headers => return true, Cursor::Body => { self.form.process_event(event, context); Cursor::Headers } Cursor::Sign => Cursor::Body, Cursor::Encrypt => Cursor::Sign, Cursor::Attachments => Cursor::Encrypt, }; } UIEvent::Input(ref key) if self.mode.is_edit() && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["scroll_down"]) => { self.set_dirty(true); self.cursor = match self.cursor { Cursor::Headers => Cursor::Body, Cursor::Body => Cursor::Sign, Cursor::Sign => Cursor::Encrypt, Cursor::Encrypt => Cursor::Attachments, Cursor::Attachments => return true, }; } UIEvent::Input(Key::Char('\n')) if self.mode.is_edit() && (self.cursor == Cursor::Sign || self.cursor == Cursor::Encrypt) => { #[cfg(feature = "gpgme")] match self.cursor { Cursor::Sign => { let is_true = self.gpg_state.sign_mail.is_true(); self.gpg_state.sign_mail = ToggleFlag::from(!is_true); } Cursor::Encrypt => { let is_true = self.gpg_state.encrypt_mail.is_true(); self.gpg_state.encrypt_mail = ToggleFlag::from(!is_true); } _ => {} }; self.set_dirty(true); } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::COMPOSING]["send_mail"]) && self.mode.is_edit() => { self.update_draft(); { let Self { ref mut hooks, ref mut draft, .. } = self; for err in hooks .iter_mut() .filter_map(|h| { if let Err(err) = h(context, draft) { Some(err) } else { None } }) .collect::>() { context.replies.push_back(UIEvent::Notification( None, err.to_string(), None, )); } } self.mode = ViewMode::Send(UIConfirmationDialog::new( "send mail?", vec![(true, "yes".to_string()), (false, "no".to_string())], /* only one choice */ true, Some(Box::new(move |id: ComponentId, result: bool| { Some(UIEvent::FinishedUIDialog(id, Box::new(result))) })), context, )); return true; } UIEvent::EmbedInput((Key::Ctrl('z'), _)) => { self.embedded .as_ref() .unwrap() .status .lock() .unwrap() .stop(); match self.embedded.take() { Some(Embedded { status: EmbedStatus::Running(e, f), }) | Some(Embedded { status: EmbedStatus::Stopped(e, f), }) => { self.embedded = Some(Embedded { status: EmbedStatus::Stopped(e, f), }); } _ => {} } context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); self.set_dirty(true); } UIEvent::EmbedInput((ref k, ref b)) => { if let Some(ref mut embed) = self.embedded { let mut embed_guard = embed.status.lock().unwrap(); if embed_guard.write_all(b).is_err() { match embed_guard.is_active() { Ok(WaitStatus::Exited(_, exit_code)) => { drop(embed_guard); let embedded = self.embedded.take(); if exit_code != 0 { context.replies.push_back(UIEvent::Notification( None, format!( "Subprocess has exited with exit code {}", exit_code ), Some(NotificationType::Error( melib::error::ErrorKind::External, )), )); } else if let Some(Embedded { status: EmbedStatus::Running(_, file), }) = embedded { self.update_from_file(file, context); } self.initialized = false; self.mode = ViewMode::Edit; self.set_dirty(true); context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); } #[cfg(any(target_os = "linux", target_os = "android"))] Ok(WaitStatus::PtraceEvent(_, _, _)) | Ok(WaitStatus::PtraceSyscall(_)) => { drop(embed_guard); match self.embedded.take() { Some(Embedded { status: EmbedStatus::Running(e, f), }) | Some(Embedded { status: EmbedStatus::Stopped(e, f), }) => { self.embedded = Some(Embedded { status: EmbedStatus::Stopped(e, f), }); } _ => {} } self.mode = ViewMode::Edit; context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); self.set_dirty(true); return true; } Ok(WaitStatus::Stopped(_, _)) => { drop(embed_guard); match self.embedded.take() { Some(Embedded { status: EmbedStatus::Running(e, f), }) | Some(Embedded { status: EmbedStatus::Stopped(e, f), }) => { self.embedded = Some(Embedded { status: EmbedStatus::Stopped(e, f), }); } _ => {} } self.mode = ViewMode::Edit; context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); self.set_dirty(true); return true; } Ok(WaitStatus::Continued(_)) | Ok(WaitStatus::StillAlive) => { context .replies .push_back(UIEvent::EmbedInput((k.clone(), b.to_vec()))); drop(embed_guard); self.set_dirty(true); return true; } Ok(WaitStatus::Signaled(_, signal, _)) => { drop(embed_guard); context.replies.push_back(UIEvent::Notification( None, format!("Subprocess was killed by {} signal", signal), Some(NotificationType::Error( melib::error::ErrorKind::External, )), )); self.initialized = false; self.embedded = None; self.mode = ViewMode::Edit; context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); } Err(err) => { context.replies.push_back(UIEvent::Notification( Some("Embed editor crashed.".to_string()), format!("Subprocess has exited with reason {}", &err), Some(NotificationType::Error( melib::error::ErrorKind::External, )), )); drop(embed_guard); self.initialized = false; self.embedded = None; self.mode = ViewMode::Edit; context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); } } } } self.set_dirty(true); return true; } UIEvent::Input(ref key) if self.mode.is_edit() && self.cursor == Cursor::Sign && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["edit"]) => { #[cfg(feature = "gpgme")] match melib::email::parser::address::rfc2822address_list( self.form.values()["From"].as_str().as_bytes(), ) .map_err(|_err| -> Error { "No valid sender address in `From:`".into() }) .and_then(|(_, list)| { list.get(0) .cloned() .ok_or_else(|| "No valid sender address in `From:`".into()) }) .and_then(|addr| { gpg::KeySelection::new( false, account_settings!(context[self.account_hash].pgp.allow_remote_lookup) .is_true(), addr.get_email(), *account_settings!(context[self.account_hash].pgp.allow_remote_lookup), context, ) }) { Ok(widget) => { self.gpg_state.sign_mail = ToggleFlag::from(true); self.mode = ViewMode::SelectEncryptKey(false, widget); } Err(err) => { context.replies.push_back(UIEvent::Notification( Some("Could not list keys.".to_string()), format!("libgpgme error: {}", &err), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); } } self.set_dirty(true); return true; } UIEvent::Input(ref key) if self.mode.is_edit() && self.cursor == Cursor::Encrypt && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["edit"]) => { #[cfg(feature = "gpgme")] match melib::email::parser::address::rfc2822address_list( self.form.values()["To"].as_str().as_bytes(), ) .map_err(|_err| -> Error { "No valid recipient addresses in `To:`".into() }) .and_then(|(_, list)| { list.get(0) .cloned() .ok_or_else(|| "No valid recipient addresses in `To:`".into()) }) .and_then(|addr| { gpg::KeySelection::new( false, account_settings!(context[self.account_hash].pgp.allow_remote_lookup) .is_true(), addr.get_email(), *account_settings!(context[self.account_hash].pgp.allow_remote_lookup), context, ) }) { Ok(widget) => { self.gpg_state.encrypt_mail = ToggleFlag::from(true); self.mode = ViewMode::SelectEncryptKey(true, widget); } Err(err) => { context.replies.push_back(UIEvent::Notification( Some("Could not list keys.".to_string()), format!("libgpgme error: {}", &err), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); } } self.set_dirty(true); return true; } UIEvent::Input(ref key) if self.mode.is_edit() && self.cursor == Cursor::Attachments && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["edit"]) => { //self.mode = ViewMode::EditAttachments { // widget: EditAttachments::new(Some(self.account_hash)), //}; self.set_dirty(true); return true; } UIEvent::Input(ref key) if self.embedded.is_some() && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["edit"]) => { self.embedded .as_ref() .unwrap() .status .lock() .unwrap() .wake_up(); match self.embedded.take() { Some(Embedded { status: EmbedStatus::Running(e, f), }) | Some(Embedded { status: EmbedStatus::Stopped(e, f), }) => { self.embedded = Some(Embedded { status: EmbedStatus::Running(e, f), }); } _ => {} } self.mode = ViewMode::Embed; context .replies .push_back(UIEvent::ChangeMode(UIMode::Embed)); self.set_dirty(true); return true; } UIEvent::Input(Key::Ctrl('c')) if self.embedded.is_some() && self.embedded.as_ref().unwrap().status.is_stopped() => { match self.embedded.take() { Some(Embedded { status: EmbedStatus::Running(embed, file), }) | Some(Embedded { status: EmbedStatus::Stopped(embed, file), }) => { let guard = embed.lock().unwrap(); guard.wake_up(); guard.terminate(); self.update_from_file(file, context); } _ => {} } context.replies.push_back(UIEvent::Notification( None, "Subprocess was killed by SIGTERM signal".to_string(), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); self.initialized = false; self.mode = ViewMode::Edit; context .replies .push_back(UIEvent::ChangeMode(UIMode::Normal)); self.set_dirty(true); return true; } UIEvent::Input(ref key) if self.mode.is_edit() && shortcut!(key == shortcuts[Shortcuts::COMPOSING]["edit"]) => { /* Edit draft in $EDITOR */ let editor = if let Some(editor_command) = account_settings!(context[self.account_hash].composing.editor_command).as_ref() { editor_command.to_string() } else { match std::env::var("EDITOR") { Err(err) => { context.replies.push_back(UIEvent::Notification( Some(err.to_string()), "$EDITOR is not set. You can change an envvar's value with setenv \ or set composing.editor_command setting in your configuration." .to_string(), Some(NotificationType::Error(melib::error::ErrorKind::None)), )); self.set_dirty(true); return true; } Ok(v) => v, } }; /* update Draft's headers based on form values */ self.update_draft(); self.draft.set_wrap_header_preamble( account_settings!(context[self.account_hash].composing.wrap_header_preamble) .clone(), ); let f = match File::create_temp_file( self.draft.to_edit_string().as_bytes(), None, None, Some("eml"), true, ) { Ok(f) => f, Err(err) => { context.replies.push_back(UIEvent::Notification( None, err.to_string(), Some(NotificationType::Error(err.kind)), )); self.set_dirty(true); return true; } }; if *account_settings!(context[self.account_hash].composing.embed) { match crate::terminal::embed::create_pty( self.embed_dimensions.0, self.embed_dimensions.1, [editor, f.path().display().to_string()].join(" "), ) { Ok(embed) => { self.embedded = Some(Embedded { status: EmbedStatus::Running(embed, f), }); self.set_dirty(true); context .replies .push_back(UIEvent::ChangeMode(UIMode::Embed)); context.replies.push_back(UIEvent::Fork(ForkType::Embed( self.embedded .as_ref() .unwrap() .status .lock() .unwrap() .child_pid, ))); self.mode = ViewMode::Embed; } Err(err) => { context.replies.push_back(UIEvent::Notification( Some(format!("Failed to create pseudoterminal: {}", err)), err.to_string(), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); } } self.set_dirty(true); return true; } /* Kill input thread so that spawned command can be sole receiver of stdin */ { context.input_kill(); } let editor_command = format!("{} {}", editor, f.path().display()); log::trace!( "Executing: sh -c \"{}\"", editor_command.replace('"', "\\\"") ); match Command::new("sh") .args(["-c", &editor_command]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .spawn() { Ok(mut child) => { let _ = child.wait(); } Err(err) => { context.replies.push_back(UIEvent::Notification( Some(format!("Failed to execute {}: {}", editor, err)), err.to_string(), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); context.replies.push_back(UIEvent::Fork(ForkType::Finished)); context.restore_input(); self.set_dirty(true); return true; } } context.replies.push_back(UIEvent::Fork(ForkType::Finished)); match f.read_to_string().and_then(|res| { self.draft.update(res.as_str()).map_err(|err| { self.draft.set_body(res); err }) }) { Ok(has_changes) => { self.has_changes = has_changes; } Err(err) => { context.replies.push_back(UIEvent::Notification( Some("Could not parse draft headers correctly.".to_string()), format!( "{err}\nThe invalid text has been set as the body of your draft", ), Some(NotificationType::Error(melib::error::ErrorKind::None)), )); self.has_changes = true; } } self.initialized = false; self.set_dirty(true); return true; } UIEvent::Action(ref a) => match a { Action::Compose(ComposeAction::AddAttachmentPipe(ref command)) => { if command.is_empty() { context.replies.push_back(UIEvent::Notification( None, format!("pipe command value is invalid: {}", command), Some(NotificationType::Error(melib::error::ErrorKind::None)), )); return false; } match File::create_temp_file(&[], None, None, None, true) .and_then(|f| { let std_file = f.as_std_file()?; Ok(( f, Command::new("sh") .args(["-c", command]) .stdin(Stdio::null()) .stdout(Stdio::from(std_file)) .spawn()?, )) }) .and_then(|(f, child)| Ok((f, child.wait_with_output()?.stderr))) { Ok((f, stderr)) => { if !stderr.is_empty() { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( "Command stderr output: `{}`.", String::from_utf8_lossy(&stderr) )), )); } let attachment = match melib::email::compose::attachment_from_file(&f.path()) { Ok(a) => a, Err(err) => { context.replies.push_back(UIEvent::Notification( Some("could not add attachment".to_string()), err.to_string(), Some(NotificationType::Error( melib::error::ErrorKind::None, )), )); self.set_dirty(true); return true; } }; self.draft.attachments_mut().push(attachment); self.has_changes = true; self.set_dirty(true); return true; } Err(err) => { context.replies.push_back(UIEvent::Notification( None, format!("could not execute pipe command {}: {}", command, &err), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); self.set_dirty(true); return true; } } } Action::Compose(ComposeAction::AddAttachment(ref path)) => { let attachment = match melib::email::compose::attachment_from_file(path) { Ok(a) => a, Err(err) => { context.replies.push_back(UIEvent::Notification( Some("could not add attachment".to_string()), err.to_string(), Some(NotificationType::Error(melib::error::ErrorKind::None)), )); self.set_dirty(true); return true; } }; self.draft.attachments_mut().push(attachment); self.has_changes = true; self.set_dirty(true); return true; } Action::Compose(ComposeAction::AddAttachmentFilePicker(ref command)) => { let command = if let Some(cmd) = command .as_ref() .or(context.settings.terminal.file_picker_command.as_ref()) { cmd.as_str() } else { context.replies.push_back(UIEvent::Notification( None, "You haven't defined any command to launch in \ [terminal.file_picker_command]." .into(), Some(NotificationType::Error(melib::error::ErrorKind::None)), )); self.set_dirty(true); return true; }; /* Kill input thread so that spawned command can be sole receiver of stdin */ { context.input_kill(); } log::trace!("Executing: sh -c \"{}\"", command.replace('"', "\\\"")); match Command::new("sh") .args(["-c", command]) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::piped()) .spawn() .and_then(|child| Ok(child.wait_with_output()?.stderr)) { Ok(stderr) => { log::trace!("stderr: {}", &String::from_utf8_lossy(&stderr)); for path in stderr.split(|c| [b'\0', b'\t', b'\n'].contains(c)) { match melib::email::compose::attachment_from_file( &String::from_utf8_lossy(path).as_ref(), ) { Ok(a) => { self.draft.attachments_mut().push(a); self.has_changes = true; } Err(err) => { context.replies.push_back(UIEvent::Notification( Some(format!( "could not add attachment: {}", String::from_utf8_lossy(path) )), err.to_string(), Some(NotificationType::Error( melib::error::ErrorKind::None, )), )); } }; } } Err(err) => { let command = command.to_string(); context.replies.push_back(UIEvent::Notification( Some(format!("Failed to execute {}: {}", command, err)), err.to_string(), Some(NotificationType::Error(melib::error::ErrorKind::External)), )); context.restore_input(); self.set_dirty(true); return true; } } context.replies.push_back(UIEvent::Fork(ForkType::Finished)); self.set_dirty(true); return true; } Action::Compose(ComposeAction::RemoveAttachment(idx)) => { if *idx + 1 > self.draft.attachments().len() { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage( "attachment with given index does not exist".to_string(), ), )); self.set_dirty(true); return true; } self.draft.attachments_mut().remove(*idx); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::DisplayMessage( "attachment removed".to_string(), ))); self.set_dirty(true); return true; } Action::Compose(ComposeAction::SaveDraft) => { save_draft( self.draft.clone().finalise().unwrap().as_bytes(), context, SpecialUsageMailbox::Drafts, Flag::SEEN | Flag::DRAFT, self.account_hash, ); self.set_dirty(true); return true; } #[cfg(feature = "gpgme")] Action::Compose(ComposeAction::ToggleSign) => { let is_true = self.gpg_state.sign_mail.is_true(); self.gpg_state.sign_mail = ToggleFlag::from(!is_true); self.set_dirty(true); return true; } #[cfg(feature = "gpgme")] Action::Compose(ComposeAction::ToggleEncrypt) => { let is_true = self.gpg_state.encrypt_mail.is_true(); self.gpg_state.encrypt_mail = ToggleFlag::from(!is_true); self.set_dirty(true); return true; } _ => {} }, _ => {} } false } fn is_dirty(&self) -> bool { match self.mode { ViewMode::Embed => true, //ViewMode::EditAttachments { ref widget } => { // widget.dirty // || widget.buttons.is_dirty() // || self.dirty // || self.pager.is_dirty() // || self.form.is_dirty() //} ViewMode::Edit => self.dirty || self.pager.is_dirty() || self.form.is_dirty(), ViewMode::Discard(_, ref widget) => { widget.is_dirty() || self.pager.is_dirty() || self.form.is_dirty() } ViewMode::SelectRecipients(ref widget) => { widget.is_dirty() || self.pager.is_dirty() || self.form.is_dirty() } #[cfg(feature = "gpgme")] ViewMode::SelectEncryptKey(_, ref widget) => { widget.is_dirty() || self.pager.is_dirty() || self.form.is_dirty() } ViewMode::Send(ref widget) => { widget.is_dirty() || self.pager.is_dirty() || self.form.is_dirty() } ViewMode::WaitingForSendResult(ref widget, _) => { widget.is_dirty() || self.pager.is_dirty() || self.form.is_dirty() } } } fn set_dirty(&mut self, value: bool) { self.dirty = value; self.pager.set_dirty(value); self.form.set_dirty(value); match self.mode { ViewMode::Discard(_, ref mut widget) => { widget.set_dirty(value); } ViewMode::SelectRecipients(ref mut widget) => { widget.set_dirty(value); } #[cfg(feature = "gpgme")] ViewMode::SelectEncryptKey(_, ref mut widget) => { widget.set_dirty(value); } ViewMode::Send(ref mut widget) => { widget.set_dirty(value); } ViewMode::WaitingForSendResult(ref mut widget, _) => { widget.set_dirty(value); } ViewMode::Edit | ViewMode::Embed => {} } //if let ViewMode::EditAttachments { ref mut widget } = self.mode { // (EditAttachmentsRefMut { // inner: widget, // draft: &mut self.draft, // }) // .set_dirty(value); //} } fn kill(&mut self, uuid: ComponentId, context: &mut Context) { if self.id != uuid { return; } if !self.has_changes { context.replies.push_back(UIEvent::Action(Tab(Kill(uuid)))); return; } self.mode = ViewMode::Discard( uuid, UIDialog::new( "this draft has unsaved changes", vec![ ('x', "quit without saving".to_string()), ('y', "save draft and quit".to_string()), ('n', "cancel".to_string()), ], true, Some(Box::new(move |id: ComponentId, results: &[char]| { Some(UIEvent::FinishedUIDialog( id, Box::new(results.first().copied().unwrap_or('n')), )) })), context, ), ); } fn shortcuts(&self, context: &Context) -> ShortcutMaps { let mut map = if self.mode.is_edit() { self.pager.shortcuts(context) } else { Default::default() }; let our_map: ShortcutMap = account_settings!(context[self.account_hash].shortcuts.composing).key_values(); map.insert(Shortcuts::COMPOSING, our_map); map } fn id(&self) -> ComponentId { self.id } fn can_quit_cleanly(&mut self, context: &Context) -> bool { if !self.has_changes { return true; } let id = self.id; /* Play it safe and ask user for confirmation */ self.mode = ViewMode::Discard( id, UIDialog::new( "this draft has unsaved changes", vec![ ('x', "quit without saving".to_string()), ('y', "save draft and quit".to_string()), ('n', "cancel".to_string()), ], true, Some(Box::new(move |id: ComponentId, results: &[char]| { Some(UIEvent::FinishedUIDialog( id, Box::new(results.first().copied().unwrap_or('n')), )) })), context, ), ); self.set_dirty(true); false } } pub fn send_draft( _sign_mail: ToggleFlag, context: &mut Context, account_hash: AccountHash, mut draft: Draft, mailbox_type: SpecialUsageMailbox, flags: Flag, complete_in_background: bool, ) -> Result>>> { let format_flowed = *account_settings!(context[account_hash].composing.format_flowed); /* if sign_mail.is_true() { let mut content_type = ContentType::default(); if format_flowed { if let ContentType::Text { ref mut parameters, .. } = content_type { parameters.push((b"format".to_vec(), b"flowed".to_vec())); } } let mut body: AttachmentBuilder = Attachment::new( content_type, Default::default(), std::mem::replace(&mut draft.body, String::new()).into_bytes(), ) .into(); if !draft.attachments.is_empty() { let mut parts = std::mem::replace(&mut draft.attachments, Vec::new()); parts.insert(0, body); let boundary = ContentType::make_boundary(&parts); body = Attachment::new( ContentType::Multipart { boundary: boundary.into_bytes(), kind: MultipartType::Mixed, parts: parts.into_iter().map(|a| a.into()).collect::>(), }, Default::default(), Vec::new(), ) .into(); } let output = todo!(); crate::mail::pgp::sign( body.into(), account_settings!(context[account_hash].pgp.gpg_binary) .as_ref() .map(|s| s.as_str()), account_settings!(context[account_hash].pgp.sign_key) .as_ref() .map(|s| s.as_str()), ); match output { Err(err) => { log::error!( "Could not sign draft in account `{}`: {err}.", context.accounts[&account_hash].name(), ); context.replies.push_back(UIEvent::Notification( Some(format!( "Could not sign draft in account `{}`.", context.accounts[&account_hash].name() )), err.to_string(), Some(NotificationType::Error(err.kind)), )); return Err(err); } Ok(output) => { draft.attachments.push(output); } } } else { */ { let mut content_type = ContentType::default(); if format_flowed { if let ContentType::Text { ref mut parameters, .. } = content_type { parameters.push((b"format".to_vec(), b"flowed".to_vec())); } let body: AttachmentBuilder = Attachment::new( content_type, Default::default(), std::mem::take(&mut draft.body).into_bytes(), ) .into(); draft.attachments.insert(0, body); } } let bytes = draft.finalise().unwrap(); let send_mail = account_settings!(context[account_hash].composing.send_mail).clone(); let ret = context.accounts[&account_hash].send(bytes.clone(), send_mail, complete_in_background); save_draft(bytes.as_bytes(), context, mailbox_type, flags, account_hash); ret } pub fn save_draft( bytes: &[u8], context: &mut Context, mailbox_type: SpecialUsageMailbox, flags: Flag, account_hash: AccountHash, ) { match context.accounts[&account_hash].save_special(bytes, mailbox_type, flags) { Err(Error { summary, details, kind, .. }) => { context.replies.push_back(UIEvent::Notification( details.map(|s| s.into()), summary.to_string(), Some(NotificationType::Error(kind)), )); } Ok(mailbox_hash) => { context.replies.push_back(UIEvent::Notification( Some("Message saved".into()), format!( "Message saved in `{}`", &context.accounts[&account_hash].mailbox_entries[&mailbox_hash].name ), Some(NotificationType::Info), )); } } } pub fn send_draft_async( #[cfg(feature = "gpgme")] gpg_state: gpg::GpgComposeState, context: &mut Context, account_hash: AccountHash, mut draft: Draft, mailbox_type: SpecialUsageMailbox, flags: Flag, ) -> Result> + Send>>> { let store_sent_mail = *account_settings!(context[account_hash].composing.store_sent_mail); let format_flowed = *account_settings!(context[account_hash].composing.format_flowed); let event_sender = context.main_loop_handler.sender.clone(); #[cfg(feature = "gpgme")] #[allow(clippy::type_complexity)] let mut filters_stack: Vec< Box< dyn FnOnce( AttachmentBuilder, ) -> Pin> + Send>> + Send, >, > = vec![]; #[cfg(feature = "gpgme")] if gpg_state.sign_mail.is_true() && !gpg_state.encrypt_mail.is_true() { filters_stack.push(Box::new(crate::mail::pgp::sign_filter( gpg_state.sign_keys, )?)); } else if gpg_state.encrypt_mail.is_true() { filters_stack.push(Box::new(crate::mail::pgp::encrypt_filter( if gpg_state.sign_mail.is_true() { Some(gpg_state.sign_keys.clone()) } else { None }, gpg_state.encrypt_keys, )?)); } let send_mail = account_settings!(context[account_hash].composing.send_mail).clone(); let send_cb = context.accounts[&account_hash].send_async(send_mail); let mut content_type = ContentType::default(); if format_flowed { if let ContentType::Text { ref mut parameters, .. } = content_type { parameters.push((b"format".to_vec(), b"flowed".to_vec())); } } let mut body: AttachmentBuilder = Attachment::new( content_type, Default::default(), std::mem::take(&mut draft.body).into_bytes(), ) .into(); if !draft.attachments.is_empty() { let mut parts = std::mem::take(&mut draft.attachments); parts.insert(0, body); let boundary = ContentType::make_boundary(&parts); body = Attachment::new( ContentType::Multipart { boundary: boundary.into_bytes(), kind: MultipartType::Mixed, parts: parts.into_iter().map(|a| a.into()).collect::>(), parameters: vec![], }, Default::default(), vec![], ) .into(); } Ok(Box::pin(async move { #[cfg(feature = "gpgme")] for f in filters_stack { body = f(body).await?; } draft.attachments.insert(0, body); let message = Arc::new(draft.finalise()?); let ret = send_cb(message.clone()).await; let is_ok = ret.is_ok(); if !is_ok || store_sent_mail { event_sender .send(ThreadEvent::UIEvent(UIEvent::Callback(CallbackFn( Box::new(move |context| { save_draft( message.as_bytes(), context, if is_ok { mailbox_type } else { SpecialUsageMailbox::Drafts }, if is_ok { flags } else { Flag::SEEN | Flag::DRAFT }, account_hash, ); }), )))) .unwrap(); } else if !store_sent_mail && is_ok { let f = File::create_temp_file(message.as_bytes(), None, None, Some("eml"), false)?; log::info!( "store_sent_mail is false; stored sent mail to {}", f.path().display() ); } ret })) } /* Sender details * %+f — the sender's name and email address. * %+n — the sender's name (or email address, if no name is included). * %+a — the sender's email address. */ fn attribution_string( fmt: Option<&str>, sender: Option<&Address>, date: UnixTimestamp, posix: bool, ) -> String { let fmt = fmt.unwrap_or("On %a, %0e %b %Y %H:%M, %+f wrote:%n"); let fmt = fmt.replace( "%+f", &sender .map(|addr| addr.to_string()) .unwrap_or_else(|| "\"\"".to_string()), ); let fmt = fmt.replace( "%+n", &sender .map(|addr| addr.get_display_name().unwrap_or_else(|| addr.get_email())) .unwrap_or_else(|| "\"\"".to_string()), ); let fmt = fmt.replace( "%+a", &sender .map(|addr| addr.get_email()) .unwrap_or_else(|| "\"\"".to_string()), ); melib::utils::datetime::timestamp_to_string(date, Some(fmt.as_str()), posix) } #[cfg(test)] mod tests { use super::*; #[test] fn test_compose_reply_subject_prefix() { let raw_mail = r#"From: "some name" To: "me" Cc: Subject: RE: your e-mail Message-ID: Content-Type: text/plain hello world. "#; let envelope = Envelope::from_bytes(raw_mail.as_bytes(), None).expect("Could not parse mail"); let tempdir = tempfile::tempdir().unwrap(); let mut context = Context::new_mock(&tempdir); let account_hash = context.accounts[0].hash(); let mailbox_hash = MailboxHash::default(); let envelope_hash = envelope.hash(); context.accounts[0] .collection .insert(envelope, mailbox_hash); let composer = Composer::reply_to( (account_hash, mailbox_hash, envelope_hash), String::new(), &mut context, false, ); assert_eq!( &composer.draft.headers()[HeaderName::SUBJECT], "RE: your e-mail" ); assert_eq!( &composer.draft.headers()[HeaderName::TO], r#"some name "# ); let raw_mail = r#"From: "some name" To: "me" Cc: Subject: your e-mail Message-ID: Content-Type: text/plain hello world. "#; let envelope = Envelope::from_bytes(raw_mail.as_bytes(), None).expect("Could not parse mail"); let envelope_hash = envelope.hash(); context.accounts[0] .collection .insert(envelope, mailbox_hash); let composer = Composer::reply_to( (account_hash, mailbox_hash, envelope_hash), String::new(), &mut context, false, ); assert_eq!( &composer.draft.headers()[HeaderName::SUBJECT], "Re: your e-mail" ); assert_eq!( &composer.draft.headers()[HeaderName::TO], r#"some name "# ); } }