first commit
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:24:59 +01:00
commit bc5fbd6ba9
180 files changed
+63750

No files matched your search

+337
View File
@@ -0,0 +1,337 @@
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;
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
#[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,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Folder names that must not be renamed or deleted.
const SYSTEM_FOLDERS: &[&str] = &["INBOX"];
/// Returns `true` if the folder is a system folder (case-insensitive).
fn is_system_folder(name: &str) -> bool {
SYSTEM_FOLDERS.iter().any(|s| s.eq_ignore_ascii_case(name))
}
/// Validates a folder name. Returns `Ok(())` if valid, or an `AppError::BadRequest`.
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(())
}
/// Build IMAP credentials from the session and app configuration.
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,
email: session.email.clone(),
password: session.password.clone(),
})
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `POST /api/folders` -- create a new IMAP folder.
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)?;
// Create the folder on the IMAP server (also subscribes).
imap_client
.create_folder(&creds, &body.name)
.await
.map_err(|e| AppError::InternalError(format!("IMAP create_folder failed: {e}")))?;
// Cache the new folder locally.
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())
}
/// `PATCH /api/folders/:name` -- rename an existing folder.
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}")))?;
// Update the local cache.
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())
}
/// `DELETE /api/folders/:name` -- delete a folder.
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}")))?;
// Remove from local cache.
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())
}
/// `PATCH /api/folders/:name/subscribe` -- toggle folder subscription.
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())
}
/// `POST /api/folders/:name/mark-all-read` -- add \Seen to every message.
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(
"UPDATE messages SET flags = CASE WHEN flags = '' THEN ?2 ELSE flags || ',' || ?2 END WHERE folder = ?1 AND instr(flags, ?2) = 0",
rusqlite::params![&name, "\\Seen"],
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
conn.execute(
"UPDATE folders SET unread_count = 0 WHERE name = ?1",
[&name],
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
Ok(Json(MessageResponse { status: "read" }).into_response())
}
/// `POST /api/folders/:name/empty` -- permanently remove every message.
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("SELECT uid FROM messages WHERE folder = ?1")
.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(
"UPDATE folders SET total_count = 0, unread_count = 0, messages_updated_at = datetime('now') WHERE name = ?1",
[&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())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[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());
}
}