use std::sync::Arc; use axum::extract::{Path, Query}; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; use crate::auth::session::SessionState; use crate::config::AppConfig; use crate::db; use crate::error::AppError; use crate::search::engine::{SearchEngine, SearchQuery}; struct ParsedQuery { text: String, subject_only: Option, folder: Option, from: Option, to: Option, date_from: Option, date_to: Option, has_attachment: Option, unread: Option, flagged: Option, } fn parse_search_query(input: &str) -> ParsedQuery { let mut text_parts: Vec = Vec::new(); let mut subject_only: Option = None; let mut folder: Option = None; let mut from: Option = None; let mut to: Option = None; let mut date_from: Option = None; let mut date_to: Option = None; let mut has_attachment: Option = None; let mut unread: Option = None; let mut flagged: Option = None; let tokens = tokenize_query(input); for token in tokens { if let Some((operator, value)) = split_operator(&token) { let op = operator.to_lowercase(); match op.as_str() { "from" => from = Some(value), "to" => to = Some(value), "subject" => subject_only = Some(value), "in" | "folder" => folder = Some(value), "date" => { if let Some((start, end)) = parse_date_range(&value) { date_from = Some(start); date_to = Some(end); } } "after" => { if let Some(epoch) = parse_date_start(&value) { date_from = Some(epoch); } } "before" => { if let Some(epoch) = parse_date_start(&value) { date_to = Some(epoch); } } "has" if value.eq_ignore_ascii_case("attachment") => { has_attachment = Some(true); } "is" if value.eq_ignore_ascii_case("unread") => unread = Some(true), "is" if value.eq_ignore_ascii_case("read") => unread = Some(false), "is" if value.eq_ignore_ascii_case("flagged") || value.eq_ignore_ascii_case("starred") => { flagged = Some(true) } "is" if value.eq_ignore_ascii_case("unflagged") || value.eq_ignore_ascii_case("unstarred") => { flagged = Some(false) } _ => { text_parts.push(token); } } } else { text_parts.push(token); } } ParsedQuery { text: text_parts.join(" "), subject_only, folder, from, to, date_from, date_to, has_attachment, unread, flagged, } } fn tokenize_query(input: &str) -> Vec { let mut tokens = Vec::new(); let mut chars = input.chars().peekable(); let mut current = String::new(); while let Some(&ch) = chars.peek() { if ch.is_whitespace() { if !current.is_empty() { tokens.push(std::mem::take(&mut current)); } chars.next(); } else if ch == '"' { chars.next(); while let Some(&qch) = chars.peek() { if qch == '"' { chars.next(); break; } current.push(qch); chars.next(); } } else { current.push(ch); chars.next(); } } if !current.is_empty() { tokens.push(current); } tokens } fn split_operator(token: &str) -> Option<(String, String)> { if let Some(colon_pos) = token.find(':') { let op = &token[..colon_pos]; let val = &token[colon_pos + 1..]; if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() { return Some((op.to_string(), val.to_string())); } } None } fn parse_date_range(date_str: &str) -> Option<(i64, i64)> { let start = parse_date_start(date_str)?; let end = start + 86_400 - 1; Some((start, end)) } fn parse_date_start(date_str: &str) -> Option { let parts: Vec<&str> = date_str.split('-').collect(); if parts.len() != 3 { return None; } let year: i64 = parts[0].parse().ok()?; let month: i64 = parts[1].parse().ok()?; let day: i64 = parts[2].parse().ok()?; if !(1..=12).contains(&month) || !(1..=31).contains(&day) { return None; } let mut total_days: i64 = 0; for y in 1970..year { total_days += if is_leap_year(y) { 366 } else { 365 }; } let days_in_months = [ 31, 28 + i64::from(is_leap_year(year)), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, ]; for days in days_in_months.iter().take((month - 1) as usize) { total_days += days; } total_days += day - 1; Some(total_days * 86_400) } fn is_leap_year(y: i64) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 } #[derive(Deserialize)] pub struct SearchParams { pub q: Option, pub folder: Option, pub subject: Option, pub from: Option, pub to: Option, pub date_from: Option, pub date_to: Option, pub has_attachment: Option, pub unread: Option, pub flagged: Option, #[serde(default = "default_limit")] pub limit: usize, #[serde(default)] pub offset: usize, #[serde(default = "default_sort")] pub sort: String, #[serde(default)] pub all: bool, } fn default_sort() -> String { "date_desc".to_string() } fn default_limit() -> usize { 10_000 } #[derive(Serialize)] pub struct SearchResponse { pub results: Vec, pub total_count: usize, pub query: String, } #[derive(Serialize)] pub struct SearchResultItem { pub uid: u32, pub folder: String, pub score: f32, pub subject: String, pub from_address: String, pub from_name: String, pub to_addresses: String, pub date: String, pub flags: String, pub has_attachments: bool, pub snippet: String, } pub async fn search_messages( Extension(session): Extension, Extension(config): Extension>, Extension(search_engine): Extension>, Query(params): Query, ) -> Result { let query_text = params.q.as_deref().unwrap_or("").trim().to_string(); let has_direct_filters = params.folder.is_some() || params.subject.is_some() || params.from.is_some() || params.to.is_some() || params.date_from.is_some() || params.date_to.is_some() || params.has_attachment.is_some() || params.unread.is_some() || params.flagged.is_some(); if query_text.is_empty() && !has_direct_filters { return Err(AppError::BadRequest( "A search query or at least one filter is required".to_string(), )); } let sort_order = params.sort.clone(); let select_all = params.all; let requested_limit = params.limit.clamp(1, 10_000); let search_offset = params.offset; let parsed = parse_search_query(&query_text); let folder = params.folder.or(parsed.folder); let subject = params.subject.or(parsed.subject_only.clone()); let from = params.from.or(parsed.from); let to = params.to.or(parsed.to); let date_from = params.date_from.or(parsed.date_from); let date_to = params.date_to.or(parsed.date_to); let has_attachment = params.has_attachment.or(parsed.has_attachment); let unread = params.unread.or(parsed.unread); let flagged = params.flagged.or(parsed.flagged); let data_dir = config.data_dir.clone(); let user_hash = session.user_hash.clone(); let text_clone = parsed.text.clone(); let parsed_subject_clone = subject.clone(); let folder_clone = folder.clone(); let from_clone = from.clone(); let to_clone = to.clone(); let (sqlite_results, limit) = tokio::task::spawn_blocking(move || { let conn = db::pool::open_user_db(&data_dir, &user_hash) .map_err(|e| format!("Database error: {e}"))?; let effective_limit = if select_all { db::messages::count_all_messages(&conn)?.max(1) } else { requested_limit }; let results = db::messages::search_messages_sqlite( &conn, &text_clone, parsed_subject_clone.as_deref(), folder_clone.as_deref(), from_clone.as_deref(), to_clone.as_deref(), date_from, date_to, has_attachment, unread, flagged, effective_limit, )?; Ok::<_, String>((results, effective_limit)) }) .await .map_err(|e| AppError::InternalError(format!("Task error: {e}")))? .map_err(|e| AppError::InternalError(format!("Search error: {e}")))?; let mut seen = std::collections::HashSet::new(); let mut results: Vec = sqlite_results .into_iter() .map(|msg| { seen.insert((msg.folder.clone(), msg.uid)); SearchResultItem { uid: msg.uid, folder: msg.folder, score: 1.0, subject: msg.subject, from_address: msg.from_address, from_name: msg.from_name, to_addresses: msg.to_addresses, date: msg.date, flags: msg.flags, has_attachments: msg.has_attachments, snippet: msg.snippet, } }) .collect(); if !parsed.text.is_empty() && results.len() < limit && let Ok(user_index) = search_engine.open_user_index(&session.user_hash) && let Ok((tantivy_results, _)) = user_index .search(&SearchQuery { text: parsed.text, subject_only: subject, folder, from, to, date_from, date_to, has_attachment, limit, offset: if select_all { 0 } else { search_offset }, }) .await { let data_dir2 = config.data_dir.clone(); let user_hash2 = session.user_hash.clone(); let remaining = limit.saturating_sub(results.len()); let tantivy_enriched = tokio::task::spawn_blocking(move || { let conn = db::pool::open_user_db(&data_dir2, &user_hash2) .map_err(|e| format!("Database error: {e}"))?; let mut items = Vec::new(); for sr in &tantivy_results { if items.len() >= remaining { break; } if let Ok(Some(msg)) = db::messages::get_single_message(&conn, &sr.folder, sr.uid) { let is_unread = !msg.flags.contains("\\Seen"); let is_flagged = msg.flags.contains("\\Flagged"); if unread.is_some_and(|wanted| wanted != is_unread) { continue; } if flagged.is_some_and(|wanted| wanted != is_flagged) { continue; } items.push((sr.score, sr.snippet.clone(), msg)); } } Ok::<_, String>(items) }) .await .map_err(|e| AppError::InternalError(format!("Task error: {e}")))? .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; for (score, snippet, msg) in tantivy_enriched { if seen.contains(&(msg.folder.clone(), msg.uid)) { continue; } seen.insert((msg.folder.clone(), msg.uid)); results.push(SearchResultItem { uid: msg.uid, folder: msg.folder, score, subject: msg.subject, from_address: msg.from_address, from_name: msg.from_name, to_addresses: msg.to_addresses, date: msg.date, flags: msg.flags, has_attachments: msg.has_attachments, snippet: if snippet.is_empty() { msg.snippet } else { snippet }, }); } } let sort_asc = sort_order == "date_asc"; results.sort_by(|a, b| { let da = crate::db::messages::parse_date_to_epoch_public(&a.date); let db_val = crate::db::messages::parse_date_to_epoch_public(&b.date); if sort_asc { da.cmp(&db_val) } else { db_val.cmp(&da) } }); results.truncate(limit); Ok(Json(SearchResponse { total_count: results.len(), results, query: query_text, }) .into_response()) } #[derive(Debug, Deserialize)] pub struct SavedSearchRequest { pub name: String, pub query_string: String, } pub async fn list_saved_searches( Extension(session): Extension, Extension(config): Extension>, ) -> Result { let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let searches = db::saved_searches::list(&conn).map_err(AppError::InternalError)?; Ok(Json(serde_json::json!({ "searches": searches })).into_response()) } pub async fn create_saved_search( Extension(session): Extension, Extension(config): Extension>, Json(data): Json, ) -> Result { if data.name.trim().is_empty() || data.query_string.trim().is_empty() { return Err(AppError::BadRequest( "Saved search name and query are required".to_string(), )); } let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let saved = db::saved_searches::create(&conn, &data.name, &data.query_string) .map_err(AppError::BadRequest)?; Ok((axum::http::StatusCode::CREATED, Json(saved)).into_response()) } pub async fn update_saved_search( Extension(session): Extension, Extension(config): Extension>, Path(id): Path, Json(data): Json, ) -> Result { let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; match db::saved_searches::update(&conn, id, &data.name, &data.query_string) .map_err(AppError::BadRequest)? { Some(saved) => Ok(Json(saved).into_response()), None => Err(AppError::NotFound("Saved search not found".to_string())), } } pub async fn delete_saved_search( Extension(session): Extension, Extension(config): Extension>, Path(id): Path, ) -> Result { let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; if db::saved_searches::delete(&conn, id).map_err(AppError::InternalError)? { Ok(Json(serde_json::json!({ "status": "deleted" })).into_response()) } else { Err(AppError::NotFound("Saved search not found".to_string())) } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_plain_text() { let p = parse_search_query("hello world"); assert_eq!(p.text, "hello world"); assert!(p.from.is_none()); assert!(p.to.is_none()); assert!(p.subject_only.is_none()); assert!(p.folder.is_none()); } #[test] fn parse_from_operator() { let p = parse_search_query("from:alice@example.com hello"); assert_eq!(p.text, "hello"); assert_eq!(p.from.as_deref(), Some("alice@example.com")); } #[test] fn parse_to_operator() { let p = parse_search_query("to:bob@example.com updates"); assert_eq!(p.text, "updates"); assert_eq!(p.to.as_deref(), Some("bob@example.com")); } #[test] fn parse_subject_operator() { let p = parse_search_query("subject:meeting notes"); assert_eq!(p.subject_only.as_deref(), Some("meeting")); assert_eq!(p.text, "notes"); } #[test] fn parse_subject_quoted() { let p = parse_search_query("subject:\"meeting notes\" extra"); assert_eq!(p.subject_only.as_deref(), Some("meeting notes")); assert_eq!(p.text, "extra"); } #[test] fn parse_folder_operators() { let p = parse_search_query("in:Sent hello"); assert_eq!(p.folder.as_deref(), Some("Sent")); assert_eq!(p.text, "hello"); let p2 = parse_search_query("folder:INBOX test"); assert_eq!(p2.folder.as_deref(), Some("INBOX")); } #[test] fn parse_date_operator() { let p = parse_search_query("date:2024-01-15 news"); assert_eq!(p.text, "news"); assert!(p.date_from.is_some()); assert!(p.date_to.is_some()); assert_eq!(p.date_from, Some(1_705_276_800)); assert_eq!(p.date_to, Some(1_705_276_800 + 86_399)); } #[test] fn parse_after_before() { let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello"); assert_eq!(p.text, "hello"); assert_eq!(p.date_from, Some(1_704_067_200)); assert_eq!(p.date_to, Some(1_717_200_000)); } #[test] fn parse_has_attachment() { let p = parse_search_query("has:attachment report"); assert_eq!(p.text, "report"); assert_eq!(p.has_attachment, Some(true)); } #[test] fn parse_state_operators() { let p = parse_search_query("is:unread is:flagged report"); assert_eq!(p.text, "report"); assert_eq!(p.unread, Some(true)); assert_eq!(p.flagged, Some(true)); let p2 = parse_search_query("is:read is:unflagged"); assert_eq!(p2.unread, Some(false)); assert_eq!(p2.flagged, Some(false)); } #[test] fn parse_multiple_operators() { let p = parse_search_query( "from:alice@test.com to:bob@test.com in:INBOX has:attachment meeting", ); assert_eq!(p.from.as_deref(), Some("alice@test.com")); assert_eq!(p.to.as_deref(), Some("bob@test.com")); assert_eq!(p.folder.as_deref(), Some("INBOX")); assert_eq!(p.has_attachment, Some(true)); assert_eq!(p.text, "meeting"); } #[test] fn parse_quoted_from() { let p = parse_search_query("from:\"John Doe\" hello"); assert_eq!(p.from.as_deref(), Some("John Doe")); assert_eq!(p.text, "hello"); } #[test] fn parse_unknown_operator_kept_as_text() { let p = parse_search_query("foo:bar hello"); assert_eq!(p.text, "foo:bar hello"); } #[test] fn parse_empty_value_not_operator() { let p = parse_search_query("hello from:"); assert_eq!(p.text, "hello from:"); assert!(p.from.is_none()); } #[test] fn date_parsing_basic() { assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200)); assert_eq!(parse_date_start("not-a-date"), None); assert_eq!(parse_date_start("2024-13-01"), None); } }