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
+143
View File
@@ -0,0 +1,143 @@
use std::{
collections::{BTreeSet, HashSet},
net::IpAddr,
};
use crate::{
error::{code::ErrorCode, BichonResult},
raise_error,
users::{permissions::Permission, role::UserRole, UserModel},
};
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub user: UserModel,
}
impl ClientContext {
pub async fn require_any_permission(
&self,
requirements: Vec<(Option<u64>, &str)>,
) -> BichonResult<()> {
for (account_id, permission) in requirements {
if self.has_permission(account_id, permission).await {
return Ok(());
}
}
Err(raise_error!(
"Access denied: Insufficient permissions to perform this action.".into(),
ErrorCode::Forbidden
))
}
pub async fn check_has_permission(
user: &UserModel,
account_id: Option<u64>,
permission: &str,
) -> bool {
if user.is_admin().await {
return true;
}
let mut global_perms = HashSet::new();
for rid in &user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
global_perms.extend(role.permissions);
}
}
if Self::check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
pub async fn has_permission(&self, account_id: Option<u64>, permission: &str) -> bool {
if self.user.is_admin().await {
return true;
}
let mut global_perms = HashSet::new();
for rid in &self.user.global_roles {
if let Some(role) = UserRole::find(*rid).await.ok().flatten() {
global_perms.extend(role.permissions);
}
}
if Self::check_global_logic(&global_perms, permission) {
return true;
}
if let Some(aid) = account_id {
if let Some(role_id) = self.user.account_access_map.get(&aid) {
if let Some(role) = UserRole::find(*role_id).await.ok().flatten() {
if role.permissions.contains(&permission.to_string())
|| Self::check_account_logic(&role.permissions, permission)
{
return true;
}
}
}
}
false
}
fn check_global_logic(global: &HashSet<String>, perm: &str) -> bool {
if global.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ => global.contains(Permission::DATA_READ_ALL),
Permission::DATA_DELETE => global.contains(Permission::DATA_DELETE_ALL),
Permission::DATA_RAW_DOWNLOAD => global.contains(Permission::DATA_RAW_DOWNLOAD_ALL),
Permission::DATA_EXPORT_BATCH => global.contains(Permission::DATA_EXPORT_BATCH_ALL),
Permission::ACCOUNT_MANAGE | Permission::ACCOUNT_READ_DETAILS => {
global.contains(Permission::ACCOUNT_MANAGE_ALL)
}
_ => false,
}
}
fn check_account_logic(scoped_perms: &BTreeSet<String>, perm: &str) -> bool {
if scoped_perms.contains(perm) {
return true;
}
match perm {
Permission::DATA_READ | Permission::ACCOUNT_READ_DETAILS => {
scoped_perms.contains(Permission::ACCOUNT_MANAGE)
}
_ => false,
}
}
pub async fn require_permission(
&self,
account_id: Option<u64>,
permission: &str,
) -> BichonResult<()> {
if self.has_permission(account_id, permission).await {
Ok(())
} else {
Err(raise_error!(
format!("Access Denied: Missing permission '{}'", permission),
ErrorCode::Forbidden
))
}
}
}
+81
View File
@@ -0,0 +1,81 @@
//
// 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::ops::Deref;
use mail_parser::{Addr as ImapAddr, Address as ImapAddress};
use serde::{Deserialize, Serialize};
pub mod auth;
pub mod paginated;
pub mod periodic;
pub mod rustls;
pub mod signal;
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct Addr {
/// The optional display name associated with the email address (e.g., "John Doe").
/// If `None`, no display name is specified.
pub name: Option<String>,
/// The optional email address (e.g., "john.doe@example.com").
/// If `None`, the address is unavailable, though typically at least one of `name` or `address` is provided.
pub address: Option<String>,
}
impl std::fmt::Display for Addr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (&self.name, &self.address) {
(Some(name), Some(address)) => write!(f, "{} <{}>", name, address),
(None, Some(address)) => write!(f, "<{}>", address),
(Some(name), None) => write!(f, "{}", name),
(None, None) => write!(f, ""),
}
}
}
impl<'x> From<&ImapAddr<'x>> for Addr {
fn from(original: &ImapAddr<'x>) -> Self {
Addr {
name: original.name.as_ref().map(|s| s.to_string()),
address: original.address.as_ref().map(|s| s.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AddrVec(pub Vec<Addr>);
impl Deref for AddrVec {
type Target = Vec<Addr>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<'x> From<&ImapAddress<'x>> for AddrVec {
fn from(original: &ImapAddress<'x>) -> Self {
let vec = match original {
ImapAddress::List(addrs) => addrs.iter().map(Addr::from).collect(),
ImapAddress::Group(groups) => groups
.iter()
.flat_map(|group| group.addresses.iter().map(Addr::from))
.collect(),
};
AddrVec(vec)
}
}
+180
View File
@@ -0,0 +1,180 @@
//
// 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::{
error::{code::ErrorCode, BichonResult},
raise_error,
};
use serde::{Deserialize, Serialize};
use std::cmp::min;
pub fn paginate_vec<T: Clone>(
items: &Vec<T>,
page: Option<u64>,
page_size: Option<u64>,
) -> BichonResult<Paginated<T>> {
let total_items = items.len() as u64;
let (offset, total_pages) = match (page, page_size) {
(Some(p), Some(s)) if p > 0 && s > 0 => {
let offset = (p - 1) * s;
let total_pages = if total_items > 0 {
(total_items + s - 1) / s
} else {
0
};
(Some(offset), Some(total_pages))
}
(Some(0), _) | (_, Some(0)) => {
return Err(raise_error!(
"'page' and 'page_size' must be greater than 0.".into(),
ErrorCode::InvalidParameter
));
}
_ => (None, None),
};
let data = match offset {
Some(offset) if offset >= total_items => vec![],
Some(offset) => {
let end = min(offset + page_size.unwrap_or(total_items), total_items) as usize;
items[offset as usize..end].to_vec()
}
None => items.clone(),
};
Ok(Paginated::new(
page,
page_size,
total_items,
total_pages,
data,
))
}
#[cfg(not(feature = "web-api"))]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DataPage<S>
where
S: Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync,
{
/// The current page number (starting from 1).
pub current_page: Option<u64>,
/// The number of items per page.
pub page_size: Option<u64>,
/// The total number of items across all pages.
pub total_items: u64,
/// The list of items returned on the current page.
pub items: Vec<S>,
/// The total number of pages. This is optional and may not be set if not calculated.
pub total_pages: Option<u64>,
}
#[cfg(not(feature = "web-api"))]
impl<S: Serialize + std::fmt::Debug + std::marker::Unpin + Send + Sync> From<Paginated<S>>
for DataPage<S>
{
fn from(paginated: Paginated<S>) -> Self {
DataPage {
current_page: paginated.page,
page_size: paginated.page_size,
total_items: paginated.total_items,
total_pages: paginated.total_pages,
items: paginated.items,
}
}
}
#[cfg(feature = "web-api")]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, poem_openapi::Object)]
pub struct DataPage<S>
where
S: Serialize
+ std::fmt::Debug
+ std::marker::Unpin
+ Send
+ Sync
+ poem_openapi::types::Type
+ poem_openapi::types::ParseFromJSON
+ poem_openapi::types::ToJSON,
{
/// The current page number (starting from 1).
pub current_page: Option<u64>,
/// The number of items per page.
pub page_size: Option<u64>,
/// The total number of items across all pages.
pub total_items: u64,
/// The list of items returned on the current page.
pub items: Vec<S>,
/// The total number of pages. This is optional and may not be set if not calculated.
pub total_pages: Option<u64>,
}
#[cfg(feature = "web-api")]
impl<
S: Serialize
+ std::fmt::Debug
+ std::marker::Unpin
+ Send
+ Sync
+ poem_openapi::types::Type
+ poem_openapi::types::ParseFromJSON
+ poem_openapi::types::ToJSON,
> From<Paginated<S>> for DataPage<S>
{
fn from(paginated: Paginated<S>) -> Self {
DataPage {
current_page: paginated.page,
page_size: paginated.page_size,
total_items: paginated.total_items,
total_pages: paginated.total_pages,
items: paginated.items,
}
}
}
#[derive(Debug)]
pub struct Paginated<T> {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub total_items: u64,
pub total_pages: Option<u64>,
pub items: Vec<T>,
}
impl<T> Paginated<T> {
pub fn new(
page: Option<u64>,
page_size: Option<u64>,
total_items: u64,
total_pages: Option<u64>,
items: Vec<T>,
) -> Self {
Paginated {
page,
page_size,
total_items,
total_pages,
items,
}
}
}
+125
View File
@@ -0,0 +1,125 @@
//
// 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::{common::signal::SIGNAL_MANAGER, error::BichonResult};
use std::{future::Future, time::Duration};
use tokio::{sync::oneshot, task::JoinHandle, time::MissedTickBehavior};
use tracing::{info, warn};
pub struct PeriodicTask {
name: String,
}
pub struct TaskHandle {
cancel_sender: Option<oneshot::Sender<()>>,
join_handle: JoinHandle<()>,
}
impl TaskHandle {
pub async fn cancel(self) {
if let Some(sender) = self.cancel_sender {
let _ = sender.send(());
}
let _ = self.join_handle.await;
}
pub async fn stop(self) {
let _ = self.join_handle.await;
}
}
impl PeriodicTask {
pub fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
}
}
/// If `enable_cancel` is true, allows cancellation through TaskHandle::cancel
pub fn start<F, T>(
self,
task: T,
param: Option<u64>,
interval: Duration,
enable_cancel: bool,
run_immediately: bool,
) -> TaskHandle
where
T: Fn(Option<u64>) -> F + Send + Sync + 'static,
F: Future<Output = BichonResult<()>> + Send + 'static,
{
info!("Task '{}' started", &self.name);
let (cancel_sender_opt, cancel_receiver_opt) = if enable_cancel {
let (tx, rx) = oneshot::channel::<()>();
(Some(tx), Some(rx))
} else {
(None, None)
};
let name_clone = self.name.clone();
let join_handle = tokio::spawn(async move {
let mut interval = tokio::time::interval(interval);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut shutdown = SIGNAL_MANAGER.subscribe();
if !run_immediately {
interval.tick().await; // discard first immediate tick
}
let mut cancel_receiver = cancel_receiver_opt;
loop {
let cancel_fut = async {
if let Some(ref mut rx) = cancel_receiver {
rx.await.ok();
} else {
std::future::pending::<()>().await;
}
};
tokio::select! {
_ = interval.tick() => {
match task(param).await {
Ok(()) => {},
Err(e) => {
warn!("Task '{}' failed: {:?}", name_clone, e);
},
}
}
// only enabled if cancel_receiver is Some
_ = cancel_fut => {
info!("Task '{}' received cancellation signal", name_clone);
break;
}
_ = shutdown.recv() => {
info!("Task '{}' shutting down due to shutdown signal", name_clone);
break;
}
}
}
info!("Task '{}' stopped", name_clone);
});
TaskHandle {
cancel_sender: cancel_sender_opt,
join_handle,
}
}
}
+39
View File
@@ -0,0 +1,39 @@
//
// 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::{
{
context::Initialize,
error::{code::ErrorCode, BichonResult},
},
raise_error,
};
pub struct BichonTls;
impl Initialize for BichonTls {
async fn initialize() -> BichonResult<()> {
rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider())
.map_err(|_| {
raise_error!(
"failed to set crypto provider".into(),
ErrorCode::InternalError
)
})
}
}
+52
View File
@@ -0,0 +1,52 @@
//
// 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::sync::LazyLock;
use crate::{context::Initialize, error::BichonResult, utils::shutdown::shutdown_signal};
use tokio::sync::broadcast;
pub static SIGNAL_MANAGER: LazyLock<SignalManager> = LazyLock::new(SignalManager::new);
pub struct SignalManager {
sender: broadcast::Sender<()>,
}
impl SignalManager {
pub fn new() -> Self {
let (sender, _) = broadcast::channel(1);
SignalManager { sender }
}
pub fn subscribe(&self) -> broadcast::Receiver<()> {
self.sender.subscribe()
}
}
impl Initialize for SignalManager {
async fn initialize() -> BichonResult<()> {
tokio::spawn({
async move {
shutdown_signal().await;
println!("\nSending shutdown signal...");
let _ = SIGNAL_MANAGER.sender.send(());
}
});
Ok(())
}
}