/* * 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 . */ //! Pre-submission hooks for draft validation and/or transformations. pub use std::borrow::Cow; use super::*; pub enum HookFn { /// Stateful hook. Closure(Box Result<()> + Send + Sync>), /// Static hook. Ptr(fn(&mut Context, &mut Draft) -> Result<()>), } impl std::fmt::Debug for HookFn { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { f.debug_struct(stringify!(HookFn)) .field( "kind", &match self { Self::Closure(_) => "closure", Self::Ptr(_) => "function ptr", }, ) .finish() } } impl std::ops::Deref for HookFn { type Target = dyn FnMut(&mut Context, &mut Draft) -> Result<()> + Send + Sync; fn deref(&self) -> &Self::Target { match self { Self::Ptr(ref v) => v, Self::Closure(ref v) => v, } } } impl std::ops::DerefMut for HookFn { fn deref_mut(&mut self) -> &mut Self::Target { match self { Self::Ptr(ref mut v) => v, Self::Closure(ref mut v) => v, } } } #[derive(Debug)] /// Pre-submission hook for draft validation and/or transformations. pub struct Hook { /// Hook name for enabling/disabling it from configuration. /// /// See [`ComposingSettings::disabled_compose_hooks`]. name: Cow<'static, str>, hook_fn: HookFn, } impl Hook { /// Hook name for enabling/disabling it from configuration. /// /// See [`ComposingSettings::disabled_compose_hooks`]. pub fn name(&self) -> &str { self.name.as_ref() } pub fn new_shell_command(name: Cow<'static, str>, command: String) -> Self { let name_ = name.clone(); Self { name, hook_fn: HookFn::Closure(Box::new(move |_, draft| -> Result<()> { use std::thread; let mut child = Command::new("sh") .arg("-c") .arg(&command) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .map_err(|err| -> Error { format!( "could not execute `{command}`. Check if its binary is in PATH or if \ the command is valid. Original error: {err}" ) .into() })?; let mut stdin = child .stdin .take() .ok_or_else(|| Error::new("failed to get stdin"))?; thread::scope(|s| { s.spawn(move || { stdin .write_all(draft.body.as_bytes()) .expect("failed to write to stdin"); }); }); let output = child.wait_with_output().map_err(|err| -> Error { format!("failed to wait on hook child {name_}: {err}").into() })?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() || !stdout.is_empty() || !stderr.is_empty() { return Err(format!( "{name_}\n exit code: {:?}\n stdout:\n{}\n stderr:\n{}", output.status.code(), stdout, stderr, ) .into()); } Ok(()) })), } } } impl std::ops::Deref for Hook { type Target = dyn FnMut(&mut Context, &mut Draft) -> Result<()> + Send + Sync; fn deref(&self) -> &Self::Target { self.hook_fn.deref() } } impl std::ops::DerefMut for Hook { fn deref_mut(&mut self) -> &mut Self::Target { self.hook_fn.deref_mut() } } fn past_date_warn(_ctx: &mut Context, draft: &mut Draft) -> Result<()> { use melib::datetime::*; if let Some(v) = draft .headers .get("Date") .map(rfc822_to_timestamp) .and_then(Result::ok) { let now: UnixTimestamp = now(); let diff = now.abs_diff(v); if diff >= 60 * 60 { return Err(format!( "Value of Date header is {} minutes in the {}.", diff / 60, if now > v { "past" } else { "future" } ) .into()); } } Ok(()) } /// Warn if [`melib::Draft`] Date is far in the past/future. pub const PASTDATEWARN: Hook = Hook { name: Cow::Borrowed("past-date-warn"), hook_fn: HookFn::Ptr(past_date_warn), }; fn important_header_warn(_ctx: &mut Context, draft: &mut Draft) -> Result<()> { for hdr in ["From", "To"] { match draft.headers.get(hdr).map(melib::Address::list_try_from) { Some(Ok(_)) => {} Some(Err(err)) => return Err(format!("{hdr} header value is invalid ({err}).").into()), None => return Err(format!("{hdr} header is missing and should be present.").into()), } } { match draft .headers .get("Date") .map(melib::datetime::rfc822_to_timestamp) { Some(Err(err)) => return Err(format!("Date header value is invalid ({err}).").into()), Some(Ok(0)) => return Err("Date header value is invalid.".into()), _ => {} } } for hdr in ["Cc", "Bcc"] { if let Some(Err(err)) = draft .headers .get(hdr) .filter(|v| !v.trim().is_empty()) .map(melib::Address::list_try_from) { return Err(format!("{hdr} header value is invalid ({err}).").into()); } } Ok(()) } /// Warn if important [`melib::Draft`] header is missing or invalid. pub const HEADERWARN: Hook = Hook { name: Cow::Borrowed("important-header-warn"), hook_fn: HookFn::Ptr(important_header_warn), }; fn missing_attachment_warn(_ctx: &mut Context, draft: &mut Draft) -> Result<()> { if draft .headers .get("Subject") .map(|s| s.to_lowercase().contains("attach")) .unwrap_or(false) && draft.attachments.is_empty() { return Err("Subject mentions attachments but attachments are empty.".into()); } if draft.body.to_lowercase().contains("attach") && draft.attachments.is_empty() { return Err("Draft body mentions attachments but attachments are empty.".into()); } Ok(()) } /// Warn if Subject and/or draft body mentions attachments but they are missing. pub const MISSINGATTACHMENTWARN: Hook = Hook { name: Cow::Borrowed("missing-attachment-warn"), hook_fn: HookFn::Ptr(missing_attachment_warn), }; fn empty_draft_warn(_ctx: &mut Context, draft: &mut Draft) -> Result<()> { if draft .headers .get("Subject") .filter(|v| !v.trim().is_empty()) .is_none() && draft.body.trim().is_empty() { return Err("Both Subject and body are empty.".into()); } Ok(()) } /// Warn if draft has no subject and no body. pub const EMPTYDRAFTWARN: Hook = Hook { name: Cow::Borrowed("empty-draft-warn"), hook_fn: HookFn::Ptr(empty_draft_warn), }; #[cfg(test)] mod tests { use super::*; #[test] fn test_draft_hook_datewarn() { let tempdir = tempfile::tempdir().unwrap(); let mut ctx = Context::new_mock(&tempdir); let mut draft = Draft::default(); draft .set_body("αδφαφσαφασ".to_string()) .set_header("Subject", "test_update()".into()) .set_header("Date", "Sun, 16 Jun 2013 17:56:45 +0200".into()); println!("Check that past Date header value produces a warning…"); #[allow(const_item_mutation)] let err_msg = PASTDATEWARN(&mut ctx, &mut draft).unwrap_err().to_string(); assert!( err_msg.starts_with("Value of Date header is "), "PASTDATEWARN should complain about Date value being in the past: {}", err_msg ); assert!( err_msg.ends_with(" minutes in the past."), "PASTDATEWARN should complain about Date value being in the past: {}", err_msg ); } #[test] fn test_draft_hook_headerwarn() { let tempdir = tempfile::tempdir().unwrap(); let mut ctx = Context::new_mock(&tempdir); let mut draft = Draft::default(); draft .set_body("αδφαφσαφασ".to_string()) .set_header("Subject", "test_update()".into()) .set_header("Date", "Sun sds16 Jun 2013 17:56:45 +0200".into()); let mut hook = HEADERWARN; println!("Check for missing/empty From header value…"); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "From header value is invalid (Parsing error. In input: \"...\",\nError: Alternative, \ Many1, Alternative, atom(): starts with whitespace or empty).", "HEADERWARN should complain about From value being empty: {}", err_msg ); draft.set_header("From", "user ".into()); println!("Check for missing/empty To header value…"); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "To header value is invalid (Parsing error. In input: \"...\",\nError: Alternative, \ Many1, Alternative, atom(): starts with whitespace or empty).", "HEADERWARN should complain about To value being empty: {}", err_msg ); draft.set_header("To", "other user ".into()); println!("Check for invalid Date header value…"); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "Date header value is invalid.", "HEADERWARN should complain about Date value being invalid: {}", err_msg ); println!("Check that valid header values produces no errors…"); draft = Draft::default(); draft .set_body("αδφαφσαφασ".to_string()) .set_header("From", "user ".into()) .set_header("To", "other user ".into()) .set_header("Subject", "test_update()".into()); hook(&mut ctx, &mut draft).unwrap(); draft.set_header("From", "user user@example.com>".into()); println!("Check for invalid From header value…"); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "From header value is invalid (Parsing error. In input: \"user \ user@example.com>...\",\nError: Alternative, Tag).", "HEADERWARN should complain about From value being invalid: {}", err_msg ); } #[test] fn test_draft_hook_missingattachmentwarn() { let tempdir = tempfile::tempdir().unwrap(); let mut ctx = Context::new_mock(&tempdir); let mut draft = Draft::default(); draft .set_body("αδφαφσαφασ".to_string()) .set_header("Subject", "Attachments included".into()) .set_header("Date", "Sun, 16 Jun 2013 17:56:45 +0200".into()); let mut hook = MISSINGATTACHMENTWARN; println!( "Check that mentioning attachments in Subject produces a warning if draft has no \ attachments…" ); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "Subject mentions attachments but attachments are empty.", "MISSINGATTACHMENTWARN should complain about missing attachments: {}", err_msg ); draft .set_header("Subject", "Hello.".into()) .set_body("Attachments included".to_string()); println!( "Check that mentioning attachments in body produces a warning if draft has no \ attachments…" ); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "Draft body mentions attachments but attachments are empty.", "MISSINGATTACHMENTWARN should complain about missing attachments: {}", err_msg ); println!( "Check that mentioning attachments produces no warnings if draft has attachments…" ); draft.set_header("Subject", "Attachments included".into()); let mut attachment = AttachmentBuilder::new(b""); attachment .set_raw(b"foobar".to_vec()) .set_content_type(ContentType::Other { name: Some("info.txt".to_string()), tag: b"text/plain".to_vec(), parameters: vec![], }) .set_content_transfer_encoding(ContentTransferEncoding::Base64); draft.attachments_mut().push(attachment); hook(&mut ctx, &mut draft).unwrap(); } #[test] fn test_draft_hook_emptydraftwarn() { let tempdir = tempfile::tempdir().unwrap(); let mut ctx = Context::new_mock(&tempdir); let mut draft = Draft::default(); draft.set_header("Date", "Sun, 16 Jun 2013 17:56:45 +0200".into()); let mut hook = EMPTYDRAFTWARN; println!("Check that empty draft produces a warning…"); let err_msg = hook(&mut ctx, &mut draft).unwrap_err().to_string(); assert_eq!( err_msg, "Both Subject and body are empty.", "EMPTYDRAFTWARN should complain about empty draft: {}", err_msg ); println!("Check that non-empty draft produces no warning…"); draft.set_header("Subject", "Ping".into()); hook(&mut ctx, &mut draft).unwrap(); } }