diff --git a/Cargo.lock b/Cargo.lock index 049ca69..a8c13e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anyhow" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224afbd727c3d6e4b90103ece64b8d1b67fbb1973b1046c2281eed3f3803f800" + [[package]] name = "async-stream" version = "0.3.3" @@ -1777,6 +1783,13 @@ dependencies = [ "yansi", ] +[[package]] +name = "procmail2notmuch" +version = "0.1.0" +dependencies = [ + "anyhow", +] + [[package]] name = "quote" version = "0.6.13" diff --git a/Cargo.toml b/Cargo.toml index 3e80e42..a49be0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "web", "server", "notmuch", + "procmail2notmuch", ] [profile.release] diff --git a/procmail2notmuch/Cargo.toml b/procmail2notmuch/Cargo.toml new file mode 100644 index 0000000..af5b033 --- /dev/null +++ b/procmail2notmuch/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "procmail2notmuch" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.69" diff --git a/procmail2notmuch/src/main.rs b/procmail2notmuch/src/main.rs new file mode 100644 index 0000000..f3108bd --- /dev/null +++ b/procmail2notmuch/src/main.rs @@ -0,0 +1,240 @@ +use std::{convert::Infallible, io::Write, str::FromStr}; + +#[derive(Debug, Default)] +enum MatchType { + From, + Sender, + To, + 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(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::From, + 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), + }); + } + Ok(Match::default()) + } +} + +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; + } + lines.push(format!( + "+{} -- {}{}", + t, + match m.match_type { + MatchType::From => "from:", + // TODO(wathiede): something more specific? + MatchType::Sender => "from:", + MatchType::To => "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!(), + }, + m.needle + )); + } + } + } + lines.sort(); + for l in lines { + writeln!(w, "{l}")?; + } + Ok(()) +} + +fn main() -> anyhow::Result<()> { + let input = "/home/wathiede/src/xinu.tv/letterbox/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 = Rule::default(); + } // variable, should only be $DEFAULT in my config + _ => panic!("Unhandled first character '{}' {}", first, l), + } + } + notmuch_from_rules(std::io::stdout(), &rules)?; + Ok(()) +}