Files
RustBox/src/routes/folder_mgmt.rs
T
2026-10-31 14:36:07 +01:00

310 lines
10 KiB
Rust

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<ImapCredentials, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Json(body): Json<CreateFolderRequest>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Path(name): Path<String>,
Json(body): Json<RenameFolderRequest>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Path(name): Path<String>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Path(name): Path<String>,
Json(body): Json<SubscribeRequest>,
) -> Result<Response, AppError> {
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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Path(name): Path<String>,
) -> Result<Response, AppError> {
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::dbq!(routes_folder_mgmt::Q_001),
crate::db::pool::params![&name, "\\Seen"],
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
conn.execute(crate::dbq!(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<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(search_engine): Extension<Arc<SearchEngine>>,
Path(name): Path<String>,
) -> Result<Response, AppError> {
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 transaction = conn
.unchecked_transaction()
.map_err(|e| AppError::InternalError(format!("Database transaction error: {e}")))?;
let uids: Vec<u32> = {
let mut statement = transaction
.prepare(crate::dbq!(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(&transaction, &name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
transaction
.execute(crate::dbq!(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 {
index
.delete_message_with_connection(&transaction, uid, &name)
.map_err(|e| AppError::InternalError(format!("Search outbox error: {e}")))?;
}
}
transaction
.commit()
.map_err(|e| AppError::InternalError(format!("Database commit error: {e}")))?;
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());
}
}