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?;