refactor(workspace): decompose project into multiple crates

This commit is contained in:
rustmailer
2026-04-23 21:45:34 +08:00
parent 5b884125f7
commit 0b866c81ff
171 changed files with 2203 additions and 2042 deletions
+185
View File
@@ -0,0 +1,185 @@
//
// 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 std::collections::BTreeSet;
use crate::{
decode_mailbox_name,
{
account::migration::{AccountModel, AccountType},
cache::imap::mailbox::{AttributeEnum, MailBox},
error::{code::ErrorCode, BichonResult},
imap::{executor::ImapExecutor, session::SessionStream},
mailbox::list::convert_names_to_mailboxes,
},
raise_error,
};
use async_imap::{types::Name, Session};
use tracing::{debug, info, warn};
pub async fn get_download_folders(
account: &AccountModel,
session: &mut Session<Box<dyn SessionStream>>,
) -> BichonResult<Vec<MailBox>> {
assert_eq!(account.account_type, AccountType::IMAP);
let names = ImapExecutor::list_all_mailboxes(session).await?;
if names.is_empty() {
warn!(
"Account {}: No mailboxes returned from IMAP server.",
account.id
);
return Err(raise_error!(format!(
"No mailboxes returned from IMAP server for account {}. This is unexpected and may indicate an issue with the IMAP server.",
&account.id
), ErrorCode::ImapUnexpectedResult));
}
let mailboxes: Vec<(MailBox, Name)> = names.into_iter().map(|n| ((&n).into(), n)).collect();
for (mailbox, _) in &mailboxes {
debug!(
"[MAILBOX DEBUG] Account {}: mailbox='{}', attributes={:?}",
account.id, mailbox.name, mailbox.attributes
);
}
detect_mailbox_changes(
account,
mailboxes.iter().map(|(m, _)| m.name.clone()).collect(),
)
.await?;
let account = AccountModel::async_get(account.id).await?;
let subscribed = &account.download_folders.unwrap_or_default();
let is_noselect = |mailbox: &MailBox| {
mailbox
.attributes
.iter()
.any(|attr| matches!(attr.attr, AttributeEnum::NoSelect))
};
let is_default_mailbox = |mailbox: &MailBox| {
mailbox.name.eq_ignore_ascii_case("INBOX")
|| mailbox
.attributes
.iter()
.any(|attr| matches!(attr.attr, AttributeEnum::Sent))
};
let mut matched_mailboxes: Vec<&Name> = if !subscribed.is_empty() {
mailboxes
.iter()
.filter(|(mailbox, _)| subscribed.contains(&mailbox.name) && !is_noselect(mailbox))
.map(|(_, name)| name)
.collect()
} else {
Vec::new()
};
if matched_mailboxes.is_empty() {
matched_mailboxes = mailboxes
.iter()
.filter(|(mailbox, _)| !is_noselect(mailbox) && is_default_mailbox(mailbox))
.map(|(_, name)| name)
.collect();
debug!(
"[MAILBOX DEBUG] Account {}: matched_mailboxes (default selection) = {:?}",
account.id,
matched_mailboxes
.iter()
.map(|n| decode_mailbox_name!(n.name().to_string()))
.collect::<Vec<_>>()
);
if !matched_mailboxes.is_empty() {
let sync_folders: Vec<String> = matched_mailboxes
.iter()
.map(|n| decode_mailbox_name!(n.name().to_string()))
.collect();
AccountModel::update_download_folders(account.id, sync_folders).await?;
} else {
warn!(
"Account {}: No subscribed mailboxes found. This is unexpected — IMAP server should at least provide INBOX.",
account.id
);
return Err(raise_error!(format!(
"No subscribed mailboxes found for account {}. This is unexpected — IMAP server should at least provide INBOX.",
&account.id
), ErrorCode::ImapUnexpectedResult));
}
}
convert_names_to_mailboxes(account.id, session, matched_mailboxes).await
}
pub async fn detect_mailbox_changes(
account: &AccountModel,
all_names: BTreeSet<String>,
) -> BichonResult<()> {
if account.known_folders.is_none() {
// First time sync: just save without comparing
AccountModel::update_known_folders(account.id, all_names).await?;
return Ok(());
}
let known_folders = account.known_folders.clone().unwrap_or_default();
// Compute differences
let new_folders: Vec<String> = all_names.difference(&known_folders).cloned().collect();
let deleted_folders: Vec<String> = known_folders.difference(&all_names).cloned().collect();
let has_changes = !new_folders.is_empty() || !deleted_folders.is_empty();
let download_folders = account.download_folders.as_deref().unwrap_or_default();
// Handle deleted folders in sync_folders
if !deleted_folders.is_empty() {
// Check if any deleted folders are in sync_folders
let remaining_sync_folders: Vec<String> = download_folders
.iter()
.filter(|folder| !deleted_folders.contains(folder))
.cloned()
.collect();
// If sync_folders changed, update them
if remaining_sync_folders.len() != download_folders.len() {
let removed_count = download_folders.len() - remaining_sync_folders.len();
info!(
"Account {}: Removed {} deleted folders from sync_folders",
account.id, removed_count
);
// Note: When all subscribed folders are deleted (remaining_sync_folders empty),
// the system's default behavior is to automatically fall back to syncing
// only the default folders (INBOX and Sent) in subsequent operations
AccountModel::update_download_folders(account.id, remaining_sync_folders).await?;
}
info!(
"Account {}: Folders deleted: {:?}",
account.id, deleted_folders
);
}
// Fire events for new folders if needed
if !new_folders.is_empty() {
info!(
"Account {}: New folders detected: {:?}",
account.id, new_folders
);
}
// Update known folders only if there were changes
if has_changes {
AccountModel::update_known_folders(account.id, all_names).await?;
}
Ok(())
}
+67
View File
@@ -0,0 +1,67 @@
//
// 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::{
{
account::{
migration::AccountModel,
state::{DownloadState, TriggerType},
},
error::BichonResult,
},
utc_now,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DownloadTask {
FullFetch,
TraceFetch,
Idle,
}
pub async fn decide_next_download_task(account: &AccountModel) -> BichonResult<DownloadTask> {
Ok(match DownloadState::get(account.id).await? {
Some(state) => {
let should_trigger = should_trigger_next_download(
state.last_trigger_at,
state.last_finished_at.unwrap_or(0),
account.download_interval_min.unwrap(),
);
if should_trigger {
DownloadState::start_new_session(account.id, TriggerType::Scheduled).await?;
DownloadTask::TraceFetch
} else {
DownloadTask::Idle
}
}
None => {
DownloadState::init(account.id).await?;
DownloadTask::FullFetch
}
})
}
fn should_trigger_next_download(
last_trigger_at: i64,
last_finished_at: i64,
sync_interval_min: i64,
) -> bool {
let now = utc_now!();
now - last_trigger_at > (sync_interval_min * 60 * 1000) && now - last_finished_at > 60 * 1000
}
+701
View File
@@ -0,0 +1,701 @@
//
// 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::{
{
account::{
migration::AccountModel,
state::{DownloadState, DownloadStatus, FolderStatus},
},
cache::{
imap::{
download::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
find_intersecting_mailboxes, find_missing_mailboxes,
mailbox::MailBox,
},
SEMAPHORE,
},
error::{code::ErrorCode, BichonResult},
imap::executor::ImapExecutor,
store::tantivy::envelope::ENVELOPE_MANAGER,
},
raise_error,
};
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
pub const DEFAULT_BATCH_SIZE: u32 = 30;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum FetchDirection {
Since,
Before,
}
pub async fn fetch_and_save_by_date(
account: &AccountModel,
date: &str,
mailbox: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
let account_id = account.id;
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
Err(e) => {
let err_msg = format!("Connection failed for this folder: {:#?}", e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
return Err(e);
}
};
let search_criteria = match direction {
FetchDirection::Since => format!("SINCE {date}"),
FetchDirection::Before => format!("BEFORE {date}"),
};
let uid_list =
match ImapExecutor::uid_search(&mut session, &mailbox.encoded_name(), &search_criteria)
.await
{
Ok(uid_list) => uid_list,
Err(e) => {
let err_msg = format!("UID search failed in [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
return Err(e);
}
};
let len = uid_list.len();
if len == 0 {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)
.await?;
return Ok(());
}
let folder_limit = account.folder_limit;
// sort small -> bigger
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
if let Some(limit) = folder_limit {
let limit = limit.max(100) as usize;
if len > limit {
uid_vec = match direction {
FetchDirection::Since => uid_vec.split_off(len - limit),
FetchDirection::Before => {
uid_vec.truncate(limit);
uid_vec
}
};
}
}
let planned = uid_vec.len() as u64;
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
0,
FolderStatus::Pending,
None,
)
.await?;
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
for (index, batch) in uid_batches.into_iter().enumerate() {
if token.is_cancelled() {
DownloadState::update_session_status(
account_id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)
.await?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
current_processed,
FolderStatus::Cancelled,
None,
)
.await?;
has_error_or_cancel = true;
break;
}
// Fetch metadata for the current batch of UIDs
match ImapExecutor::uid_batch_retrieve_emails(
&mut session,
account_id,
mailbox.id,
&batch.0,
token.clone(),
)
.await
{
Ok(_) => {
current_processed += batch.1;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
current_processed,
FolderStatus::Downloading,
None,
)
.await?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", index, e);
DownloadState::append_session_error(account_id, err_msg.clone()).await?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
current_processed,
FolderStatus::Failed,
Some(err_msg),
)
.await?;
has_error_or_cancel = true;
break;
}
}
}
if !has_error_or_cancel {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
planned,
current_processed,
FolderStatus::Success,
None,
)
.await?;
}
session.logout().await.ok();
Ok(())
}
pub async fn fetch_and_save_full_mailbox(
account: &AccountModel,
mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
let mailbox_id = mailbox.id;
let account_id = account.id;
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
Err(e) => {
let err_msg = format!("Connection failed for this folder: {:#?}", e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
return Err(e);
}
};
let total = match session.examine(&mailbox.encoded_name()).await {
Ok(mailbox) => mailbox.exists as u64,
Err(e) => {
let err_msg = format!("Failed to examine folder [{}]: {:#?}", mailbox.name, e);
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
mailbox.exists as u64,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
session.logout().await.ok();
return Err(raise_error!(
format!("{:#?}", e),
ErrorCode::ImapCommandFailed
));
}
};
let folder_limit = account.folder_limit;
let total_to_fetch = match folder_limit {
Some(limit) if (limit as u64) < total => {
let limit64 = limit as u64;
total.min(limit64.max(100))
}
_ => total,
};
let page_size = if let Some(limit) = folder_limit {
limit
.max(100)
.min(account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
} else {
account.download_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
};
let total_batches = total_to_fetch.div_ceil(page_size as u64);
let desc = folder_limit.is_some();
info!(
"Starting full mailbox download for '{}', total={}, limit={:?}, batches={}, desc={}",
mailbox.name, total, folder_limit, total_batches, desc
);
let mut current_processed = 0u64;
let mut has_error_or_cancel = false;
for page in 1..=total_batches {
if token.is_cancelled() {
DownloadState::update_session_status(
account_id,
DownloadStatus::Cancelled,
Some("User stopped or system shutdown".to_string()),
)
.await?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
current_processed,
FolderStatus::Cancelled,
None,
)
.await?;
has_error_or_cancel = true;
break;
}
match ImapExecutor::batch_retrieve_emails(
&mut session,
account_id,
mailbox_id,
total_to_fetch,
page as u64,
page_size as u64,
&mailbox.encoded_name(),
desc,
token.clone(),
)
.await
{
Ok(count) => {
current_processed += count as u64;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
current_processed,
FolderStatus::Downloading,
None,
)
.await?;
}
Err(e) => {
let err_msg = format!("Batch {} failed: {:#?}", page, e);
DownloadState::append_session_error(account_id, err_msg.clone()).await?;
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
current_processed,
FolderStatus::Failed,
Some(err_msg),
)
.await?;
has_error_or_cancel = true;
break;
}
};
}
if !has_error_or_cancel {
DownloadState::update_folder_progress(
account_id,
mailbox.name.clone(),
total_to_fetch,
current_processed,
FolderStatus::Success,
None,
)
.await?;
}
session.logout().await.ok();
Ok(())
}
pub fn generate_uid_sequence_hashset(
unique_nums: Vec<u32>,
chunk_size: usize,
desc: bool,
) -> Vec<(String, u64)> {
assert!(!unique_nums.is_empty());
let mut nums = unique_nums;
if desc {
nums.reverse();
}
let mut result = Vec::new();
for chunk in nums.chunks(chunk_size) {
let size = chunk.len() as u64;
let compressed = compress_uid_list(chunk.to_vec());
result.push((compressed, size));
}
result
}
pub fn compress_uid_list(nums: Vec<u32>) -> String {
if nums.is_empty() {
return String::new();
}
let mut sorted_nums = nums;
sorted_nums.sort();
let mut result = Vec::new();
let mut current_range_start = sorted_nums[0];
let mut current_range_end = sorted_nums[0];
for &n in sorted_nums.iter().skip(1) {
if n == current_range_end + 1 {
current_range_end = n;
} else {
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
current_range_start = n;
current_range_end = n;
}
}
if current_range_start == current_range_end {
result.push(current_range_start.to_string());
} else {
result.push(format!("{}:{}", current_range_start, current_range_end));
}
result.join(",")
}
pub async fn reconcile_mailboxes(
account: &AccountModel,
remote_mailboxes: &[MailBox],
local_mailboxes: &[MailBox],
token: CancellationToken,
) -> BichonResult<()> {
let start_time = Instant::now();
let existing_mailboxes = find_intersecting_mailboxes(local_mailboxes, remote_mailboxes);
let account_id = account.id;
if !existing_mailboxes.is_empty() {
let mut mailboxes_to_update = Vec::with_capacity(existing_mailboxes.len());
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)
.await?;
for (local_mailbox, remote_mailbox) in &existing_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
break;
}
if local_mailbox.uid_validity != remote_mailbox.uid_validity {
if remote_mailbox.uid_validity.is_none() {
let err_msg = format!(
"Mailbox '{}' logic error: Server did not provide UIDVALIDITY.",
local_mailbox.name
);
warn!("Account {}: {}", account_id, err_msg);
DownloadState::update_folder_progress(
account_id,
remote_mailbox.name.clone(),
0,
0,
FolderStatus::Failed,
Some(err_msg.clone()),
)
.await?;
DownloadState::append_session_error(account_id, err_msg).await?;
continue;
}
info!(
"Account {}: Mailbox '{}' detected with changed uid_validity (local: {:#?}, remote: {:#?}). \
The mailbox data may be invalid, resetting its envelopes and rebuilding the cache.",
account_id, local_mailbox.name, &local_mailbox.uid_validity, &remote_mailbox.uid_validity
);
DownloadState::update_folder_progress(
account_id,
local_mailbox.name.clone(),
remote_mailbox.exists as u64,
0,
FolderStatus::Downloading,
Some("UID validity changed, rebuilding...".into()),
)
.await?;
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&date_since.since_date()?,
remote_mailbox,
FetchDirection::Since,
token.clone(),
)
.await?;
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
token.clone(),
)
.await?;
}
None => {
rebuild_mailbox_cache(
account,
local_mailbox,
remote_mailbox,
token.clone(),
)
.await?
}
},
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox, token.clone())
.await?;
}
mailboxes_to_update.push(remote_mailbox.clone());
}
//The metadata of this mailbox must only be updated after a successful synchronization;
//otherwise, it may cause synchronization errors and result in missing emails in the local sync results.
MailBox::batch_upsert(&mailboxes_to_update).await?;
}
debug!(
"Checked mailbox folders for account ID: {}. Compared local and server folders to identify changes. Elapsed time: {} seconds",
account.id,
start_time.elapsed().as_secs()
);
let missing_mailboxes = find_missing_mailboxes(local_mailboxes, remote_mailboxes);
//Mail folders that are not locally need to be downloaded.
if !missing_mailboxes.is_empty() {
MailBox::batch_insert(&missing_mailboxes).await?;
let mut has_error = false;
let mut last_err = None;
for mailbox in &missing_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
break;
}
if mailbox.exists > 0 {
let account = account.clone();
let mailbox = mailbox.clone();
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
error!(
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
account.id, &mailbox.name, err
);
continue;
}
};
let result = match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&date_since.since_date()?,
&mailbox,
FetchDirection::Since,
token.clone(),
)
.await
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&r.calculate_date()?,
&mailbox,
FetchDirection::Before,
token.clone(),
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox, token.clone()).await
}
},
};
match result {
Ok(_) => {}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
last_err = Some(err);
}
}
}
}
if has_error {
if let Some(e) = last_err {
return Err(e);
}
return Err(raise_error!(
"Some tasks failed".into(),
ErrorCode::InternalError
));
}
}
Ok(())
}
//only check new emails and sync
async fn perform_incremental_sync(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
if remote_mailbox.exists > 0 {
let local_max_uid = ENVELOPE_MANAGER
.get_max_uid(account.id, local_mailbox.id)
.await?;
match local_max_uid {
Some(max_uid) => {
let mut session = ImapExecutor::create_connection(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
ImapExecutor::fetch_new_mail(
&mut session,
account,
local_mailbox,
max_uid + 1,
before_date.as_deref(),
token,
)
.await?;
session.logout().await.ok();
}
None => {
info!(
"No maximum UID found in index for mailbox, assuming local cache is missing."
);
match &account.date_since {
Some(date_since) => {
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
token,
)
.await?;
}
None => {
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
}
}
}
}
}
Ok(())
}
+138
View File
@@ -0,0 +1,138 @@
//
// 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::{
account::{
migration::{AccountModel, AccountType},
state::{DownloadState, DownloadStatus},
},
cache::imap::{mailbox::MailBox, download::flow::FetchDirection},
error::BichonResult,
imap::executor::ImapExecutor,
};
use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_by_date};
use std::time::Instant;
use download_folders::get_download_folders;
use download_type::{decide_next_download_task, DownloadTask};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
pub mod flow;
pub mod rebuild;
pub mod download_folders;
pub mod download_type;
pub async fn process_imap_download(
account: &AccountModel,
token: CancellationToken,
) -> BichonResult<()> {
assert_eq!(account.account_type, AccountType::IMAP);
let start_time = Instant::now();
let account_id = account.id;
let download_task = decide_next_download_task(account).await?;
if matches!(download_task, DownloadTask::Idle) {
return Ok(());
}
let mut session = match ImapExecutor::create_connection(account_id).await {
Ok(session) => session,
Err(e) => {
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(format!("Failed to connect to IMAP server: {}", e)),
)
.await?;
return Err(e);
}
};
let remote_mailboxes = match get_download_folders(account, &mut session).await {
Ok(mailboxes) => mailboxes,
Err(err) => {
let err_msg = format!("Failed to fetch mailboxes: {}", err);
warn!(account_id = account.id, error = %err, "{}", err_msg);
DownloadState::update_session_status(account_id, DownloadStatus::Failed, Some(err_msg))
.await?;
return Ok(());
}
};
session.logout().await.ok();
if matches!(download_task, DownloadTask::FullFetch) {
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&date_since.since_date()?,
FetchDirection::Since,
token,
)
.await
}
None => match &account.date_before {
Some(r) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&r.calculate_date()?,
FetchDirection::Before,
token,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes, token).await,
},
};
match result {
Ok(_) => {
DownloadState::update_session_status(account_id, DownloadStatus::Success, None)
.await?;
}
Err(e) => {
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(format!("Email Download interrupted: {:#?}", e)),
)
.await?;
}
}
return Ok(());
}
let local_mailboxes = MailBox::list_all(account_id).await?;
match reconcile_mailboxes(account, &remote_mailboxes, &local_mailboxes, token).await {
Ok(_) => {
DownloadState::update_session_status(account_id, DownloadStatus::Success, None).await?
}
Err(e) => {
DownloadState::update_session_status(
account_id,
DownloadStatus::Failed,
Some(format!("Email Download interrupted: {:#?}", e)),
)
.await?
}
}
let elapsed_time = start_time.elapsed().as_secs();
debug!(
"Account{{{}}} Incremental sync completed: {} seconds elapsed.",
account.email, elapsed_time
);
Ok(())
}
+267
View File
@@ -0,0 +1,267 @@
//
// 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::{
{
account::{
migration::AccountModel,
state::{DownloadState, DownloadStatus, FolderStatus},
},
cache::{
imap::{
download::flow::{
fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection,
},
mailbox::MailBox,
},
SEMAPHORE,
},
error::{code::ErrorCode, BichonResult},
store::tantivy::envelope::ENVELOPE_MANAGER,
},
raise_error,
};
use tokio_util::sync::CancellationToken;
use tracing::{error, info};
pub async fn rebuild_cache(
account: &AccountModel,
remote_mailboxes: &[MailBox],
token: CancellationToken,
) -> BichonResult<()> {
MailBox::batch_insert(remote_mailboxes).await?;
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)
.await?;
let mut has_error = false;
let mut last_err = None;
for mailbox in remote_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
break;
}
if mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
account.id, &mailbox.name
);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)
.await?;
continue;
}
let account = account.clone();
let mailbox = mailbox.clone();
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
error!(
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
account.id, &mailbox.name, err
);
continue;
}
};
match fetch_and_save_full_mailbox(&account, &mailbox, token.clone()).await {
Ok(_) => {},
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
last_err = Some(err);
}
}
}
if has_error {
if let Some(e) = last_err {
return Err(e);
}
return Err(raise_error!(
"Some tasks failed".into(),
ErrorCode::InternalError
));
}
Ok(())
}
pub async fn rebuild_cache_by_date(
account: &AccountModel,
remote_mailboxes: &[MailBox],
date: &str,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
MailBox::batch_insert(remote_mailboxes).await?;
DownloadState::init_folder_details(
account.id,
remote_mailboxes.iter().map(|m| m.name.clone()).collect(),
)
.await?;
let mut has_error = false;
let mut last_err = None;
for mailbox in remote_mailboxes {
if token.is_cancelled() {
DownloadState::update_session_status(
account.id,
DownloadStatus::Cancelled,
Some("Received termination signal (User stop or System shutdown)".to_string()),
)
.await?;
break;
}
if mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' on the remote server has no emails. Skipping fetch for this mailbox.",
account.id, &mailbox.name
);
DownloadState::update_folder_progress(
account.id,
mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)
.await?;
continue;
}
let account = account.clone();
let mailbox = mailbox.clone();
let date = date.to_string();
let direction = direction.clone();
let _global_permit = match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => permit,
Err(err) => {
error!(
"Failed to acquire global semaphore permit for account {} mailbox '{}': {:#?}",
account.id, &mailbox.name, err
);
continue;
}
};
match fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction, token.clone())
.await
{
Ok(_) => {}
Err(err) => {
has_error = true;
tracing::error!("Folder sync task failed: {:#?}", err);
last_err = Some(err);
}
}
}
if has_error {
if let Some(e) = last_err {
return Err(e);
}
return Err(raise_error!(
"Some tasks failed".into(),
ErrorCode::InternalError
));
}
Ok(())
}
pub async fn rebuild_mailbox_cache(
account: &AccountModel,
local_mailbox: &MailBox,
remote_mailbox: &MailBox,
token: CancellationToken,
) -> BichonResult<()> {
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox.id])
.await?;
if remote_mailbox.exists == 0 {
info!(
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
account.id,
&local_mailbox.name
);
DownloadState::update_folder_progress(
account.id,
remote_mailbox.name.clone(),
0,
0,
FolderStatus::Success,
None,
)
.await?;
return Ok(());
}
fetch_and_save_full_mailbox(account, remote_mailbox, token).await?;
Ok(())
}
pub async fn rebuild_mailbox_cache_by_date(
account: &AccountModel,
local_mailbox_id: u64,
date: &str,
remote: &MailBox,
direction: FetchDirection,
token: CancellationToken,
) -> BichonResult<()> {
ENVELOPE_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
.await?;
if remote.exists == 0 {
info!(
"Account {}: Mailbox '{}' has no emails on the remote server. The mailbox is empty, no envelopes to fetch.",
account.id,
&remote.name
);
DownloadState::update_folder_progress(
account.id,
remote.name.clone(),
0,
0,
FolderStatus::Success,
None,
)
.await?;
return Ok(());
}
fetch_and_save_by_date(account, date, remote, direction, token).await?;
Ok(())
}
+214
View File
@@ -0,0 +1,214 @@
//
// 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::{
decode_mailbox_name, encode_mailbox_name,
{
database::{
async_filter_by_secondary_key_impl, async_find_impl, batch_delete_impl,
batch_insert_impl, batch_upsert_impl, delete_impl, filter_by_secondary_key_impl,
find_impl, manager::DB_MANAGER,
},
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
use async_imap::types::{Name, NameAttribute};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
#[native_model(id = 1, version = 1)]
#[native_db]
pub struct MailBox {
/// The unique identifier for the mailbox
#[primary_key]
pub id: u64,
/// The ID of the account associated with the mailbox
#[secondary_key]
pub account_id: u64,
/// The unique, decoded, human-readable name of the mailbox (e.g., "INBOX", "Sent Items").
/// This is the decoded name as presented to users, derived from the IMAP server's mailbox name
/// (e.g., after decoding UTF-7 or other encodings per RFC 3501).
pub name: String,
/// Optional delimiter used to separate mailbox names in a hierarchy (e.g., "/" or ".").
/// Used in IMAP to structure nested mailboxes (e.g., "INBOX/Archive").
pub delimiter: Option<String>,
/// List of attributes associated with the mailbox (e.g., `\NoSelect`, `\Deleted`).
/// These indicate special properties, such as whether the mailbox can hold messages.
pub attributes: Vec<Attribute>,
/// The number of messages that currently exist in the mailbox.
pub exists: u32,
/// Optional number of unseen messages in the mailbox (i.e., messages without the `\Seen` flag).
pub unseen: Option<u32>,
/// The next unique identifier (UID) that will be assigned to a new message in the mailbox.
/// If `None`, the IMAP server has not provided this information.
pub uid_next: Option<u32>,
/// The validity identifier for UIDs in this mailbox, used to ensure UID consistency across sessions.
/// If `None`, the IMAP server has not provided this information.
pub uid_validity: Option<u32>,
}
impl MailBox {
pub fn encoded_name(&self) -> String {
encode_mailbox_name!(&self.name)
}
pub async fn async_get(id: u64) -> BichonResult<MailBox> {
let result = async_find_impl::<MailBox>(DB_MANAGER.envelope_db(), id).await?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
ErrorCode::InternalError
)
})?)
}
pub fn get(id: u64) -> BichonResult<MailBox> {
let result = find_impl::<MailBox>(DB_MANAGER.envelope_db(), id)?;
Ok(result.ok_or_else(|| {
raise_error!(
format!("mailbox {} not found", id),
ErrorCode::InternalError
)
})?)
}
pub async fn delete(id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
rw.get()
.primary::<MailBox>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| raise_error!("mailbox missing".into(), ErrorCode::InternalError))
})
.await
}
pub async fn list_all(account_id: u64) -> BichonResult<Vec<MailBox>> {
async_filter_by_secondary_key_impl(
DB_MANAGER.envelope_db(),
MailBoxKey::account_id,
account_id,
)
.await
}
pub fn find_mailbox(account_id: u64, mailbox_id: u64) -> BichonResult<Option<MailBox>> {
let all: Vec<MailBox> = filter_by_secondary_key_impl(
DB_MANAGER.envelope_db(),
MailBoxKey::account_id,
account_id,
)?;
Ok(all.into_iter().find(|m| m.id == mailbox_id))
}
pub async fn batch_insert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_insert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
}
pub async fn batch_upsert(mailboxes: &[MailBox]) -> BichonResult<()> {
batch_upsert_impl(DB_MANAGER.envelope_db(), mailboxes.to_vec()).await
}
pub async fn clean(account_id: u64) -> BichonResult<()> {
batch_delete_impl(DB_MANAGER.envelope_db(), move |rw| {
let mailboxes: Vec<MailBox> = rw
.scan()
.secondary::<MailBox>(MailBoxKey::account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(account_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(mailboxes)
})
.await?;
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
pub struct Attribute {
pub attr: AttributeEnum,
pub extension: Option<String>,
}
impl Attribute {
pub fn new(attr: AttributeEnum, extension: Option<String>) -> Self {
Self { attr, extension }
}
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Enum))]
pub enum AttributeEnum {
NoInferiors,
NoSelect,
Marked,
Unmarked,
All,
Archive,
Drafts,
Flagged,
Junk,
Sent,
Trash,
Extension,
Unknown,
}
impl From<&Name> for MailBox {
fn from(value: &Name) -> Self {
let name = decode_mailbox_name!(value.name().to_string());
let delimiter = value.delimiter().map(|f| f.to_owned());
let attributes: Vec<Attribute> = value.attributes().iter().map(|na| na.into()).collect();
//The remaining parts will be supplemented during the examine_mailbox process.
MailBox {
name,
delimiter,
attributes,
..Default::default() //has_synced is initialized to false here
}
}
}
impl From<&NameAttribute<'_>> for Attribute {
fn from(value: &NameAttribute) -> Self {
match value {
NameAttribute::NoInferiors => Attribute::new(AttributeEnum::NoInferiors, None),
NameAttribute::NoSelect => Attribute::new(AttributeEnum::NoSelect, None),
NameAttribute::Marked => Attribute::new(AttributeEnum::Marked, None),
NameAttribute::Unmarked => Attribute::new(AttributeEnum::Unmarked, None),
NameAttribute::All => Attribute::new(AttributeEnum::All, None),
NameAttribute::Archive => Attribute::new(AttributeEnum::Archive, None),
NameAttribute::Drafts => Attribute::new(AttributeEnum::Drafts, None),
NameAttribute::Flagged => Attribute::new(AttributeEnum::Flagged, None),
NameAttribute::Junk => Attribute::new(AttributeEnum::Junk, None),
NameAttribute::Sent => Attribute::new(AttributeEnum::Sent, None),
NameAttribute::Trash => Attribute::new(AttributeEnum::Trash, None),
NameAttribute::Extension(s) => {
Attribute::new(AttributeEnum::Extension, Some(s.to_string()))
}
_ => Attribute::new(AttributeEnum::Unknown, None),
}
}
}
+71
View File
@@ -0,0 +1,71 @@
//
// 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 std::{
collections::{HashMap, HashSet},
sync::LazyLock,
};
use crate::{
account::{old_state::AccountRunningState, state::DownloadState},
database::ModelsAdapter,
};
use mailbox::MailBox;
use native_db::Models;
pub mod download;
pub mod mailbox;
pub mod task;
pub static MAILBOX_MODELS: LazyLock<Models> = LazyLock::new(|| {
let mut adapter = ModelsAdapter::new();
adapter.register_model::<MailBox>();
adapter.register_model::<AccountRunningState>();
adapter.register_model::<DownloadState>();
adapter.models
});
pub fn find_missing_mailboxes(
local_mailboxes: &[MailBox],
server_mailboxes: &[MailBox],
) -> Vec<MailBox> {
let local_names: HashSet<_> = local_mailboxes.iter().map(|m| &m.name).collect();
server_mailboxes
.iter()
.filter(|m| !local_names.contains(&m.name))
.cloned()
.collect()
}
pub fn find_intersecting_mailboxes(
local_mailboxes: &[MailBox],
remote_mailboxes: &[MailBox],
) -> Vec<(MailBox, MailBox)> {
let local_map: HashMap<_, _> = local_mailboxes
.iter()
.map(|m| (m.name.clone(), m.clone()))
.collect();
remote_mailboxes
.iter()
.filter_map(|m| {
local_map
.get(&m.name)
.map(|local_mailbox| (local_mailbox.clone(), m.clone()))
})
.collect()
}
+154
View File
@@ -0,0 +1,154 @@
//
// 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::account::entity::AuthType;
use crate::account::state::DownloadState;
use crate::cache::imap::download::process_imap_download;
use crate::common::periodic::{PeriodicTask, TaskHandle};
use crate::oauth2::token::OAuth2AccessToken;
use crate::{account::migration::AccountModel, error::BichonResult};
use crate::utc_now;
use std::collections::HashMap;
use std::sync::atomic::{AtomicI64, Ordering};
use std::{sync::LazyLock, time::Duration};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
static _DESCRIPTION: &str = "This task periodically synchronizes mailbox data for a specified account, ensuring that all local data is up-to-date.";
const TASK_INTERVAL: Duration = Duration::from_secs(10);
pub static SYNC_TASKS: LazyLock<AccountSyncTask> = LazyLock::new(AccountSyncTask::new);
static LAST_WARN_TIME: AtomicI64 = AtomicI64::new(0);
const WARN_INTERVAL_MS: i64 = 600_000;
pub struct AccountSyncTask {
tasks: Mutex<Option<HashMap<u64, (TaskHandle, CancellationToken)>>>,
}
impl AccountSyncTask {
pub fn new() -> Self {
Self {
tasks: Mutex::new(Some(HashMap::new())),
}
}
pub async fn start_account_download_task(&self, account_id: u64, email: String) {
let task_name = format!("account-download-task-{}-{}", account_id, &email);
let periodic_task = PeriodicTask::new(&task_name);
let cancel_token = CancellationToken::new();
let task_token = cancel_token.clone();
let task = move |param: Option<u64>| {
let account_id = param.unwrap();
let internal_token = task_token.clone();
Box::pin(async move {
let account = AccountModel::async_get(account_id).await.ok();
match account {
Some(account) => {
if !account.enabled {
let last = LAST_WARN_TIME.load(Ordering::Relaxed);
let now = utc_now!();
if now - last >= WARN_INTERVAL_MS {
LAST_WARN_TIME.store(now, Ordering::Relaxed);
warn!(
"Account {}: download aborted. Account is currently disabled.",
account_id
);
}
} else {
if let Some(imap) = &account.imap {
if let AuthType::OAuth2 = imap.auth.auth_type {
if OAuth2AccessToken::get(account.id).await?.is_none() {
if utc_now!() % 300_000 == 0 {
warn!("Account {}: download aborted. OAuth2 authorization not completed. Please visit the rustmailer admin page to authorize this account.", account_id);
}
return Ok(());
}
}
}
if let Err(e) = process_imap_download(&account, internal_token).await {
DownloadState::append_global_error_message(
account.id,
format!("error in account download task: {:#?}", e),
)
.await?;
error!(
"Failed to download mailbox data for '{}': {:?}",
account_id, e
)
}
}
}
None => {
error!(
"Account {}: download aborted. Account entity not found.",
account_id
);
}
}
Ok(())
})
};
let handler = periodic_task.start(task, Some(account_id), TASK_INTERVAL, true, true);
self.add_task(account_id, (handler, cancel_token)).await;
}
pub async fn add_task(&self, account_id: u64, handler: (TaskHandle, CancellationToken)) {
let mut guard = self.tasks.lock().await;
if let Some(map) = guard.as_mut() {
map.insert(account_id, handler);
} else {
tracing::error!("Failed to add task: HashMap has been taken during shutdown.");
}
}
pub async fn stop(&self, account_id: u64) -> BichonResult<()> {
let mut guard = self.tasks.lock().await;
if let Some(map) = guard.as_mut() {
if let Some((handler, token)) = map.remove(&account_id) {
drop(guard);
token.cancel();
handler.cancel().await;
}
}
Ok(())
}
pub async fn shutdown(&self) {
let mut guard = self.tasks.lock().await;
if let Some(map) = guard.take() {
drop(guard);
for (account_id, (handler, token)) in map {
info!(
"Shutdown: Sending cancel signal to account {}...",
account_id
);
token.cancel();
if let Err(_) = tokio::time::timeout(Duration::from_secs(5), handler.stop()).await
{
error!(
"Shutdown: Account {} download task forced timeout.",
account_id
);
}
}
info!("Shutdown: All download tasks processed.");
}
}
}