use std::sync::Arc; use axum::extract::Path; 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::imap::client::{ImapClient, ImapCredentials}; use crate::search::engine::SearchEngine; #[derive(Deserialize)] pub struct CreateFolderRequest { pub name: String, } #[derive(Deserialize)] pub struct RenameFolderRequest { pub new_name: String, } #[derive(Deserialize)] pub struct SubscribeRequest { pub subscribed: bool, } #[derive(Serialize)] struct MessageResponse { status: &'static str, } const SYSTEM_FOLDERS: &[&str] = &["INBOX", "Sent", "Drafts", "Trash", "Junk", "Spam"]; fn is_system_folder(name: &str) -> bool { SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name)) } fn validate_folder_name(name: &str) -> Result<(), AppError> { if name.trim().is_empty() { return Err(AppError::BadRequest( "Folder name cannot be empty".to_string(), )); } if name.len() > 255 { return Err(AppError::BadRequest( "Folder name must be 255 characters or fewer".to_string(), )); } if name.contains('{') || name.contains('}') || name.contains('*') || name.contains('%') { return Err(AppError::BadRequest( "Folder name contains invalid characters".to_string(), )); } if name.chars().any(|c| c.is_control()) { return Err(AppError::BadRequest( "Folder name contains invalid characters".to_string(), )); } Ok(()) } fn build_creds(session: &SessionState, _config: &AppConfig) -> Result { Ok(ImapCredentials { host: session.imap_host.clone(), port: session.imap_port, tls: session.imap_tls, tls_verify: session.imap_tls_verify, email: session.email.clone(), password: session.password.clone(), }) } pub async fn create_folder( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Json(body): Json, ) -> Result { validate_folder_name(&body.name)?; let creds = build_creds(&session, &config)?; imap_client .create_folder(&creds, &body.name) .await .map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; db::folders::insert_folder_if_new(&conn, &body.name, None, "") .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; Ok(Json(MessageResponse { status: "created" }).into_response()) } pub async fn rename_folder( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Path(name): Path, Json(body): Json, ) -> Result { if is_system_folder(&name) { return Err(AppError::BadRequest(format!( "Cannot rename system folder '{name}'" ))); } validate_folder_name(&body.new_name)?; let creds = build_creds(&session, &config)?; imap_client .rename_folder(&creds, &name, &body.new_name) .await .map_err(|e| AppError::InternalError(format!("IMAP rename_folder failed: {e}")))?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; db::folders::rename_folder_in_cache(&conn, &name, &body.new_name) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; db::special_folders::rename_folder_reference(&conn, &name, &body.new_name) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; Ok(Json(MessageResponse { status: "renamed" }).into_response()) } pub async fn delete_folder( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Path(name): Path, ) -> Result { if is_system_folder(&name) { return Err(AppError::BadRequest(format!( "Cannot delete system folder '{name}'" ))); } let creds = build_creds(&session, &config)?; imap_client .delete_folder(&creds, &name) .await .map_err(|e| AppError::InternalError(format!("IMAP delete_folder failed: {e}")))?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; db::special_folders::clear_folder_reference(&conn, &name) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; db::folders::delete_folder_and_messages(&conn, &name) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; Ok(Json(MessageResponse { status: "deleted" }).into_response()) } pub async fn subscribe_folder( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Path(name): Path, Json(body): Json, ) -> Result { let creds = build_creds(&session, &config)?; imap_client .subscribe_folder(&creds, &name, body.subscribed) .await .map_err(|e| AppError::InternalError(format!("IMAP subscribe_folder failed: {e}")))?; Ok(Json(MessageResponse { status: if body.subscribed { "subscribed" } else { "unsubscribed" }, }) .into_response()) } pub async fn mark_all_read( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Path(name): Path, ) -> Result { let creds = build_creds(&session, &config)?; imap_client .mark_all_read(&creds, &name) .await .map_err(|e| AppError::ServiceUnavailable(format!("IMAP mark-all-read failed: {e}")))?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; conn.execute( crate::queries::sqlite::queries::routes_folder_mgmt::Q_001, rusqlite::params![&name, "\\Seen"], ) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; conn.execute( crate::queries::sqlite::queries::routes_folder_mgmt::Q_002, [&name], ) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; Ok(Json(MessageResponse { status: "read" }).into_response()) } pub async fn empty_folder( Extension(session): Extension, Extension(config): Extension>, Extension(imap_client): Extension>, Extension(search_engine): Extension>, Path(name): Path, ) -> Result { let creds = build_creds(&session, &config)?; imap_client .empty_folder(&creds, &name) .await .map_err(|e| AppError::ServiceUnavailable(format!("IMAP empty-folder failed: {e}")))?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let uids: Vec = { let mut statement = conn .prepare(crate::queries::sqlite::queries::routes_folder_mgmt::Q_003) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; let rows = statement .query_map([&name], |row| row.get(0)) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; rows.filter_map(Result::ok).collect() }; db::messages::delete_folder_messages(&conn, &name) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; conn.execute( crate::queries::sqlite::queries::routes_folder_mgmt::Q_004, [&name], ) .map_err(|e| AppError::InternalError(format!("Database error: {e}")))?; if let Ok(index) = search_engine.open_user_index(&session.user_hash) { for uid in uids { if let Err(error) = index.delete_message(uid, &name) { tracing::warn!(uid, folder = %name, %error, "Failed to remove emptied message from search index"); } } } Ok(Json(MessageResponse { status: "emptied" }).into_response()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_system_folder() { assert!(is_system_folder("INBOX")); assert!(is_system_folder("inbox")); assert!(is_system_folder("Sent")); assert!(is_system_folder("Drafts")); assert!(is_system_folder("Trash")); assert!(is_system_folder("Junk")); assert!(is_system_folder("Spam")); assert!(!is_system_folder("Archive")); assert!(!is_system_folder("MyFolder")); } #[test] fn test_validate_folder_name_ok() { assert!(validate_folder_name("MyFolder").is_ok()); assert!(validate_folder_name("Projects/2024").is_ok()); } #[test] fn test_validate_folder_name_empty() { assert!(validate_folder_name("").is_err()); assert!(validate_folder_name(" ").is_err()); } #[test] fn test_validate_folder_name_too_long() { let long = "a".repeat(256); assert!(validate_folder_name(&long).is_err()); } #[test] fn test_validate_folder_name_control_chars() { for control in ['\0', '\t', '\r', '\n', '\u{7f}', '\u{85}'] { assert!(validate_folder_name(&format!("test{control}folder")).is_err()); } assert!(validate_folder_name("Projects/café").is_ok()); } #[test] fn test_validate_folder_name_invalid_chars() { assert!(validate_folder_name("test{folder").is_err()); assert!(validate_folder_name("test}folder").is_err()); assert!(validate_folder_name("test*folder").is_err()); assert!(validate_folder_name("test%folder").is_err()); } }