commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user