use std::{collections::HashMap, io::Write}; use clap::{Parser, Subcommand}; use letterbox_shared::{cleanup_match, Match, MatchType, Rule}; use sqlx::{types::Json, PgPool}; #[derive(Debug, Subcommand)] enum Mode { Debug, Notmuchrc, LoadSql { #[arg(short, long)] dsn: String, }, } /// Simple program to greet a person #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { #[arg(short, long, default_value = "/home/wathiede/dotfiles/procmailrc")] input: String, #[command(subcommand)] mode: Mode, } #[tokio::main] async fn main() -> anyhow::Result<()> { let args = Args::parse(); let mut rules = Vec::new(); let mut cur_rule = Rule::default(); for l in std::fs::read_to_string(args.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 // If carbon-copy flag present, don't stop on match cur_rule.stop_on_match = !l.contains('c'); } '*' => { // add to current rule let m: Match = l.parse()?; cur_rule.matches.push(m); } '.' => { // delivery to folder cur_rule.tag = cleanup_match( "", &l.replace('.', "/") .replace(' ', "") .trim_matches('/') .to_string(), ); rules.push(cur_rule); cur_rule = Rule::default(); } '/' => cur_rule = Rule::default(), // Ex. /dev/null '|' => cur_rule = Rule::default(), // external command '$' => { // TODO(wathiede): tag messages with no other tag as 'inbox' cur_rule.tag = cleanup_match("", "inbox"); rules.push(cur_rule); cur_rule = Rule::default(); } // variable, should only be $DEFAULT in my config _ => panic!("Unhandled first character '{}'\nLine: {}", first, l), } } match args.mode { Mode::Debug => print_rules(&rules), Mode::Notmuchrc => notmuch_from_rules(std::io::stdout(), &rules)?, Mode::LoadSql { dsn } => load_sql(&dsn, &rules).await?, } Ok(()) } fn print_rules(rules: &[Rule]) { let mut tally = HashMap::new(); for r in rules { for m in &r.matches { *tally.entry(m.match_type).or_insert(0) += 1; } } let mut sorted: Vec<_> = tally.iter().map(|(k, v)| (v, k)).collect(); sorted.sort(); sorted.reverse(); for (v, k) in sorted { println!("{k:?}: {v}"); } } 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 { let t = &r.tag; 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::ListId => "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(()) } async fn load_sql(dsn: &str, rules: &[Rule]) -> anyhow::Result<()> { let pool = PgPool::connect(dsn).await?; println!("clearing email_rule table"); sqlx::query!("DELETE FROM email_rule") .execute(&pool) .await?; for (order, rule) in rules.iter().enumerate() { println!("inserting {order}: {rule:?}"); sqlx::query!( r#" INSERT INTO email_rule (sort_order, rule) VALUES ($1, $2) "#, order as i32, Json(rule) as _ ) .execute(&pool) .await?; } Ok(()) }