use std::{ convert::Infallible, hash::{DefaultHasher, Hash, Hasher}, str::FromStr, }; use build_info::{BuildInfo, VersionControl}; use letterbox_notmuch::SearchSummary; use regex::{RegexBuilder, RegexSetBuilder}; use serde::{Deserialize, Serialize}; use tracing::debug; #[derive(Serialize, Deserialize, Debug)] pub struct SearchResult { pub summary: SearchSummary, pub query: String, pub page: usize, pub results_per_page: usize, pub total: usize, } #[derive(Serialize, Deserialize, Debug, strum_macros::Display)] pub enum WebsocketMessage { RefreshMessages, } pub mod urls { pub const MOUNT_POINT: &'static str = "/api"; pub fn view_original(host: Option<&str>, id: &str) -> String { if let Some(host) = host { format!("//{host}/api/original/{id}") } else { format!("/api/original/{id}") } } pub fn cid_prefix(host: Option<&str>, cid: &str) -> String { if let Some(host) = host { format!("//{host}/api/cid/{cid}/") } else { format!("/api/cid/{cid}/") } } pub fn download_attachment(host: Option<&str>, id: &str, idx: &str, filename: &str) -> String { if let Some(host) = host { format!( "//{host}/api/download/attachment/{}/{}/{}", id, idx, filename ) } else { format!("/api/download/attachment/{}/{}/{}", id, idx, filename) } } } pub fn build_version(bi: fn() -> &'static BuildInfo) -> String { fn commit(git: &Option) -> String { let Some(VersionControl::Git(git)) = git else { return String::new(); }; let mut s = vec!["-".to_string(), git.commit_short_id.clone()]; if let Some(branch) = &git.branch { s.push(format!(" ({branch})")); } s.join("") } let bi = bi(); format!("v{}{}", bi.crate_info.version, commit(&bi.version_control)).to_string() } pub fn compute_color(data: &str) -> String { let mut hasher = DefaultHasher::new(); data.hash(&mut hasher); format!("#{:06x}", hasher.finish() % (1 << 24)) } #[derive( Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, )] pub enum MatchType { From, Sender, To, Cc, Subject, ListId, DeliveredTo, XForwardedTo, ReplyTo, XOriginalTo, XSpam, Body, #[default] Unknown, } #[derive(Debug, Default, Serialize, Deserialize)] pub struct Match { pub match_type: MatchType, pub needle: String, } #[derive(Debug, Default, Serialize, Deserialize)] pub struct Rule { pub stop_on_match: bool, pub matches: Vec, pub tag: String, } impl Rule { pub fn is_match(&self, header_key: &str, header_value: &str) -> bool { let pats: Vec<_> = self .matches .iter() .filter_map(|m| match m.match_type { MatchType::To => Some("^(to|cc|bcc|x-original-to)$"), MatchType::From => Some("^from$"), MatchType::Sender => Some("^sender$"), MatchType::Subject => Some("^subject$"), MatchType::ListId => Some("^list-id$"), MatchType::XOriginalTo => Some("^x-original-to$"), MatchType::ReplyTo => Some("^reply-to$"), MatchType::XSpam => Some("^x-spam$"), MatchType::Body => None, c => panic!("TODO handle '{c:?}' match type"), }) .collect(); let set = RegexSetBuilder::new(&pats) .case_insensitive(true) .build() .expect("failed to compile regex for matches"); let matches: Vec<_> = set.matches(header_key).into_iter().collect(); if !matches.is_empty() { //info!("matched key '{header_key}' '{header_value}'"); for m_idx in matches { let needle = regex::escape(&self.matches[m_idx].needle); let pat = RegexBuilder::new(&needle) .case_insensitive(true) .build() .expect("failed to compile regex for needle"); if pat.is_match(header_value) { debug!("{header_key} matched {header_value} against {needle}"); return true; } } } false } } mod matches { // From https://linux.die.net/man/5/procmailrc // If the regular expression contains '^TO_' it will be substituted by '(^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To):(.*[^-a-zA-Z0-9_.])?)' // If the regular expression contains '^TO' it will be substituted by '(^((Original-)?(Resent-)?(To|Cc|Bcc)|(X-Envelope |Apparently(-Resent)?)-To):(.*[^a-zA-Z])?)', which should catch all destination specifications containing a specific word. pub const TO: &'static str = "TO"; pub const CC: &'static str = "Cc"; pub const TOCC: &'static str = "(TO|Cc)"; pub const FROM: &'static str = "From"; pub const SENDER: &'static str = "Sender"; pub const SUBJECT: &'static str = "Subject"; pub const DELIVERED_TO: &'static str = "Delivered-To"; pub const X_FORWARDED_TO: &'static str = "X-Forwarded-To"; pub const REPLY_TO: &'static str = "Reply-To"; pub const X_ORIGINAL_TO: &'static str = "X-Original-To"; pub const LIST_ID: &'static str = "List-ID"; pub const X_SPAM: &'static str = "X-Spam"; pub const X_SPAM_FLAG: &'static str = "X-Spam-Flag"; } impl FromStr for Match { type Err = Infallible; fn from_str(s: &str) -> Result { // Examples: // "* 1^0 ^TOsonyrewards.com@xinu.tv" // "* ^TOsonyrewards.com@xinu.tv" let mut it = s.split_whitespace().skip(1); let mut needle = it.next().unwrap(); if needle == "1^0" { needle = it.next().unwrap(); } let mut needle = vec![needle]; needle.extend(it); let needle = needle.join(" "); let first = needle.chars().nth(0).unwrap_or(' '); use matches::*; if first == '^' { let needle = &needle[1..]; if needle.starts_with(TO) { return Ok(Match { match_type: MatchType::To, needle: cleanup_match(TO, needle), }); } else if needle.starts_with(FROM) { return Ok(Match { match_type: MatchType::From, needle: cleanup_match(FROM, needle), }); } else if needle.starts_with(CC) { return Ok(Match { match_type: MatchType::Cc, needle: cleanup_match(CC, needle), }); } else if needle.starts_with(TOCC) { return Ok(Match { match_type: MatchType::To, needle: cleanup_match(TOCC, needle), }); } else if needle.starts_with(SENDER) { return Ok(Match { match_type: MatchType::Sender, needle: cleanup_match(SENDER, needle), }); } else if needle.starts_with(SUBJECT) { return Ok(Match { match_type: MatchType::Subject, needle: cleanup_match(SUBJECT, needle), }); } else if needle.starts_with(X_ORIGINAL_TO) { return Ok(Match { match_type: MatchType::XOriginalTo, needle: cleanup_match(X_ORIGINAL_TO, needle), }); } else if needle.starts_with(LIST_ID) { return Ok(Match { match_type: MatchType::ListId, needle: cleanup_match(LIST_ID, needle), }); } else if needle.starts_with(REPLY_TO) { return Ok(Match { match_type: MatchType::ReplyTo, needle: cleanup_match(REPLY_TO, needle), }); } else if needle.starts_with(X_SPAM_FLAG) { return Ok(Match { match_type: MatchType::XSpam, needle: '*'.to_string(), }); } else if needle.starts_with(X_SPAM) { return Ok(Match { match_type: MatchType::XSpam, needle: '*'.to_string(), }); } else if needle.starts_with(DELIVERED_TO) { return Ok(Match { match_type: MatchType::DeliveredTo, needle: cleanup_match(DELIVERED_TO, needle), }); } else if needle.starts_with(X_FORWARDED_TO) { return Ok(Match { match_type: MatchType::XForwardedTo, needle: cleanup_match(X_FORWARDED_TO, needle), }); } else { unreachable!("needle: '{needle}'") } } else { return Ok(Match { match_type: MatchType::Body, needle: cleanup_match("", &needle), }); } } } fn unescape(s: &str) -> String { s.replace('\\', "") } pub fn cleanup_match(prefix: &str, s: &str) -> String { unescape(&s[prefix.len()..]).replace(".*", "") }