Something went wrong. Try again.
A personal email screening utility using Himalaya, Jev, and Ratatui
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365use std::{ collections::BTreeMap, env, ffi::OsStr, fmt, fs::{self, OpenOptions}, io::{self, Write}, path::{Component, Path, PathBuf},};
#[cfg(unix)]use std::os::unix::fs::OpenOptionsExt;
use anyhow::{Context, Result, bail};use serde::{Deserialize, Serialize};
pub const ACTIONS: [&str; 4] = ["reply", "review", "archive", "delete"];const DEFAULT_POLICY: &str = include_str!("../policies/default.json");
#[derive(Clone, Debug, Eq, PartialEq)]pub enum PolicySource { EmbeddedDefault, File(PathBuf),}
impl PolicySource { pub fn resolve(requested: Option<&Path>) -> Result<Self> { Self::resolve_in(requested, &configured_policy_directory()?) }
pub fn install_default() -> Result<(PathBuf, bool)> { install_default_in(&configured_policy_directory()?) }
fn resolve_in(requested: Option<&Path>, configured_directory: &Path) -> Result<Self> { let configured_default = configured_directory.join("default.json"); let Some(requested) = requested else { return if path_exists(&configured_default)? { Ok(Self::File(configured_default)) } else { Ok(Self::EmbeddedDefault) }; };
if is_policy_name(requested) { let configured = configured_directory.join(requested).with_extension("json"); if path_exists(&configured)? { return Ok(Self::File(configured)); } if requested == Path::new("default") { return Ok(Self::EmbeddedDefault); } bail!( "configured policy {:?} does not exist at {}", requested.display().to_string(), configured.display() ); }
Ok(Self::File(requested.to_owned())) }
pub fn load(&self) -> Result<ClassificationPolicy> { match self { Self::EmbeddedDefault => { ClassificationPolicy::parse(DEFAULT_POLICY, "embedded policies/default.json") } Self::File(path) => { let source = fs::read_to_string(path).with_context(|| { format!("failed to read classification policy {}", path.display()) })?; ClassificationPolicy::parse(&source, &path.display().to_string()) } } }
pub fn scope(&self) -> Result<String> { match self { Self::EmbeddedDefault => Ok("embedded:default".to_owned()), Self::File(path) => Ok(format!( "file:{}", path.canonicalize() .with_context(|| { format!("failed to resolve classification policy {}", path.display()) })? .display() )), } }}
impl fmt::Display for PolicySource { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::EmbeddedDefault => formatter.write_str("embedded policies/default.json"), Self::File(path) => write!(formatter, "{}", path.display()), } }}
fn configured_policy_directory() -> Result<PathBuf> { resolve_configured_policy_directory( env::var_os("XDG_CONFIG_HOME").as_deref(), dirs::config_dir(), )}
fn resolve_configured_policy_directory( xdg_config_home: Option<&OsStr>, fallback_directory: Option<PathBuf>,) -> Result<PathBuf> { let root = xdg_config_home .filter(|directory| !directory.is_empty()) .map(PathBuf::from) .filter(|directory| directory.is_absolute()) .or(fallback_directory) .context("could not determine the user configuration directory")?; Ok(root.join("screener").join("policies"))}
fn is_policy_name(value: &Path) -> bool { let mut components = value.components(); matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() && value.extension().is_none()}
fn path_exists(path: &Path) -> Result<bool> { path.try_exists() .with_context(|| format!("failed to inspect configured policy {}", path.display()))}
fn install_default_in(configured_directory: &Path) -> Result<(PathBuf, bool)> { fs::create_dir_all(configured_directory).with_context(|| { format!( "failed to create configured policy directory {}", configured_directory.display() ) })?; let path = configured_directory.join("default.json"); let mut options = OpenOptions::new(); options.create_new(true).write(true); #[cfg(unix)] options.mode(0o600); let mut file = match options.open(&path) { Ok(file) => file, Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { return Ok((path, false)); } Err(error) => { return Err(error) .with_context(|| format!("failed to create default policy {}", path.display())); } }; file.write_all(DEFAULT_POLICY.as_bytes()) .with_context(|| format!("failed to write default policy {}", path.display()))?; file.sync_all() .with_context(|| format!("failed to sync default policy {}", path.display()))?; Ok((path, true))}
#[derive(Debug, Deserialize, Serialize)]#[serde(deny_unknown_fields)]pub struct ClassificationPolicy { purpose: String, decision_order: Vec<String>, actions: BTreeMap<String, PolicyAction>, examples: Vec<PolicyExample>,}
#[derive(Debug, Deserialize, Serialize)]#[serde(deny_unknown_fields)]struct PolicyAction { description: String, indicators: Vec<String>, exclusions: Vec<String>,}
#[derive(Debug, Deserialize, Serialize)]#[serde(deny_unknown_fields)]struct PolicyExample { message_type: String, action: String, reason: String,}
impl ClassificationPolicy { fn parse(source: &str, source_name: &str) -> Result<Self> { let policy: Self = serde_json::from_str(source) .with_context(|| format!("failed to parse classification policy {source_name}"))?; policy.validate(source_name)?; Ok(policy) }
fn validate(&self, source_name: &str) -> Result<()> { if self.purpose.trim().is_empty() { bail!("classification policy {source_name} has an empty purpose"); } if self.decision_order.is_empty() { bail!("classification policy {source_name} has no decision_order rules"); }
for action in ACTIONS { let Some(rule) = self.actions.get(action) else { bail!("classification policy {source_name} is missing action {action}"); }; if rule.description.trim().is_empty() || rule.indicators.is_empty() { bail!( "classification policy {source_name} action {action} needs a description and indicators" ); } } if self.actions.len() != ACTIONS.len() { let unsupported = self .actions .keys() .find(|action| !ACTIONS.contains(&action.as_str())) .expect("action count differs only when an unsupported action exists"); bail!("classification policy {source_name} has unsupported action {unsupported}"); }
for example in &self.examples { if !ACTIONS.contains(&example.action.as_str()) { bail!( "classification policy {source_name} example has unsupported action {}", example.action ); } }
Ok(()) }}
#[cfg(test)]mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH};
fn temporary_policy_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir() .join(format!("screener-{label}-{}-{nonce}", std::process::id())) .join("policies") }
#[test] fn embedded_policy_is_valid() { let policy = PolicySource::EmbeddedDefault.load().unwrap(); assert!( policy .examples .iter() .any(|example| example.action == "review") ); }
#[test] fn configured_default_precedes_embedded_default() { let directory = temporary_policy_directory("default-policy"); assert_eq!( PolicySource::resolve_in(None, &directory).unwrap(), PolicySource::EmbeddedDefault ); assert_eq!( PolicySource::resolve_in(Some(Path::new("default")), &directory).unwrap(), PolicySource::EmbeddedDefault );
fs::create_dir_all(&directory).unwrap(); let configured_default = directory.join("default.json"); fs::write(&configured_default, DEFAULT_POLICY).unwrap(); let source = PolicySource::resolve_in(None, &directory).unwrap(); assert_eq!(source, PolicySource::File(configured_default.clone())); source.load().unwrap(); assert_eq!( source.scope().unwrap(), format!( "file:{}", configured_default.canonicalize().unwrap().display() ) );
fs::remove_dir_all(directory.parent().unwrap()).unwrap(); }
#[test] fn resolves_named_config_policy_and_preserves_explicit_paths() { let directory = temporary_policy_directory("named-policy"); fs::create_dir_all(&directory).unwrap(); let configured = directory.join("work.json"); fs::write(&configured, DEFAULT_POLICY).unwrap();
let named = PolicySource::resolve_in(Some(Path::new("work")), &directory).unwrap(); assert_eq!(named, PolicySource::File(configured)); named.load().unwrap();
let explicit = Path::new("./custom/policy.json"); assert_eq!( PolicySource::resolve_in(Some(explicit), &directory).unwrap(), PolicySource::File(explicit.to_owned()) ); let error = PolicySource::resolve_in(Some(Path::new("missing")), &directory).unwrap_err(); assert!(error.to_string().contains("missing.json"));
fs::remove_dir_all(directory.parent().unwrap()).unwrap(); }
#[test] fn installs_editable_default_without_overwriting_user_changes() { let directory = temporary_policy_directory("install-default"); let (path, created) = install_default_in(&directory).unwrap(); assert!(created); assert_eq!(path, directory.join("default.json")); PolicySource::File(path.clone()).load().unwrap();
fs::write(&path, "user-owned policy").unwrap(); let (same_path, created) = install_default_in(&directory).unwrap(); assert!(!created); assert_eq!(same_path, path); assert_eq!(fs::read_to_string(&path).unwrap(), "user-owned policy");
fs::remove_dir_all(directory.parent().unwrap()).unwrap(); }
#[test] fn xdg_config_home_precedes_platform_config_directory() { let xdg = std::env::temp_dir().join("screener-xdg-policy-config"); let fallback = std::env::temp_dir().join("screener-platform-config"); assert_eq!( resolve_configured_policy_directory(Some(xdg.as_os_str()), Some(fallback.clone())) .unwrap(), xdg.join("screener").join("policies") ); assert_eq!( resolve_configured_policy_directory( Some(OsStr::new("relative/config")), Some(fallback.clone()) ) .unwrap(), fallback.join("screener").join("policies") ); }
#[test] fn policy_requires_every_supported_action() { let source = r#"{ "purpose": "Classify mail", "decision_order": ["Choose one"], "actions": { "reply": {"description": "Reply", "indicators": ["Question"], "exclusions": []}, "review": {"description": "Review", "indicators": ["Human attention"], "exclusions": []}, "archive": {"description": "Archive", "indicators": ["Record"], "exclusions": []} }, "examples": [] }"#;
let error = ClassificationPolicy::parse(source, "test policy").unwrap_err(); assert!(error.to_string().contains("missing action delete")); }}