use std::{convert::Infallible, io::Write, str::FromStr}; #[derive(Debug, Default)] enum MatchType { From, Sender, To, Cc, Subject, List, DeliveredTo, XForwardedTo, ReplyTo, XOriginalTo, XSpam, Body, #[default] Unknown, } #[derive(Debug, Default)] struct Match { match_type: MatchType, needle: String, } #[derive(Debug, Default)] struct Rule { matches: Vec, tags: Vec, } fn unescape(s: &str) -> String { s.replace('\\', "") } fn cleanup_match(prefix: &str, s: &str) -> String { unescape(&s[prefix.len()..]).replace(".*", "") } mod matches { 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::List, 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 notmuch_from_rules(mut w: W, rules: &[Rule]) -> anyhow::Result<()> { // TODO(wathiede): if reindexing this many tags is too slow, see if combining rules per tag is // faster. let mut lines = Vec::new(); for r in rules { for m in &r.matches { for t in &r.tags { if let MatchType::Unknown = m.match_type { eprintln!("rule has unknown match {:?}", r); continue; } let rule = match m.match_type { MatchType::From => "from:", // TODO(wathiede): something more specific? MatchType::Sender => "from:", MatchType::To => "to:", MatchType::Cc => "to:", MatchType::Subject => "subject:", MatchType::List => "List-ID:", MatchType::Body => "", // TODO(wathiede): these will probably require adding fields to notmuch // index. Handle them later. MatchType::DeliveredTo | MatchType::XForwardedTo | MatchType::ReplyTo | MatchType::XOriginalTo | MatchType::XSpam => continue, MatchType::Unknown => unreachable!(), }; // Preserve unread status if run with --remove-all lines.push(format!( r#"-unprocessed +{} +unread -- is:unread tag:unprocessed {}"{}""#, t, rule, m.needle )); lines.push(format!( // TODO(wathiede): this assumes `notmuch new` is configured to add // `tag:unprocessed` to all new mail. r#"-unprocessed +{} -- tag:unprocessed {}"{}""#, t, rule, m.needle )); } } } lines.sort(); for l in lines { writeln!(w, "{l}")?; } Ok(()) } fn main() -> anyhow::Result<()> { let input = "/home/wathiede/dotfiles/procmailrc"; let mut rules = Vec::new(); let mut cur_rule = Rule::default(); for l in std::fs::read_to_string(input)?.lines() { let l = if let Some(idx) = l.find('#') { &l[..idx] } else { l } .trim(); if l.is_empty() { continue; } if l.find('=').is_some() { // Probably a variable assignment, skip line continue; } let first = l.chars().nth(0).unwrap_or(' '); match first { ':' => { // start of rule } '*' => { // add to current rule let m: Match = l.parse()?; cur_rule.matches.push(m); } '.' => { // delivery to folder cur_rule.tags.push(cleanup_match( "", &l.replace('.', "/") .replace(' ', "") .trim_matches('/') .to_string(), )); rules.push(cur_rule); cur_rule = Rule::default(); } '|' => cur_rule = Rule::default(), // external command '$' => { // TODO(wathiede): tag messages with no other tag as 'inbox' cur_rule.tags.push(cleanup_match("", "inbox")); rules.push(cur_rule); cur_rule = Rule::default(); } // variable, should only be $DEFAULT in my config _ => panic!("Unhandled first character '{}' {}", first, l), } } notmuch_from_rules(std::io::stdout(), &rules)?; Ok(()) }