diff --git a/Cargo.lock b/Cargo.lock index c64a8b4..7d02269 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3014,6 +3014,9 @@ version = "0.15.9" dependencies = [ "anyhow", "clap", + "serde", + "sqlx", + "tokio 1.44.2", ] [[package]] diff --git a/procmail2notmuch/Cargo.toml b/procmail2notmuch/Cargo.toml index 2221fed..43d31c8 100644 --- a/procmail2notmuch/Cargo.toml +++ b/procmail2notmuch/Cargo.toml @@ -12,4 +12,7 @@ version.workspace = true [dependencies] anyhow = "1.0.69" -clap = { version = "4.5.37", features = ["derive"] } +clap = { version = "4.5.37", features = ["derive", "env"] } +serde = { version = "1.0.219", features = ["derive"] } +sqlx = { version = "0.8.5", features = ["postgres", "runtime-tokio"] } +tokio = { version = "1.44.2", features = ["rt", "macros", "rt-multi-thread"] } diff --git a/procmail2notmuch/src/main.rs b/procmail2notmuch/src/main.rs index 86af16e..ab452b8 100644 --- a/procmail2notmuch/src/main.rs +++ b/procmail2notmuch/src/main.rs @@ -1,8 +1,12 @@ use std::{collections::HashMap, convert::Infallible, io::Write, str::FromStr}; -use clap::{Parser, ValueEnum}; +use clap::{Parser, Subcommand}; +use serde::{Deserialize, Serialize}; +use sqlx::{types::Json, PgPool}; -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd)] +#[derive( + Copy, Clone, Debug, Default, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, +)] enum MatchType { From, Sender, @@ -19,16 +23,16 @@ enum MatchType { #[default] Unknown, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize, Deserialize)] struct Match { match_type: MatchType, needle: String, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Serialize, Deserialize)] struct Rule { matches: Vec, - tags: Option, + tag: Option, } fn unescape(s: &str) -> String { @@ -155,13 +159,109 @@ impl FromStr for Match { } } +#[derive(Debug, Subcommand)] +enum Mode { + Debug, + Notmuchrc, + LoadSql { + #[arg(short, long, default_value = env!("DATABASE_URL"))] + 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 + } + '*' => { + // add to current rule + let m: Match = l.parse()?; + cur_rule.matches.push(m); + } + '.' => { + // delivery to folder + cur_rule.tag = Some(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 = Some(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 { - if let Some(t) = &r.tags { + if let Some(t) = &r.tag { if let MatchType::Unknown = m.match_type { eprintln!("rule has unknown match {:?}", r); continue; @@ -206,92 +306,26 @@ fn notmuch_from_rules(mut w: W, rules: &[Rule]) -> anyhow::Result<()> Ok(()) } -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] -enum Mode { - Debug, - Notmuchrc, -} +async fn load_sql(dsn: &str, rules: &[Rule]) -> anyhow::Result<()> { + let pool = PgPool::connect(dsn).await?; + sqlx::migrate!("../server/migrations").run(&pool).await?; + println!("clearing email_rule table"); + sqlx::query!("DELETE FROM email_rule") + .execute(&pool) + .await?; -/// 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, - - #[arg(value_enum)] - mode: Mode, -} - -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 - } - '*' => { - // add to current rule - let m: Match = l.parse()?; - cur_rule.matches.push(m); - } - '.' => { - // delivery to folder - cur_rule.tags = Some(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.tags = Some(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)?, + 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(()) } - -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}"); - } -} diff --git a/server/migrations/20250419202131_email-rules.down.sql b/server/migrations/20250419202131_email-rules.down.sql new file mode 100644 index 0000000..8986085 --- /dev/null +++ b/server/migrations/20250419202131_email-rules.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF NOT EXISTS email_rule; + +-- Add down migration script here diff --git a/server/migrations/20250419202131_email-rules.up.sql b/server/migrations/20250419202131_email-rules.up.sql new file mode 100644 index 0000000..1003500 --- /dev/null +++ b/server/migrations/20250419202131_email-rules.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS email_rule ( + id integer NOT NULL GENERATED ALWAYS AS IDENTITY, + sort_order integer NOT NULL, + rule jsonb NOT NULL +);