309 lines
10 KiB
Rust
309 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::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<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 uids: Vec<u32> = {
|
|
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());
|
|
}
|
|
}
|