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

+130
View File
@@ -0,0 +1,130 @@
use std::sync::Arc;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::Serialize;
use crate::auth::session::SessionState;
use crate::config::AppConfig;
use crate::db;
use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials};
/// Response envelope for `GET /api/folders`.
#[derive(Serialize)]
struct FoldersResponse {
folders: Vec<FolderEntry>,
}
/// A single folder in the response.
#[derive(Serialize)]
struct FolderEntry {
name: String,
delimiter: Option<String>,
attributes: Vec<String>,
is_subscribed: bool,
total_count: u32,
unread_count: u32,
}
/// How many seconds the folder list cache is considered fresh.
const FOLDER_LIST_TTL_SECS: u32 = 30;
/// `GET /api/folders`
///
/// Lists all IMAP folders for the authenticated user, syncing the result
/// into the per-user SQLite cache. If the cache was refreshed within
/// `FOLDER_LIST_TTL_SECS` seconds, IMAP is skipped entirely.
pub async fn list_folders(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
) -> Result<Response, AppError> {
// Open the per-user database.
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// If the folder cache was updated recently, skip the IMAP round-trip.
let cache_fresh = db::folders::is_folder_cache_fresh(&conn, FOLDER_LIST_TTL_SECS)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if !cache_fresh {
let creds = ImapCredentials {
host: session.imap_host.clone(),
port: session.imap_port,
tls: session.imap_tls,
email: session.email.clone(),
password: session.password.clone(),
};
// Fetch folders from IMAP server.
let imap_folders = imap_client
.list_folders(&creds)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
// Sync each folder into SQLite cache.
// Use INSERT OR IGNORE to create new folders without triggering CASCADE on
// existing ones, then UPDATE the metadata fields separately.
for folder in &imap_folders {
let flags_csv = folder.attributes.join(",");
db::folders::insert_folder_if_new(
&conn,
&folder.name,
folder.delimiter.as_deref(),
&flags_csv,
)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
// Remove stale folders that no longer exist on the server.
let current_names: Vec<String> = imap_folders.iter().map(|f| f.name.clone()).collect();
db::folders::remove_stale_folders(&conn, &current_names)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Touch updated_at on all folders so the cache TTL resets.
db::folders::touch_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
// Refresh unread counts from cached messages — but skip folders whose
// messages cache has been invalidated (messages_updated_at IS NULL).
// Those folders have a manually adjusted unread_count (via adjust_unread_count)
// that should be preserved until the folder's messages are resynced from IMAP.
let all_folders = db::folders::get_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
for f in &all_folders {
let invalidated = db::folders::is_folder_messages_invalidated(&conn, &f.name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
if !invalidated {
db::folders::refresh_unread_count(&conn, &f.name)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
}
}
// Read back from cache to get the refreshed counts.
let cached = db::folders::get_all_folders(&conn)
.map_err(|e| AppError::InternalError(format!("Database error: {e}")))?;
// Build response from cached data (includes any stored counts).
let folders: Vec<FolderEntry> = cached
.into_iter()
.map(|f| {
let attributes: Vec<String> = if f.flags.is_empty() {
vec![]
} else {
f.flags.split(',').map(|s| s.to_string()).collect()
};
FolderEntry {
name: f.name,
delimiter: f.delimiter,
attributes,
is_subscribed: f.is_subscribed,
total_count: f.total_count,
unread_count: f.unread_count,
}
})
.collect();
Ok(Json(FoldersResponse { folders }).into_response())
}