mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// Copyright (c) 2025 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::modules::account::state::AccountRunningState;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::error;
|
||||
|
||||
pub static STATUS_DISPATCHER: LazyLock<ErrorDispatcher> = LazyLock::new(ErrorDispatcher::new);
|
||||
|
||||
pub struct ErrorDispatcher {
|
||||
channel: mpsc::Sender<(u64, String)>,
|
||||
}
|
||||
|
||||
impl ErrorDispatcher {
|
||||
pub fn new() -> Self {
|
||||
let (tx, mut rx) = mpsc::channel::<(u64, String)>(100);
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Some((account_id, error)) = rx.recv().await {
|
||||
match AccountRunningState::append_error_message(account_id, error).await {
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
error!(
|
||||
"Failed to append error for account: {}. Error: {:#?}",
|
||||
&account_id, error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ErrorDispatcher { channel: tx }
|
||||
}
|
||||
|
||||
pub async fn append_error(&self, account_id: u64, error: String) {
|
||||
if let Err(e) = self.channel.send((account_id, error.clone())).await {
|
||||
error!(
|
||||
"Failed to dispatch status update for account: {}, Error: {}. Channel error: {:?}",
|
||||
&account_id, error, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// Copyright (c) 2025 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::{encrypt, modules::error::BichonResult};
|
||||
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Object)]
|
||||
pub struct ImapConfig {
|
||||
/// IMAP server hostname or IP address
|
||||
#[oai(validator(max_length = 253, pattern = r"^[a-zA-Z0-9\-\.]+$"))]
|
||||
pub host: String,
|
||||
/// IMAP server port number
|
||||
#[oai(validator(minimum(value = "1"), maximum(value = "65535")))]
|
||||
pub port: u16,
|
||||
/// Connection encryption method
|
||||
pub encryption: Encryption,
|
||||
/// Authentication configuration
|
||||
pub auth: AuthConfig,
|
||||
/// Optional proxy ID for establishing the connection.
|
||||
/// - If `None` or not provided, the client will connect directly to the IMAP server.
|
||||
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID.
|
||||
pub use_proxy: Option<u64>,
|
||||
}
|
||||
|
||||
impl ImapConfig {
|
||||
pub fn try_encrypt_password(self) -> BichonResult<Self> {
|
||||
Ok(Self {
|
||||
host: self.host,
|
||||
port: self.port,
|
||||
encryption: self.encryption,
|
||||
auth: self.auth.encrypt()?,
|
||||
use_proxy: self.use_proxy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Enum, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum AuthType {
|
||||
/// Standard password authentication (PLAIN/LOGIN)
|
||||
#[default]
|
||||
Password,
|
||||
/// OAuth 2.0 authentication (SASL XOAUTH2)
|
||||
OAuth2,
|
||||
}
|
||||
|
||||
#[derive(Object, Default, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
///Authentication method to use
|
||||
pub auth_type: AuthType,
|
||||
/// Credential secret for Password authentication.
|
||||
///
|
||||
/// Users should provide a plaintext password (1 to 256 characters).
|
||||
/// The server will encrypt the password using AES-256-GCM and securely store it.
|
||||
/// The plaintext password is never stored, so users must remember it for authentication.
|
||||
#[oai(validator(max_length = 256, min_length = 1))]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn encrypt(self) -> BichonResult<Self> {
|
||||
match self.password {
|
||||
Some(password) => Ok(Self {
|
||||
auth_type: self.auth_type,
|
||||
password: Some(encrypt!(&password)?),
|
||||
}),
|
||||
None => Ok(self),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
match self.auth_type {
|
||||
AuthType::Password if self.password.is_none() => {
|
||||
Err("When auth_type is Passwd, password must not be None.")
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Debug, Eq, PartialEq, Serialize, Deserialize, Enum)]
|
||||
pub enum Encryption {
|
||||
/// SSL/TLS encrypted connection
|
||||
#[default]
|
||||
Ssl,
|
||||
/// StartTLS encryption
|
||||
StartTls,
|
||||
/// Unencrypted connection
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<bool> for Encryption {
|
||||
fn from(value: bool) -> Self {
|
||||
if value {
|
||||
Self::Ssl
|
||||
} else {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//
|
||||
// Copyright (c) 2025 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 native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{
|
||||
encrypt,
|
||||
modules::{
|
||||
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
|
||||
cache::imap::mailbox::MailBox,
|
||||
database::{insert_impl, list_all_impl},
|
||||
error::BichonResult,
|
||||
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
|
||||
},
|
||||
utc_now,
|
||||
};
|
||||
|
||||
use crate::id;
|
||||
use crate::modules::account::payload::AccountCreateRequest;
|
||||
use crate::modules::account::payload::AccountUpdateRequest;
|
||||
use crate::modules::account::payload::MinimalAccount;
|
||||
use crate::modules::cache::imap::task::SYNC_TASKS;
|
||||
use crate::modules::context::controller::SYNC_CONTROLLER;
|
||||
use crate::modules::context::executors::MAIL_CONTEXT;
|
||||
use crate::modules::database::count_by_unique_secondary_key_impl;
|
||||
use crate::modules::database::delete_impl;
|
||||
use crate::modules::database::manager::DB_MANAGER;
|
||||
use crate::modules::database::{
|
||||
paginate_query_primary_scan_all_impl, secondary_find_impl, update_impl,
|
||||
};
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::oauth2::token::OAuth2AccessToken;
|
||||
use crate::modules::rest::response::DataPage;
|
||||
use crate::modules::token::AccessToken;
|
||||
use crate::raise_error;
|
||||
|
||||
pub type AccountModel = AccountV1;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
||||
pub enum AccountType {
|
||||
#[default]
|
||||
IMAP,
|
||||
NoSync,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 4, version = 1)]
|
||||
#[native_db(primary_key(pk -> String))]
|
||||
pub struct AccountV1 {
|
||||
#[secondary_key(unique)]
|
||||
pub id: u64,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub capabilities: Option<Vec<String>>,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub folder_limit: Option<u32>,
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
pub account_type: AccountType,
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub known_folders: Option<BTreeSet<String>>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub use_proxy: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccountV1 {
|
||||
fn pk(&self) -> String {
|
||||
format!("{}_{}", self.created_at, self.id)
|
||||
}
|
||||
|
||||
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
|
||||
Ok(Self {
|
||||
id: id!(64),
|
||||
email: request.email,
|
||||
name: request.name,
|
||||
imap: request.imap.map(|i| i.try_encrypt_password()).transpose()?,
|
||||
enabled: request.enabled,
|
||||
capabilities: None,
|
||||
date_since: request.date_since,
|
||||
sync_folders: None,
|
||||
known_folders: None,
|
||||
account_type: request.account_type,
|
||||
sync_interval_min: request.sync_interval_min,
|
||||
created_at: utc_now!(),
|
||||
updated_at: utc_now!(),
|
||||
use_proxy: request.use_proxy,
|
||||
folder_limit: request.folder_limit,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn check_account_active(account_id: u64) -> BichonResult<AccountModel> {
|
||||
let account =
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Account id='{account_id}' not found"),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
|
||||
if !account.enabled {
|
||||
return Err(raise_error!(
|
||||
format!("Account id='{account_id}' is disabled"),
|
||||
ErrorCode::AccountDisabled
|
||||
));
|
||||
}
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
/// Fetches an `AccountEntity` by its `id`.
|
||||
pub async fn get(account_id: u64) -> BichonResult<AccountModel> {
|
||||
let result: AccountModel = Self::find(account_id).await?.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Account with ID '{account_id}' not found"),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
|
||||
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id, account_id)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Saves the current `AccountEntity` by persisting it to storage.
|
||||
pub async fn save(&self) -> BichonResult<()> {
|
||||
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
|
||||
}
|
||||
|
||||
pub async fn create_account(request: AccountCreateRequest) -> BichonResult<AccountModel> {
|
||||
let entity = request.create_entity()?;
|
||||
entity.save().await?;
|
||||
if matches!(entity.account_type, AccountType::IMAP) {
|
||||
SYNC_CONTROLLER
|
||||
.trigger_start(entity.id, entity.email.clone())
|
||||
.await;
|
||||
}
|
||||
Ok(entity)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
account_id: u64,
|
||||
request: AccountUpdateRequest,
|
||||
validate: bool,
|
||||
) -> BichonResult<()> {
|
||||
let account = AccountModel::get(account_id).await?;
|
||||
if validate {
|
||||
request.validate_update_request(&account)?;
|
||||
}
|
||||
update_impl(
|
||||
DB_MANAGER.meta_db(),
|
||||
move |_| Ok(account),
|
||||
move |current| Self::apply_update_fields(current, request),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(account_id: u64) -> BichonResult<()> {
|
||||
let account = Self::get(account_id).await?;
|
||||
if let Err(error) = Self::cleanup_account_resources_sequential(&account).await {
|
||||
tracing::error!(
|
||||
"[CLEANUP_ACCOUNT_ERROR] Account {}: failed to cleanup resources: {:#?}",
|
||||
account_id,
|
||||
error
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_account(account_id: u64) -> BichonResult<()> {
|
||||
delete_impl(DB_MANAGER.meta_db(), move|rw|{
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(||raise_error!(format!("The account entity with id={account_id} that you want to delete was not found."), ErrorCode::ResourceNotFound))
|
||||
}).await
|
||||
}
|
||||
|
||||
async fn cleanup_account_resources_sequential(account: &AccountModel) -> BichonResult<()> {
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
SYNC_TASKS.stop(account.id).await?;
|
||||
AccountRunningState::delete(account.id).await?;
|
||||
MAIL_CONTEXT.clean_account(account.id).await?;
|
||||
}
|
||||
OAuth2AccessToken::try_delete(account.id).await?;
|
||||
AccessToken::cleanup_account(account.id).await?;
|
||||
MailBox::clean(account.id).await?;
|
||||
ENVELOPE_INDEX_MANAGER
|
||||
.delete_account_envelopes(account.id)
|
||||
.await?;
|
||||
EML_INDEX_MANAGER
|
||||
.delete_account_envelopes(account.id)
|
||||
.await?;
|
||||
Self::delete_account(account.id).await?;
|
||||
info!("Sequential cleanup completed for account: {}", account.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_sync_folders(
|
||||
account_id: u64,
|
||||
sync_folders: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!(format!("When trying to update account sync_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
|
||||
}, |current|{
|
||||
let mut updated = current.clone();
|
||||
updated.sync_folders = Some(sync_folders);
|
||||
Ok(updated)
|
||||
}).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_known_folders(
|
||||
account_id: u64,
|
||||
known_folders: BTreeSet<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!(format!("When trying to update account known_folders, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
|
||||
}, |current|{
|
||||
let mut updated = current.clone();
|
||||
updated.known_folders = Some(known_folders);
|
||||
Ok(updated)
|
||||
}).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_capabilities(
|
||||
account_id: u64,
|
||||
capabilities: Vec<String>,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(DB_MANAGER.meta_db(), move |rw| {
|
||||
rw.get().secondary::<AccountModel>(AccountV1Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| raise_error!(format!("When trying to update account capabilities, the corresponding record was not found. account_id={}", account_id), ErrorCode::ResourceNotFound))
|
||||
}, |current|{
|
||||
let mut updated = current.clone();
|
||||
updated.capabilities = Some(capabilities);
|
||||
Ok(updated)
|
||||
}).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retrieves a list of all `AccountEntity` instances.
|
||||
pub async fn list_all() -> BichonResult<Vec<AccountModel>> {
|
||||
list_all_impl(DB_MANAGER.meta_db()).await
|
||||
}
|
||||
|
||||
pub async fn minimal_list() -> BichonResult<Vec<MinimalAccount>> {
|
||||
let result = list_all_impl(DB_MANAGER.meta_db())
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|a: &AccountModel| a.enabled)
|
||||
.map(|account: AccountModel| MinimalAccount {
|
||||
id: account.id,
|
||||
email: account.email,
|
||||
})
|
||||
.collect::<Vec<MinimalAccount>>();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn count() -> BichonResult<usize> {
|
||||
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV1Key::id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn paginate_list(
|
||||
page: Option<u64>,
|
||||
page_size: Option<u64>,
|
||||
desc: Option<bool>,
|
||||
) -> BichonResult<DataPage<AccountModel>> {
|
||||
paginate_query_primary_scan_all_impl(DB_MANAGER.meta_db(), page, page_size, desc)
|
||||
.await
|
||||
.map(DataPage::from)
|
||||
}
|
||||
|
||||
// This method applies the updates from the request to the old account entity
|
||||
fn apply_update_fields(
|
||||
old: &AccountModel,
|
||||
request: AccountUpdateRequest,
|
||||
) -> BichonResult<AccountModel> {
|
||||
let mut new = old.clone();
|
||||
|
||||
if let Some(date_since) = request.date_since {
|
||||
new.date_since = Some(date_since);
|
||||
}
|
||||
|
||||
if let Some(folder_limit) = request.folder_limit {
|
||||
new.folder_limit = Some(folder_limit);
|
||||
}
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
new.name = Some(name.clone());
|
||||
}
|
||||
|
||||
if matches!(old.account_type, AccountType::IMAP) {
|
||||
if let Some(imap) = &request.imap {
|
||||
let mut new_imap = imap.clone();
|
||||
if let Some(password) = &new_imap.auth.password {
|
||||
let encrypted_password = encrypt!(password)?;
|
||||
new_imap.auth.password = Some(encrypted_password);
|
||||
}
|
||||
new.imap = Some(new_imap);
|
||||
}
|
||||
|
||||
if let Some(folder_names) = request.sync_folders {
|
||||
new.sync_folders = Some(folder_names);
|
||||
}
|
||||
if let Some(sync_interval_min) = &request.sync_interval_min {
|
||||
new.sync_interval_min = Some(*sync_interval_min);
|
||||
}
|
||||
if let Some(use_proxy) = request.use_proxy {
|
||||
new.use_proxy = Some(use_proxy);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(old.account_type, AccountType::NoSync) {
|
||||
if let Some(email) = &request.email {
|
||||
new.email = email.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(enabled) = request.enabled {
|
||||
new.enabled = enabled;
|
||||
}
|
||||
new.updated_at = utc_now!();
|
||||
Ok(new)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Copyright (c) 2025 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/>.
|
||||
|
||||
|
||||
pub mod dispatcher;
|
||||
pub mod entity;
|
||||
pub mod payload;
|
||||
pub mod since;
|
||||
pub mod state;
|
||||
pub mod migration;
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// Copyright (c) 2025 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::modules::account::entity::ImapConfig;
|
||||
use crate::modules::account::migration::{AccountModel, AccountType};
|
||||
use crate::modules::account::since::DateSince;
|
||||
use crate::modules::error::code::ErrorCode;
|
||||
use crate::modules::error::BichonResult;
|
||||
use crate::modules::token::AccountInfo;
|
||||
use crate::{raise_error, validate_email};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct AccountCreateRequest {
|
||||
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
|
||||
pub email: String,
|
||||
pub name: Option<String>,
|
||||
pub imap: Option<ImapConfig>,
|
||||
pub enabled: bool,
|
||||
pub date_since: Option<DateSince>,
|
||||
pub account_type: AccountType,
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
pub use_proxy: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccountCreateRequest {
|
||||
pub fn create_entity(self) -> BichonResult<AccountModel> {
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
match self.account_type {
|
||||
AccountType::IMAP => {
|
||||
match &self.imap {
|
||||
Some(imap) => Self::validate_request(imap, &self.email)?,
|
||||
None => {
|
||||
return Err(raise_error!(
|
||||
"IMAP configuration is required for IMAP account type".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
}
|
||||
if self.sync_interval_min.is_none() {
|
||||
return Err(raise_error!(
|
||||
"`sync_interval_min` is required for IMAP account type".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
AccountType::NoSync => {}
|
||||
}
|
||||
Ok(AccountModel::new(self)?)
|
||||
}
|
||||
|
||||
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
|
||||
imap.auth
|
||||
.validate()
|
||||
.map_err(|e| raise_error!(e.to_owned(), ErrorCode::InvalidParameter))?;
|
||||
validate_email!(email)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct AccountUpdateRequest {
|
||||
pub email: Option<String>,
|
||||
/// Represents the account activation status.
|
||||
///
|
||||
/// If this value is `false`, all account-related resources will be unavailable
|
||||
/// and any attempts to access them should return an error indicating the account
|
||||
/// is inactive.
|
||||
pub enabled: Option<bool>,
|
||||
/// Display name for the account (optional)
|
||||
pub name: Option<String>,
|
||||
/// IMAP server configuration
|
||||
pub imap: Option<ImapConfig>,
|
||||
/// Controls initial synchronization time range
|
||||
///
|
||||
/// When dealing with large mailboxes, this restricts scanning to:
|
||||
/// - Messages after specified starting point
|
||||
/// - Or within sliding window
|
||||
///
|
||||
/// ### Use Cases
|
||||
/// - Event-driven systems (only sync recent actionable emails)
|
||||
/// - First-time sync optimization for large accounts
|
||||
/// - Reducing server load during resyncs
|
||||
pub date_since: Option<DateSince>,
|
||||
/// Max emails to sync for this folder.
|
||||
/// If not set, sync all emails.
|
||||
/// otherwise sync up to `n` most recent emails (min 10).
|
||||
#[oai(validator(minimum(value = "100")))]
|
||||
pub folder_limit: Option<u32>,
|
||||
/// Configuration for selective folder (mailbox/label) synchronization
|
||||
///
|
||||
/// - For IMAP/SMTP accounts:
|
||||
/// Stores the mailbox names, since IMAP mailboxes do not have stable IDs.
|
||||
/// Synchronization is keyed by the folder name.
|
||||
///
|
||||
/// - For Gmail API accounts:
|
||||
/// A Gmail label is treated as a mailbox (model mapping).
|
||||
/// Since label names can be easily changed, the stable `labelId` is recorded here
|
||||
/// instead of the label name.
|
||||
///
|
||||
/// Defaults to standard folders (`INBOX`, `Sent`) if empty.
|
||||
/// Modified folders will be automatically synced on the next update.
|
||||
pub sync_folders: Option<Vec<String>>,
|
||||
/// Incremental sync interval (seconds)
|
||||
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
|
||||
pub sync_interval_min: Option<i64>,
|
||||
/// Optional proxy ID for establishing the connection to external APIs (e.g., Gmail, Outlook).
|
||||
/// - If `None` or not provided, the client will connect directly to the API server.
|
||||
/// - If `Some(proxy_id)`, the client will use the pre-configured proxy with the given ID for API requests.
|
||||
pub use_proxy: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccountUpdateRequest {
|
||||
pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> {
|
||||
if let Some(date_since) = self.date_since.as_ref() {
|
||||
date_since.validate()?;
|
||||
}
|
||||
if matches!(account.account_type, AccountType::IMAP) {
|
||||
if let Some(mailboxes) = self.sync_folders.as_ref() {
|
||||
if mailboxes.is_empty() {
|
||||
return Err(raise_error!(
|
||||
"Invalid configuration: 'sync_folders' cannot be empty. \
|
||||
If you are modifying the subscription list, please provide at least one mailbox to subscribe to.".into(), ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
|
||||
pub struct MinimalAccount {
|
||||
pub id: u64,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub fn filter_accessible_accounts<'a>(
|
||||
all_accounts: &'a [MinimalAccount],
|
||||
allowed: &BTreeSet<AccountInfo>,
|
||||
) -> Vec<MinimalAccount> {
|
||||
all_accounts
|
||||
.iter()
|
||||
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//
|
||||
// Copyright (c) 2025 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::{
|
||||
modules::error::{code::ErrorCode, BichonResult},
|
||||
raise_error,
|
||||
};
|
||||
use chrono::{Datelike, Days, Local, Months, NaiveDate, Utc};
|
||||
use poem_openapi::{Enum, Object};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct DateSince {
|
||||
/// Absolute date boundary in ISO 8601 format (YYYY-MM-DD)
|
||||
///
|
||||
/// ### Validation Rules
|
||||
/// - Must match exact format `^\d{4}-\d{2}-\d{2}$`
|
||||
/// - Date must be logically valid (e.g. no 2025-05-01)
|
||||
///
|
||||
/// ### Example
|
||||
/// ```json
|
||||
/// {
|
||||
/// "fixed": "2025-05-01"
|
||||
/// }
|
||||
/// ```
|
||||
#[oai(validator(pattern = r"^\d{4}-\d{2}-\d{2}$"))]
|
||||
pub fixed: Option<String>,
|
||||
/// Relative time period from current date
|
||||
///
|
||||
/// ### Constraints
|
||||
/// - Value must be ≥ 1
|
||||
/// - Units support day/month/year granularity
|
||||
///
|
||||
/// ### Example
|
||||
/// ```json
|
||||
/// {
|
||||
/// "relative": {
|
||||
/// "unit": "Days",
|
||||
/// "value": 7
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub relative: Option<RelativeDate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
|
||||
pub enum Unit {
|
||||
#[default]
|
||||
Days,
|
||||
Months,
|
||||
Years,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct RelativeDate {
|
||||
/// The time unit to use for the offset (days, months, or years)
|
||||
pub unit: Unit,
|
||||
/// The quantity of time units to offset (must be a positive integer)
|
||||
#[oai(validator(minimum(value = "1")))]
|
||||
pub value: u32,
|
||||
}
|
||||
|
||||
impl RelativeDate {
|
||||
pub fn validate_date(&self) -> BichonResult<()> {
|
||||
if self.value == 0 {
|
||||
return Err(raise_error!(
|
||||
"Value must be greater than 0".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
let date = match self.unit {
|
||||
Unit::Days => now.checked_sub_days(Days::new(self.value as u64)),
|
||||
Unit::Months => now.checked_sub_months(Months::new(self.value)),
|
||||
Unit::Years => now.checked_sub_months(Months::new(self.value * 12)),
|
||||
};
|
||||
|
||||
let date = date.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Invalid date: the calculated date is earlier than 1970 or an overflow occurred."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
let naive_date = date.date_naive();
|
||||
|
||||
// Check if the date is before 1970
|
||||
if naive_date.year() < 1970 {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Date cannot be earlier than 1970-01-01. Provided: '{}'",
|
||||
naive_date
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_date(&self) -> BichonResult<chrono::DateTime<Local>> {
|
||||
if self.value == 0 {
|
||||
return Err(raise_error!(
|
||||
"Value must be greater than 0".into(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
let now = Local::now();
|
||||
let date = match self.unit {
|
||||
Unit::Days => now.checked_sub_days(Days::new(self.value as u64)),
|
||||
Unit::Months => now.checked_sub_months(Months::new(self.value)),
|
||||
Unit::Years => now.checked_sub_months(Months::new(self.value * 12)),
|
||||
};
|
||||
|
||||
let date = date.ok_or_else(|| {
|
||||
raise_error!(
|
||||
"Invalid date: the calculated date is earlier than 1970 or an overflow occurred."
|
||||
.into(),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
let naive_date = date.date_naive();
|
||||
if naive_date.year() < 1970 {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Date cannot be earlier than 1970-01-01. Provided: '{}'",
|
||||
naive_date
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
Ok(date)
|
||||
}
|
||||
|
||||
pub fn calculate_date(&self) -> BichonResult<String> {
|
||||
let date = self.compute_date()?;
|
||||
Ok(date.format("%d-%b-%Y").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl DateSince {
|
||||
pub fn validate(&self) -> BichonResult<()> {
|
||||
match (&self.fixed, &self.relative) {
|
||||
// If only `relative` is provided
|
||||
(None, Some(r)) => {
|
||||
r.validate_date()?;
|
||||
}
|
||||
// If only `fixed` is provided
|
||||
(Some(fixed), None) => {
|
||||
self.validate_fixed_date(fixed)?;
|
||||
}
|
||||
// If both or neither are provided
|
||||
_ => {
|
||||
return Err(raise_error!(
|
||||
"Invalid input: You must provide either 'fixed' or 'relative', but not both."
|
||||
.to_string(),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_fixed_date(&self, fixed: &str) -> BichonResult<()> {
|
||||
// Try to parse the input string as YYYY-MM-DD
|
||||
let date = NaiveDate::parse_from_str(fixed, "%Y-%m-%d").map_err(|_| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Invalid date format. Expected 'YYYY-MM-DD'. Example: '2024-11-19'. Provided: '{}'",
|
||||
fixed
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
|
||||
let now = Utc::now().date_naive();
|
||||
|
||||
// Check if the date is in the future
|
||||
if date >= now {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Date cannot be in the future. Provided: '{}', Today: '{}'",
|
||||
fixed,
|
||||
now.format("%Y-%m-%d")
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
// Check if the date is before 1970
|
||||
if date.year() < 1970 {
|
||||
return Err(raise_error!(
|
||||
format!(
|
||||
"Date cannot be earlier than 1970-01-01. Provided: '{}'",
|
||||
fixed
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_user_date(&self, fixed: &str) -> BichonResult<String> {
|
||||
let date = NaiveDate::parse_from_str(fixed, "%Y-%m-%d").map_err(|_| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"Invalid date format. Expected 'YYYY-MM-DD'. Example: '2024-11-19'. Provided: '{}'",
|
||||
fixed
|
||||
),
|
||||
ErrorCode::InvalidParameter
|
||||
)
|
||||
})?;
|
||||
// Format the date into "%d-%b-%Y" format
|
||||
Ok(date.format("%d-%b-%Y").to_string())
|
||||
}
|
||||
|
||||
pub fn since_date(&self) -> BichonResult<String> {
|
||||
// Handle the case where only one of `fixed` or `relative` is provided
|
||||
if let Some(r) = &self.relative {
|
||||
// If `relative` is provided, calculate the date
|
||||
r.calculate_date()
|
||||
} else if let Some(f) = &self.fixed {
|
||||
// If `fixed` is provided, format the date
|
||||
self.format_user_date(f)
|
||||
} else {
|
||||
// If neither is provided, return an error
|
||||
Err(raise_error!(
|
||||
"You must provide either a 'fixed' or 'relative' date.".to_string(),
|
||||
ErrorCode::InvalidParameter
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::modules::account::since::{DateSince, RelativeDate, Unit};
|
||||
|
||||
#[test]
|
||||
fn test1() {
|
||||
let e = DateSince {
|
||||
fixed: Some("2014-09-12".to_string()),
|
||||
relative: None,
|
||||
};
|
||||
|
||||
e.validate().unwrap();
|
||||
|
||||
println!("{}", e.since_date().unwrap());
|
||||
|
||||
let e = DateSince {
|
||||
fixed: None,
|
||||
relative: Some(RelativeDate {
|
||||
unit: Unit::Days,
|
||||
value: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
e.validate().unwrap();
|
||||
|
||||
println!("{}", e.since_date().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// Copyright (c) 2025 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::{
|
||||
modules::{
|
||||
database::{async_find_impl, delete_impl, manager::DB_MANAGER, update_impl, upsert_impl},
|
||||
error::{code::ErrorCode, BichonResult},
|
||||
},
|
||||
raise_error, utc_now,
|
||||
};
|
||||
use native_db::*;
|
||||
use native_model::{native_model, Model};
|
||||
use poem_openapi::Object;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const ERROR_COUNT_PER_ACCOUNT: usize = 30;
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct MailboxBatchProgress {
|
||||
pub total_batches: u32,
|
||||
pub current_batch: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
#[native_model(id = 2, version = 1)]
|
||||
#[native_db]
|
||||
pub struct AccountRunningState {
|
||||
#[primary_key]
|
||||
pub account_id: u64,
|
||||
pub last_incremental_sync_start: i64,
|
||||
pub last_incremental_sync_end: Option<i64>,
|
||||
pub errors: Vec<AccountError>,
|
||||
pub is_initial_sync_completed: bool,
|
||||
pub progress: Option<BTreeMap<String, MailboxBatchProgress>>,
|
||||
pub initial_sync_start_time: Option<i64>,
|
||||
pub initial_sync_end_time: Option<i64>,
|
||||
pub initial_sync_failed_time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
|
||||
pub struct AccountError {
|
||||
pub error: String,
|
||||
pub at: i64,
|
||||
}
|
||||
|
||||
impl AccountRunningState {
|
||||
pub async fn add(account_id: u64) -> BichonResult<()> {
|
||||
let info = AccountRunningState {
|
||||
account_id,
|
||||
last_incremental_sync_start: 0,
|
||||
last_incremental_sync_end: None,
|
||||
errors: vec![],
|
||||
is_initial_sync_completed: false,
|
||||
progress: None,
|
||||
initial_sync_start_time: None,
|
||||
initial_sync_end_time: None,
|
||||
initial_sync_failed_time: None,
|
||||
};
|
||||
upsert_impl(DB_MANAGER.envelope_db(), info).await
|
||||
}
|
||||
|
||||
pub async fn get(account_id: u64) -> BichonResult<Option<AccountRunningState>> {
|
||||
async_find_impl(DB_MANAGER.envelope_db(), account_id).await
|
||||
}
|
||||
|
||||
async fn update_account_running_state(
|
||||
account_id: u64,
|
||||
updater: impl FnOnce(&AccountRunningState) -> BichonResult<AccountRunningState> + Send + 'static,
|
||||
) -> BichonResult<()> {
|
||||
update_impl(
|
||||
DB_MANAGER.envelope_db(),
|
||||
move |rw| {
|
||||
rw.get()
|
||||
.primary::<AccountRunningState>(account_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!("Cannot find sync info of account={}", account_id),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
},
|
||||
updater,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(account_id: u64) -> BichonResult<()> {
|
||||
if Self::get(account_id).await?.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
delete_impl(DB_MANAGER.envelope_db(), move |rw| {
|
||||
rw.get()
|
||||
.primary::<AccountRunningState>(account_id)
|
||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
|
||||
.ok_or_else(|| {
|
||||
raise_error!(
|
||||
format!(
|
||||
"AccountRunningState '{}' not found during deletion process.",
|
||||
account_id
|
||||
),
|
||||
ErrorCode::ResourceNotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.initial_sync_start_time = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_sync_completed(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.is_initial_sync_completed = true;
|
||||
updated.initial_sync_end_time = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_sync_failed(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.initial_sync_failed_time = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_current_sync_batch_number(
|
||||
account_id: u64,
|
||||
syncing_folder: String,
|
||||
batch_number: u32,
|
||||
) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
let mut progress_map = updated.progress.clone().unwrap_or_default();
|
||||
let entry =
|
||||
progress_map
|
||||
.entry(syncing_folder.to_string())
|
||||
.or_insert(MailboxBatchProgress {
|
||||
total_batches: 0,
|
||||
current_batch: 0,
|
||||
});
|
||||
entry.current_batch = batch_number;
|
||||
updated.progress = Some(progress_map);
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_folder_initial_sync_completed(
|
||||
account_id: u64,
|
||||
syncing_folder: String,
|
||||
) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
let mut progress_map = updated.progress.clone().unwrap_or_default();
|
||||
let entry =
|
||||
progress_map
|
||||
.entry(syncing_folder.to_string())
|
||||
.or_insert(MailboxBatchProgress {
|
||||
total_batches: 0,
|
||||
current_batch: 0,
|
||||
});
|
||||
entry.current_batch = entry.total_batches;
|
||||
updated.progress = Some(progress_map);
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_initial_current_syncing_folder(
|
||||
account_id: u64,
|
||||
current_syncing_folder: String,
|
||||
total_sync_batches: u32,
|
||||
) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
let mut progress_map = updated.progress.clone().unwrap_or_default();
|
||||
progress_map.insert(
|
||||
current_syncing_folder.clone(),
|
||||
MailboxBatchProgress {
|
||||
total_batches: total_sync_batches,
|
||||
current_batch: 0,
|
||||
},
|
||||
);
|
||||
updated.progress = Some(progress_map);
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_incremental_sync_start(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.last_incremental_sync_start = utc_now!();
|
||||
updated.last_incremental_sync_end = None;
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_incremental_sync_end(account_id: u64) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.last_incremental_sync_end = Some(utc_now!());
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn append_error_message(account_id: u64, error: String) -> BichonResult<()> {
|
||||
Self::update_account_running_state(account_id, move |current| {
|
||||
let mut updated = current.clone();
|
||||
updated.append_error_log(error);
|
||||
Ok(updated)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn append_error_log(&mut self, error: String) {
|
||||
let new_error = AccountError {
|
||||
error,
|
||||
at: utc_now!(),
|
||||
};
|
||||
|
||||
self.errors.push(new_error);
|
||||
if self.errors.len() > ERROR_COUNT_PER_ACCOUNT {
|
||||
self.errors.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_insert_single_error() {
|
||||
let mut account_state = AccountRunningState {
|
||||
account_id: 1000u64,
|
||||
last_incremental_sync_start: 1000,
|
||||
last_incremental_sync_end: Some(2000),
|
||||
errors: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
account_state.append_error_log(String::from("Error 1"));
|
||||
assert_eq!(account_state.errors.len(), 1);
|
||||
assert_eq!(account_state.errors[0].error, "Error 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_multiple_errors() {
|
||||
let mut account_state = AccountRunningState {
|
||||
account_id: 1000u64,
|
||||
last_incremental_sync_start: 1000,
|
||||
last_incremental_sync_end: Some(2000),
|
||||
errors: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for i in 1..=5 {
|
||||
account_state.append_error_log(format!("Error {}", i));
|
||||
}
|
||||
|
||||
assert_eq!(account_state.errors.len(), 5);
|
||||
assert_eq!(account_state.errors[4].error, "Error 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_limit_exceeded() {
|
||||
let mut account_state = AccountRunningState {
|
||||
account_id: 1000u64,
|
||||
last_incremental_sync_start: 1000,
|
||||
last_incremental_sync_end: Some(2000),
|
||||
errors: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for i in 1..=25 {
|
||||
account_state.append_error_log(format!("Error {}", i));
|
||||
}
|
||||
|
||||
// Should only keep the last 10 errors
|
||||
assert_eq!(account_state.errors.len(), ERROR_COUNT_PER_ACCOUNT);
|
||||
assert_eq!(account_state.errors[0].error, "Error 6");
|
||||
assert_eq!(account_state.errors[19].error, "Error 25");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_error_after_limit() {
|
||||
let mut account_state = AccountRunningState {
|
||||
account_id: 1000u64,
|
||||
last_incremental_sync_start: 1000,
|
||||
last_incremental_sync_end: Some(2000),
|
||||
errors: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Insert exactly 10 errors
|
||||
for i in 1..=20 {
|
||||
account_state.append_error_log(format!("Error {}", i));
|
||||
}
|
||||
|
||||
// Insert one more error to exceed the limit
|
||||
account_state.append_error_log(String::from("Error 21"));
|
||||
|
||||
assert_eq!(account_state.errors.len(), ERROR_COUNT_PER_ACCOUNT);
|
||||
assert_eq!(account_state.errors[0].error, "Error 2"); // The first error is removed
|
||||
assert_eq!(account_state.errors[19].error, "Error 21"); // The last inserted error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user