mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-31 01:52:30 +00:00
277 lines
11 KiB
Rust
277 lines
11 KiB
Rust
//
|
|
// 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, TriggerType};
|
|
use crate::cache::imap::download::process_imap_download;
|
|
use crate::common::periodic::{PeriodicTask, TaskHandle};
|
|
use crate::error::code::ErrorCode;
|
|
use crate::oauth2::token::OAuth2AccessToken;
|
|
use crate::{account::migration::AccountModel, error::BichonResult};
|
|
use crate::{raise_error, utc_now};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::atomic::{AtomicI64, Ordering};
|
|
use std::{sync::LazyLock, time::Duration};
|
|
use tokio::sync::Mutex;
|
|
use tokio::task::JoinHandle;
|
|
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<AccountDownTask> = LazyLock::new(AccountDownTask::new);
|
|
static LAST_WARN_TIME: AtomicI64 = AtomicI64::new(0);
|
|
const WARN_INTERVAL_MS: i64 = 600_000;
|
|
|
|
pub struct AccountDownTask {
|
|
tasks: Mutex<Option<HashMap<u64, (TaskHandle, CancellationToken)>>>,
|
|
manual_tasks: Mutex<HashMap<u64, (JoinHandle<()>, CancellationToken)>>,
|
|
busy_accounts: Mutex<HashSet<u64>>,
|
|
}
|
|
|
|
impl AccountDownTask {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
tasks: Mutex::new(Some(HashMap::new())),
|
|
manual_tasks: Mutex::new(HashMap::new()),
|
|
busy_accounts: Mutex::new(HashSet::new()),
|
|
}
|
|
}
|
|
|
|
async fn set_busy(&self, account_id: u64, is_busy: bool) {
|
|
let mut guard = self.busy_accounts.lock().await;
|
|
if is_busy {
|
|
guard.insert(account_id);
|
|
} else {
|
|
guard.remove(&account_id);
|
|
}
|
|
}
|
|
|
|
/// Atomically check and set busy. Returns true if we claimed the slot,
|
|
/// false if another task is already busy on this account.
|
|
async fn try_set_busy(&self, account_id: u64) -> bool {
|
|
let mut guard = self.busy_accounts.lock().await;
|
|
if guard.contains(&account_id) {
|
|
false
|
|
} else {
|
|
guard.insert(account_id);
|
|
true
|
|
}
|
|
}
|
|
|
|
// async fn is_busy(&self, account_id: u64) -> bool {
|
|
// self.busy_accounts.lock().await.contains(&account_id)
|
|
// }
|
|
|
|
pub async fn start_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 {
|
|
if SYNC_TASKS.is_manual_running(account_id).await {
|
|
info!(
|
|
"Account {}: Scheduled task skipped (Manual task is running).",
|
|
account_id
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
if !SYNC_TASKS.try_set_busy(account_id).await {
|
|
warn!(
|
|
"Account {}: Scheduled task skipped (Previous sync still active).",
|
|
account_id
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let _busy_guard = scopeguard::guard(account_id, |id| {
|
|
tokio::spawn(async move {
|
|
SYNC_TASKS.set_busy(id, false).await;
|
|
});
|
|
});
|
|
let account = AccountModel::get(account_id).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)?.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,
|
|
TriggerType::Scheduled,
|
|
)
|
|
.await
|
|
{
|
|
DownloadState::append_session_error(
|
|
account.id,
|
|
format!("error in account download task: {:#?}", e),
|
|
)?;
|
|
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.");
|
|
}
|
|
}
|
|
|
|
pub async fn start_manual_task(&self, account_id: u64) -> BichonResult<()> {
|
|
{
|
|
if self.is_manual_running(account_id).await {
|
|
return Err(raise_error!(
|
|
"Manual task already running.".into(),
|
|
ErrorCode::Forbidden
|
|
));
|
|
}
|
|
if !self.try_set_busy(account_id).await {
|
|
return Err(raise_error!(
|
|
"The background synchronization is currently active. Please try again in a few seconds.".into(),
|
|
ErrorCode::Forbidden
|
|
));
|
|
}
|
|
}
|
|
|
|
let cancel_token = CancellationToken::new();
|
|
let token_clone = cancel_token.clone();
|
|
let handle = tokio::spawn(async move {
|
|
// busy already claimed by caller via try_set_busy
|
|
let _cleanup = scopeguard::guard(account_id, |id| {
|
|
tokio::spawn(async move {
|
|
SYNC_TASKS.set_busy(id, false).await;
|
|
let mut guard = SYNC_TASKS.manual_tasks.lock().await;
|
|
guard.remove(&id);
|
|
});
|
|
});
|
|
if token_clone.is_cancelled() {
|
|
return;
|
|
}
|
|
let account = match AccountModel::get(account_id) {
|
|
Ok(acc) => acc,
|
|
Err(e) => {
|
|
error!("Failed to fetch account {}: {:?}", account_id, e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Err(e) = process_imap_download(&account, token_clone, TriggerType::Manual).await
|
|
{
|
|
error!("Manual download failed for {}: {:?}", account_id, e);
|
|
let error_msg = format!("error in account download task: {:#?}", e);
|
|
let _ = DownloadState::append_session_error(account.id, error_msg);
|
|
}
|
|
});
|
|
{
|
|
let mut guard = self.manual_tasks.lock().await;
|
|
guard.insert(account_id, (handle, cancel_token));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn cancel_manual_task(&self, account_id: u64) {
|
|
let mut guard = self.manual_tasks.lock().await;
|
|
if let Some((handle, token)) = guard.remove(&account_id) {
|
|
token.cancel();
|
|
let _ = handle.await;
|
|
}
|
|
}
|
|
|
|
pub async fn is_manual_running(&self, account_id: u64) -> bool {
|
|
let guard = self.manual_tasks.lock().await;
|
|
guard.contains_key(&account_id)
|
|
}
|
|
}
|