feat: cache mailbox list for 10 minutes and show progress on initial fetch

- Add 10-minute cache after fetching mailbox list from mail accounts
- Display progress during initial mailbox retrieval
- Prevent timeouts when handling large mailbox lists
This commit is contained in:
rustmailer
2026-05-14 20:01:45 +08:00
parent 1682f21cf7
commit 6b6f11e4c1
10 changed files with 319 additions and 36 deletions
@@ -23,6 +23,7 @@ use crate::{
{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{AttributeEnum, MailBox},
cache::imap::mailbox_cache,
error::{code::ErrorCode, BichonResult},
imap::{executor::ImapExecutor, session::SessionStream},
mailbox::list::convert_names_to_mailboxes,
@@ -179,6 +180,7 @@ pub async fn detect_mailbox_changes(
// Update known folders only if there were changes
if has_changes {
AccountModel::update_known_folders(account.id, all_names)?;
mailbox_cache::invalidate(account.id).await;
}
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::cache::imap::mailbox::MailBox;
use crate::utc_now;
use lru::LruCache;
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::LazyLock;
use tokio::sync::Mutex;
struct CacheEntry {
mailboxes: Vec<MailBox>,
fetched_at: i64,
}
static CACHE: LazyLock<Mutex<LruCache<u64, CacheEntry>>> = LazyLock::new(|| {
Mutex::new(LruCache::new(NonZeroUsize::new(64).unwrap()))
});
const TTL_MS: i64 = 10 * 60 * 1000; // 10 minutes
pub async fn get(account_id: u64) -> Option<Vec<MailBox>> {
let mut guard = CACHE.lock().await;
if let Some(entry) = guard.get(&account_id) {
if utc_now!() - entry.fetched_at < TTL_MS {
return Some(entry.mailboxes.clone());
}
guard.pop(&account_id);
}
None
}
pub async fn set(account_id: u64, mailboxes: Vec<MailBox>) {
let mut guard = CACHE.lock().await;
guard.put(
account_id,
CacheEntry {
mailboxes,
fetched_at: utc_now!(),
},
);
}
pub async fn invalidate(account_id: u64) {
let mut guard = CACHE.lock().await;
guard.pop(&account_id);
}
// Background fetch state tracking
#[derive(Clone, Debug)]
pub enum FetchStatus {
Fetching { examined: usize, total: usize },
Ready,
Error(String),
}
static FETCH_STATES: LazyLock<Mutex<HashMap<u64, FetchStatus>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub async fn fetch_status(account_id: u64) -> Option<FetchStatus> {
FETCH_STATES.lock().await.get(&account_id).cloned()
}
pub async fn set_fetching(account_id: u64) {
FETCH_STATES.lock().await.insert(
account_id,
FetchStatus::Fetching {
examined: 0,
total: 0,
},
);
}
pub async fn update_fetch_progress(account_id: u64, examined: usize, total: usize) {
let mut guard = FETCH_STATES.lock().await;
guard.insert(
account_id,
FetchStatus::Fetching { examined, total },
);
}
pub async fn set_fetch_ready(account_id: u64) {
FETCH_STATES
.lock()
.await
.insert(account_id, FetchStatus::Ready);
}
pub async fn set_fetch_error(account_id: u64, error: String) {
FETCH_STATES
.lock()
.await
.insert(account_id, FetchStatus::Error(error));
}
pub async fn clear_fetch_state(account_id: u64) {
FETCH_STATES.lock().await.remove(&account_id);
}
+1
View File
@@ -22,6 +22,7 @@ use mailbox::MailBox;
pub mod download;
pub mod mailbox;
pub mod mailbox_cache;
pub mod task;
pub fn find_missing_mailboxes(
+134 -3
View File
@@ -18,6 +18,7 @@
use crate::account::migration::{AccountModel, AccountType};
use crate::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::cache::imap::mailbox_cache::{self, FetchStatus};
use crate::error::code::ErrorCode;
use crate::error::BichonResult;
use crate::imap::executor::ImapExecutor;
@@ -26,12 +27,28 @@ use crate::raise_error;
use crate::utils::create_hash;
use async_imap::types::Name;
use async_imap::Session;
use serde::{Deserialize, Serialize};
pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResult<Vec<MailBox>> {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct MailboxListResponse {
pub mailboxes: Vec<MailBox>,
/// "ready" | "fetching" | "error"
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub examined: Option<usize>,
pub total: Option<usize>,
}
pub async fn get_account_mailboxes(
account_id: u64,
remote: bool,
) -> BichonResult<MailboxListResponse> {
let account = AccountModel::check_account_exists(account_id)?;
if remote {
if matches!(account.account_type, AccountType::IMAP) {
request_imap_all_mailbox_list(account_id).await
return Ok(remote_mailboxes(account_id).await);
} else {
return Err(raise_error!(
"The 'remote' option can only be used with IMAP accounts.".into(),
@@ -39,10 +56,124 @@ pub async fn get_account_mailboxes(account_id: u64, remote: bool) -> BichonResul
));
}
} else {
MailBox::list_all(account_id)
let mailboxes = MailBox::list_all(account_id)?;
return Ok(MailboxListResponse {
mailboxes,
status: "ready".into(),
error: None,
examined: None,
total: None,
});
}
}
fn make_pending_response(status: &FetchStatus, error: Option<String>) -> MailboxListResponse {
let (examined, total) = match status {
FetchStatus::Fetching { examined, total } => (Some(*examined), Some(*total)),
_ => (None, None),
};
MailboxListResponse {
mailboxes: vec![],
status: match status {
FetchStatus::Ready => "ready".into(),
FetchStatus::Fetching { .. } => "fetching".into(),
FetchStatus::Error(_) => "error".into(),
},
error,
examined,
total,
}
}
async fn remote_mailboxes(account_id: u64) -> MailboxListResponse {
// Cache hit
if let Some(cached) = mailbox_cache::get(account_id).await {
return MailboxListResponse {
mailboxes: cached,
status: "ready".into(),
error: None,
examined: None,
total: None,
};
}
match mailbox_cache::fetch_status(account_id).await {
Some(status @ FetchStatus::Fetching { .. }) => {
return make_pending_response(&status, None);
}
Some(FetchStatus::Error(err)) => {
mailbox_cache::clear_fetch_state(account_id).await;
return MailboxListResponse {
mailboxes: vec![],
status: "error".into(),
error: Some(err),
examined: None,
total: None,
};
}
_ => {}
}
// No cache, no fetch in progress — start background fetch
mailbox_cache::set_fetching(account_id).await;
spawn_fetch_task(account_id);
MailboxListResponse {
mailboxes: vec![],
status: "fetching".into(),
error: None,
examined: Some(0),
total: Some(0),
}
}
fn spawn_fetch_task(account_id: u64) {
tokio::spawn(async move {
match fetch_remote_with_progress(account_id).await {
Ok(mailboxes) => {
mailbox_cache::set(account_id, mailboxes).await;
mailbox_cache::set_fetch_ready(account_id).await;
}
Err(e) => {
mailbox_cache::set_fetch_error(account_id, format!("{:#?}", e)).await;
}
}
});
}
async fn fetch_remote_with_progress(account_id: u64) -> BichonResult<Vec<MailBox>> {
let mut session = ImapExecutor::create_connection(account_id).await?;
let names = ImapExecutor::list_all_mailboxes(&mut session).await?;
let total = names.len();
mailbox_cache::update_fetch_progress(account_id, 0, total).await;
let mut mailboxes = Vec::new();
for (i, name) in names.iter().enumerate() {
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
if contains_no_select(&mailbox.attributes) {
continue;
}
mailbox.account_id = account_id;
mailbox.id = create_hash(account_id, &mailbox.name);
let mx = session
.examine(mailbox_name.as_str())
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::ImapCommandFailed))?;
mailbox.exists = mx.exists;
mailbox.unseen = mx.unseen;
mailbox.uid_next = mx.uid_next;
mailbox.uid_validity = mx.uid_validity;
mailboxes.push(mailbox);
mailbox_cache::update_fetch_progress(account_id, i + 1, total).await;
}
session.logout().await.ok();
Ok(mailboxes)
}
pub async fn request_imap_all_mailbox_list(account_id: u64) -> BichonResult<Vec<MailBox>> {
let mut session = ImapExecutor::create_connection(account_id).await?;
let names = ImapExecutor::list_all_mailboxes(&mut session).await?;
+1 -1
View File
@@ -310,7 +310,7 @@ impl MemDb {
}
}
eprintln!("[memdb] snapshot saved at seq={last_seq}");
//eprintln!("[memdb] snapshot saved at seq={last_seq}");
Ok(())
}
+2 -3
View File
@@ -19,9 +19,8 @@
use crate::common::auth::WrappedContext;
use crate::rest::api::ApiTags;
use crate::rest::ApiResult;
use bichon_core::cache::imap::mailbox::MailBox;
use bichon_core::mailbox::delete::delete_mailbox_impl;
use bichon_core::mailbox::list::get_account_mailboxes;
use bichon_core::mailbox::list::{get_account_mailboxes, MailboxListResponse};
use bichon_core::users::permissions::Permission;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
@@ -51,7 +50,7 @@ impl MailBoxApi {
account_id: Path<u64>,
remote: Query<Option<bool>>,
context: WrappedContext,
) -> ApiResult<Json<Vec<MailBox>>> {
) -> ApiResult<Json<MailboxListResponse>> {
let account_id = account_id.0;
context.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)?;
let remote = remote.0.unwrap_or(false);
+9 -1
View File
@@ -32,8 +32,16 @@ export interface MailboxData {
unseen: number | null;
}
export interface MailboxListResponse {
mailboxes: MailboxData[];
status: "ready" | "fetching" | "error";
error?: string | null;
examined?: number | null;
total?: number | null;
}
export const list_mailboxes = async (accountId: number, remote: boolean) => {
const response = await axiosInstance.get<MailboxData[]>(`api/v1/list-mailboxes/${accountId}?remote=${remote}`);
const response = await axiosInstance.get<MailboxListResponse>(`api/v1/list-mailboxes/${accountId}?remote=${remote}`);
return response.data;
};
@@ -161,6 +161,7 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
const [treeData, setTreeData] = useState<TreeViewBaseItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [fetchProgress, setFetchProgress] = useState<{ examined: number; total: number } | null>(null);
const queryClient = useQueryClient();
const { t } = useTranslation()
const { theme } = useTheme()
@@ -168,42 +169,65 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
useEffect(() => {
if (!open) return;
let cancelled = false;
const fetchMailboxes = async () => {
setIsLoading(true);
try {
const data = await list_mailboxes(currentRow.id, true);
if (!cancelled) {
setMailboxes(data);
const allIds = data.map(mailbox => String(mailbox.id));
setAllIds(allIds);
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
const tree = buildTree(data);
setTreeData(tree);
const itemsWithChildren = getParentIds(tree);
setItemsWithChildren(itemsWithChildren);
setExpandedItems(itemsWithChildren);
const download_folders = data
.filter(mailbox => currentRow.download_folders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
setSelectedItems(download_folders);
const processMailboxes = (data: MailboxData[]) => {
setMailboxes(data);
const allIds = data.map(mailbox => String(mailbox.id));
setAllIds(allIds);
const tree = buildTree(data);
setTreeData(tree);
const itemsWithChildren = getParentIds(tree);
setItemsWithChildren(itemsWithChildren);
setExpandedItems(itemsWithChildren);
const download_folders = data
.filter(mailbox => currentRow.download_folders.includes(mailbox.name))
.map(mailbox => mailbox.id.toString());
setSelectedItems(download_folders);
};
const fetchMailboxes = async () => {
try {
const response = await list_mailboxes(currentRow.id, true);
if (cancelled) return;
if (response.status === "ready") {
processMailboxes(response.mailboxes);
setError(undefined);
setIsLoading(false);
} else if (response.status === "fetching") {
setIsLoading(true);
setError(undefined);
if (response.examined != null && response.total != null && response.total > 0) {
setFetchProgress({ examined: response.examined, total: response.total });
}
pollingTimer = setTimeout(fetchMailboxes, 2000);
} else if (response.status === "error") {
setIsLoading(false);
setError(response.error || "Unknown error");
}
} catch (err: any) {
if (axios.isAxiosError(err)) {
const resData = err.response?.data;
if (resData) {
setError(`Error ${resData.code || ''}: ${resData.message || ''}`);
if (!cancelled) {
if (axios.isAxiosError(err)) {
const resData = err.response?.data;
if (resData) {
setError(`Error ${resData.code || ''}: ${resData.message || ''}`);
} else {
setError(err.message);
}
} else {
setError(err.message);
setError(err.message || String(err));
}
setIsLoading(false);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
setIsLoading(true);
fetchMailboxes();
return () => {
cancelled = true;
if (pollingTimer) clearTimeout(pollingTimer);
};
}, [currentRow, open]);
@@ -455,12 +479,16 @@ export function DownloadFoldersDialog({ currentRow, open, onOpenChange }: Props)
</div>
</div>
<ScrollArea className="h-[calc(100%-500px)] flex-1 min-h-0 w-full pr-4 -mr-4 py-1">
<ScrollArea className="h-[32rem] flex-1 min-h-0 w-full pr-4 -mr-4 py-1">
{isLoading && (
<div className="p-8 space-y-8">
<div className="flex flex-col items-center gap-3 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm font-medium">{t('accounts.folderSync.loadingMailboxFolders')}</span>
<span className="text-sm font-medium">
{fetchProgress && fetchProgress.total > 0
? `${t('accounts.folderSync.loadingMailboxFolders')} (${fetchProgress.examined}/${fetchProgress.total})`
: t('accounts.folderSync.loadingMailboxFolders')}
</span>
</div>
<div className="space-y-2">
@@ -181,7 +181,7 @@ export function MailboxPopover() {
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
queryKey: ['search-mailboxes', activeAccountId],
queryFn: () => list_mailboxes(activeAccountId!, false),
queryFn: async () => (await list_mailboxes(activeAccountId!, false)).mailboxes,
enabled: !!activeAccountId,
});
+1 -1
View File
@@ -181,7 +181,7 @@ export function MailboxPopover() {
const { data: activeMailboxes = [], isLoading: activeIsLoading } = useQuery({
queryKey: ['search-mailboxes', activeAccountId],
queryFn: () => list_mailboxes(activeAccountId!, false),
queryFn: async () => (await list_mailboxes(activeAccountId!, false)).mailboxes,
enabled: !!activeAccountId,
});