Something went wrong. Try again.
A personal email screening utility using Himalaya, Jev, and Ratatui
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466use std::{ collections::HashSet, env, ffi::OsStr, fs::{self, OpenOptions}, io::{self, Write}, path::{Path, PathBuf},};
#[cfg(unix)]use std::os::unix::fs::OpenOptionsExt;
use anyhow::{Context, Result, bail};use chrono::Utc;use serde::{Deserialize, Serialize};
use crate::action::ClassificationAction;
const STORE_VERSION: u32 = 1;pub const MAX_LESSON_EXCERPT_BYTES: usize = 1_500;pub const RELEVANT_LESSON_LIMIT: usize = 5;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]pub struct Lesson { pub policy_scope: String, pub message_id: String, pub subject: String, pub sender: String, pub content_excerpt: String, pub predicted_action: ClassificationAction, pub corrected_action: ClassificationAction, pub note: Option<String>, pub recorded_at: String,}
#[derive(Clone, Debug, Eq, PartialEq)]pub struct LessonInput { pub message_id: String, pub subject: String, pub sender: String, pub content_excerpt: String, pub predicted_action: ClassificationAction, pub corrected_action: ClassificationAction, pub note: Option<String>,}
#[derive(Debug, Serialize)]pub struct PromptLesson<'a> { subject: &'a str, sender: &'a str, content_excerpt: &'a str, model_suggested: ClassificationAction, user_corrected_to: ClassificationAction, #[serde(skip_serializing_if = "Option::is_none")] user_note: Option<&'a str>,}
#[derive(Debug, Deserialize, Serialize)]#[serde(deny_unknown_fields)]struct LessonFile { version: u32, lessons: Vec<Lesson>,}
#[derive(Debug)]pub struct LessonStore { path: PathBuf, lessons: Vec<Lesson>,}
impl LessonStore { pub fn load(path: PathBuf) -> Result<Self> { let lessons = match fs::read_to_string(&path) { Ok(source) => { let file: LessonFile = serde_json::from_str(&source) .with_context(|| format!("failed to parse lesson store {}", path.display()))?; if file.version != STORE_VERSION { bail!( "lesson store {} uses unsupported version {}", path.display(), file.version ); } file.lessons } Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), Err(error) => { return Err(error) .with_context(|| format!("failed to read lesson store {}", path.display())); } };
Ok(Self { path, lessons }) }
pub fn default_path() -> Result<PathBuf> { resolve_default_path( env::var_os("XDG_CONFIG_HOME").as_deref(), dirs::data_local_dir(), ) }
pub fn path(&self) -> &Path { &self.path }
pub fn len(&self) -> usize { self.lessons.len() }
pub fn record(&mut self, policy_scope: &str, inputs: Vec<LessonInput>) -> Result<usize> { if inputs.is_empty() { return Ok(0); } let input_count = inputs.len();
let recorded_at = Utc::now().to_rfc3339(); let mut lessons = self.lessons.clone(); for input in inputs { let lesson = Lesson { policy_scope: policy_scope.to_owned(), message_id: input.message_id, subject: input.subject, sender: input.sender, content_excerpt: truncate_utf8(input.content_excerpt, MAX_LESSON_EXCERPT_BYTES), predicted_action: input.predicted_action, corrected_action: input.corrected_action, note: input .note .map(|note| note.trim().to_owned()) .filter(|note| !note.is_empty()), recorded_at: recorded_at.clone(), }; if let Some(existing) = lessons.iter_mut().find(|existing| { existing.policy_scope == lesson.policy_scope && existing.message_id == lesson.message_id }) { *existing = lesson; } else { lessons.push(lesson); } }
self.write(&lessons)?; self.lessons = lessons; Ok(input_count) }
pub fn relevant_to_with_limit<'a>( &'a self, policy_scope: &str, subject: &str, sender: &str, content: &str, limit: usize, ) -> Vec<PromptLesson<'a>> { let message_terms = terms(&format!("{subject} {content}")); let mut scored: Vec<_> = self .lessons .iter() .filter(|lesson| lesson.policy_scope == policy_scope) .filter_map(|lesson| { let lesson_terms = terms(&format!( "{} {} {}", lesson.subject, lesson.content_excerpt, lesson.note.as_deref().unwrap_or_default() )); let overlap = message_terms.intersection(&lesson_terms).count(); let exact_subject = !subject.trim().is_empty() && subject.trim().eq_ignore_ascii_case(lesson.subject.trim()); if overlap == 0 && !exact_subject { return None; } let sender_match = sender.trim().eq_ignore_ascii_case(lesson.sender.trim()); let score = overlap * 10 + usize::from(exact_subject) * 100 + usize::from(sender_match); Some((score, &lesson.recorded_at, lesson)) }) .collect(); scored .sort_unstable_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(left.1))); scored.truncate(limit); scored .into_iter() .map(|(_, _, lesson)| PromptLesson { subject: &lesson.subject, sender: &lesson.sender, content_excerpt: &lesson.content_excerpt, model_suggested: lesson.predicted_action, user_corrected_to: lesson.corrected_action, user_note: lesson.note.as_deref(), }) .collect() }
fn write(&self, lessons: &[Lesson]) -> Result<()> { let parent = self .path .parent() .filter(|parent| !parent.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")); fs::create_dir_all(parent).with_context(|| { format!( "failed to create lesson store directory {}", parent.display() ) })?; let temporary = self.path.with_extension("json.tmp"); let serialized = serde_json::to_vec_pretty(&LessonFile { version: STORE_VERSION, lessons: lessons.to_vec(), }) .context("failed to serialize lessons")?;
let mut options = OpenOptions::new(); options.create(true).truncate(true).write(true); #[cfg(unix)] options.mode(0o600); let mut file = options .open(&temporary) .with_context(|| format!("failed to create {}", temporary.display()))?; file.write_all(&serialized) .with_context(|| format!("failed to write {}", temporary.display()))?; file.sync_all() .with_context(|| format!("failed to sync {}", temporary.display()))?; fs::rename(&temporary, &self.path).with_context(|| { format!( "failed to replace lesson store {} with {}", self.path.display(), temporary.display() ) })?; Ok(()) }}
fn resolve_default_path( xdg_config_home: Option<&OsStr>, fallback_directory: Option<PathBuf>,) -> Result<PathBuf> { if let Some(directory) = xdg_config_home .filter(|directory| !directory.is_empty()) .map(PathBuf::from) .filter(|directory| directory.is_absolute()) { return Ok(directory.join("screener").join("lessons.json")); }
fallback_directory .map(|directory| directory.join("screener").join("lessons.json")) .context("could not determine the local application data directory")}
fn truncate_utf8(mut value: String, max_bytes: usize) -> String { if value.len() <= max_bytes { return value; } let mut end = max_bytes; while !value.is_char_boundary(end) { end -= 1; } value.truncate(end); value}
fn terms(value: &str) -> HashSet<String> { value .split(|character: char| !character.is_alphanumeric()) .map(str::to_lowercase) .filter(|term| term.len() >= 3 && !is_stopword(term)) .collect()}
fn is_stopword(term: &str) -> bool { matches!( term, "and" | "are" | "but" | "for" | "from" | "has" | "have" | "not" | "our" | "that" | "the" | "this" | "was" | "were" | "will" | "with" | "you" | "your" )}
#[cfg(test)]mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH};
fn temporary_path(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); std::env::temp_dir().join(format!( "screener-{label}-{}-{nonce}.json", std::process::id() )) }
fn input(id: &str, corrected_action: ClassificationAction) -> LessonInput { LessonInput { message_id: id.to_owned(), subject: "Last chance dog food sale".to_owned(), sender: "Pet Store <offers@example.com>".to_owned(), content_excerpt: "Save on dog food before the promotion ends.".to_owned(), predicted_action: ClassificationAction::Archive, corrected_action, note: Some(" promotional urgency is disposable ".to_owned()), } }
fn lesson(policy_scope: &str, id: &str, subject: &str, sender: &str, content: &str) -> Lesson { Lesson { policy_scope: policy_scope.to_owned(), message_id: id.to_owned(), subject: subject.to_owned(), sender: sender.to_owned(), content_excerpt: content.to_owned(), predicted_action: ClassificationAction::Archive, corrected_action: ClassificationAction::Delete, note: None, recorded_at: "2026-01-01T00:00:00Z".to_owned(), } }
#[test] fn xdg_config_home_precedes_existing_data_directory_fallback() { let xdg_directory = std::env::temp_dir().join("screener-xdg-config"); let fallback_directory = std::env::temp_dir().join("screener-data-local");
assert_eq!( resolve_default_path( Some(xdg_directory.as_os_str()), Some(fallback_directory.clone()) ) .unwrap(), xdg_directory.join("screener").join("lessons.json") ); assert_eq!( resolve_default_path(None, Some(fallback_directory.clone())).unwrap(), fallback_directory.join("screener").join("lessons.json") ); assert_eq!( resolve_default_path( Some(OsStr::new("relative/config")), Some(fallback_directory.clone()) ) .unwrap(), fallback_directory.join("screener").join("lessons.json") ); }
#[test] fn persists_compact_lessons_and_replaces_same_policy_message() { let path = temporary_path("lesson-store"); let mut store = LessonStore::load(path.clone()).unwrap(); let mut first = input("message-1", ClassificationAction::Review); first.content_excerpt = "é".repeat(MAX_LESSON_EXCERPT_BYTES); assert_eq!(store.record("default", vec![first]).unwrap(), 1);
let reloaded = LessonStore::load(path.clone()).unwrap(); assert_eq!(reloaded.lessons.len(), 1); assert_eq!( reloaded.lessons[0].content_excerpt.len(), MAX_LESSON_EXCERPT_BYTES ); assert_eq!(reloaded.lessons[0].content_excerpt.chars().count(), 750); assert_eq!( reloaded.lessons[0].note.as_deref(), Some("promotional urgency is disposable") );
let mut reloaded = reloaded; reloaded .record( "default", vec![input("message-1", ClassificationAction::Delete)], ) .unwrap(); reloaded .record( "newsletter-policy", vec![input("message-1", ClassificationAction::Review)], ) .unwrap(); assert_eq!(reloaded.lessons.len(), 2); assert_eq!( reloaded .lessons .iter() .find(|lesson| lesson.policy_scope == "default") .unwrap() .corrected_action, ClassificationAction::Delete );
fs::remove_file(path).unwrap(); }
#[test] fn retrieves_semantically_relevant_lessons_within_policy_scope() { let sender = "Pet Store <offers@example.com>"; let store = LessonStore { path: temporary_path("unused"), lessons: vec![ lesson( "default", "dog", "Last chance dog food sale", sender, "Save on dog food before this promotion ends.", ), lesson( "default", "account", "Quarterly account status", sender, "Your membership account is ready.", ), lesson( "other-policy", "other", "Last chance dog food sale", sender, "Save on dog food before this promotion ends.", ), ], };
let relevant = store.relevant_to_with_limit( "default", "Final hours for dog food savings", "Different Sender <different@example.com>", "This pet food promotion ends tonight.", RELEVANT_LESSON_LIMIT, ); assert_eq!(relevant.len(), 1); assert_eq!(relevant[0].subject, "Last chance dog food sale"); assert_eq!(relevant[0].user_corrected_to, ClassificationAction::Delete);
let sender_only = store.relevant_to_with_limit( "default", "Unrelated notice", sender, "There is nothing semantically similar here.", RELEVANT_LESSON_LIMIT, ); assert!(sender_only.is_empty()); }}