Merge branch 'main' into feat/envelope-endpoint-and-api-improvements

This commit is contained in:
rustmailer
2025-12-30 22:32:11 +08:00
committed by GitHub
217 changed files with 26670 additions and 3785 deletions

View File

@@ -34,6 +34,22 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify Cargo.toml version matches git tag
shell: bash
run: |
TAG_VERSION="${GITHUB_REF_NAME}"
CARGO_VERSION=$(grep '^version' Cargo.toml | head -n1 | cut -d '"' -f2)
echo "Git tag version: $TAG_VERSION"
echo "Cargo.toml version: $CARGO_VERSION"
if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then
echo "::error::Version mismatch! Git tag ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)"
exit 1
fi
- name: Install Rust
uses: actions-rs/toolchain@v1
with:

2
Cargo.lock generated
View File

@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
version = "0.1.4"
version = "0.2.1"
dependencies = [
"ahash",
"async-imap",

View File

@@ -1,6 +1,6 @@
[package]
name = "bichon"
version = "0.1.4"
version = "0.2.1"
edition = "2021"
[[bin]]

176
README.md
View File

@@ -52,49 +52,20 @@ Built in Rust, it requires no external dependencies and provides fast, efficient
## 🚀 Features
### ⚡ Lightweight & Standalone
- Pure Rust, single-machine application.
- No external database required.
- Includes **WebUI** for intuitive management.
* **Lightweight & Standalone** — Pure Rust, no external database, with built-in WebUI
* **Multi-Account Sync** — Download and manage emails from multiple accounts
* **Flexible Fetching** — Sync by date range, email count, or specific mailboxes
* **IMAP & OAuth2 Auth** — Password or OAuth2 login with automatic token refresh
* **Proxy & Auto Config** — Supports network proxies and automatic IMAP discovery
* **Unified Search** — Search across all accounts by sender, subject, body, date, size, attachments, and more
* **Tags & Facets** — Organize emails using Tantivy facet-based tags
* **Compressed Storage** — Transparent compression and deduplication for efficient storage
* **Email Management** — Browse, view threads, bulk clean up, export EML or attachments
* **Dashboard & Analytics** — Visual insights into email volume, trends, and top senders
* **Internationalized WebUI** — Frontend available in 18 languages
* **OpenAPI Access** — OpenAPI docs with access-token authentication
* **Multi-User & Role-Based Access Control (RBAC)** — Supports multiple users with fine-grained, role-based permissions
### 📬 Multi-Account Management
- Synchronize and download emails from multiple accounts.
- Flexible selection: by **date range**, **number of emails**, or **specific mailboxes**.
### 🔑 IMAP & OAuth2 Authentication
- Supports **IMAP password** or **OAuth2** login.
- Built-in WebUI for **OAuth2 authorization**, including **automatic token refresh** (e.g., Gmail, Outlook).
- Supports **network proxy** for IMAP and OAuth2.
- Automatic IMAP server discovery and configuration.
### 🔍 Unified Multi-Account Search
- Powerful search across all accounts:
**account**, **mailbox**, **sender**, **attachment name**, **has attachments**, **size**, **date**, **subject**, **body**.
### 🏷️ Tags & Facets
- Organize archived emails using **tags** backed by Tantivy **facets**.
- Efficiently filter and locate emails based on these facet-based tags.
### 💾 Compressed & Deduplicated Storage
- Store emails efficiently with **transparent compression** and **deduplication**—emails can be read directly without any extra steps.
### 📂 Email Management & Viewing
- Bulk cleanup of local archives.
- Download emails as **EML** or **attachments separately**.
- View and browse emails directly.
- View the full **conversation thread** of any email.
### 📊 Dashboard & Analytics
- Visualize email statistics: **counts**, **time distribution**, **top senders**, **largest emails**, **account rankings**.
### 🌐 Internationalization (i18n)
* WebUI fully supports **17 languages** for all interface elements.
* Backend responses (e.g., system messages, API data) are **not yet internationalized**.
* Frontend is ready to support more languages in the future with minimal effort.
### 🛠️ OpenAPI Support
- Provides **OpenAPI documentation**.
- **Access token authentication** for programmatic access.
## 🐾 Why Create Bichon?
@@ -285,79 +256,53 @@ Extract and run:
* If you are accessing Bichon from a proxy domain **mydomain** argument --bichon-cors-origins="https://mydomain" is required.
## Setting the Bichon Encryption Password
## 🔐 Setting the Bichon Encryption Password
Bichon uses an encryption password to secure sensitive data. **You must set it before first use**, when no data exists.
Please refer to the following documentation for detailed instructions on how to set the Bichon encryption password:
Once set, it **cannot be changed**. Changing it later will make all encrypted data unreadable. To start over, you would need to **reinitialize Bichon and clear all emails and metadata**.
👉 [https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password](https://github.com/rustmailer/bichon/wiki/Setting-the-Bichon-Encryption-Password)
### How to Set the Password
All configuration methods, including command-line options, environment variables, and password file support (v0.2.0+), are documented there.
You can set the password **via command-line or environment variable**:
## 🔑 User Authentication & Admin Account
### Command-Line
Starting from **Bichon v0.2.0**, the authentication model has been updated.
```bash
bichon --bichon-encrypt-password "your-strong-password"
```
### Built-in Admin User (v0.2.0+)
### Environment Variable
* Bichon no longer uses the legacy single-account `root / root` login.
* The system now ships with a built-in **admin** user by default.
* **Default credentials:**
```bash
export BICHON_ENCRYPT_PASSWORD="your-strong-password"
bichon
```
* **Username:** `admin`
* **Password:** `admin@bichon`
**Tip:** Use a strong, secure password and keep it safe, as it cannot be changed later.
> The legacy `root` account and the `root / root` default credentials **no longer exist**.
## 🔑 Root User Login Information
**Bichon currently supports a single Root user login for system access and management.**
### Mandatory Access Token Authentication
### First Login and Enabling Access
* From **v0.2.0 onward**, **access-tokenbased authentication is always enabled**.
* The startup flag and environment variable
`--bichon-enable-access-token` / `BICHON_ENABLE_ACCESS_TOKEN`
are **deprecated and no longer used**.
* No additional configuration is required to enable authentication.
To enable the login feature, you must specify a command-line argument or set an environment variable when starting Bichon.
#### 1\. Command-Line Argument
### Managing Account Information
Add the `--bichon-enable-access-token` flag to your startup command:
After logging in, the admin user can manage their profile directly in the WebUI:
```bash
# Linux/macOS Binary Deployment Example
./bichon --bichon-root-dir /tmp/bichon-data --bichon-enable-access-token
```
1. Log in to the WebUI using the default admin credentials.
2. Navigate to **Settings → Profile**.
3. Update:
#### 2\. Environment Variable (Recommended for Docker)
Set the environment variable `BICHON_ENABLE_ACCESS_TOKEN` to `true`:
```bash
# Docker Deployment Example
docker run -d \
--name bichon \
-p 15630:15630 \
-v $(pwd)/bichon-data:/data \
-e BICHON_LOG_LEVEL=info \
-e BICHON_ROOT_DIR=/data \
-e BICHON_ENABLE_ACCESS_TOKEN=true \
rustmailer/bichon:latest
```
### Default Credentials
* **Initial Login Account:** `root`
* **Initial Password:** `root`
### Changing the Password
**It is strongly recommended that you change the default password immediately after your first login.**
You can change the password via the WebUI:
1. Log in to the WebUI.
2. Navigate to the **Settings** page.
3. Use the **Reset Root Password** option to modify your password.
* Username
* Password
* Avatar and other profile information
⚠️ **Security Notice:**
For security reasons, you should **change the default admin password immediately after the first login**.
## 📖 Documentation
@@ -386,6 +331,40 @@ A special thank you to **[@rallisf1](https://github.com/rallisf1)** for sharing
This data is provided solely as a **reference** for real-world usage. We encourage more users to share their Bichon usage screenshots and metrics (e.g., ingestion volume, compression ratio, search speed, etc.) to help the community conduct a more comprehensive assessment of Bichon's suitability and performance.
---
## Roadmap
- ✓ Multi-user support with account/password login
- System-level roles (admin / user)
- Per-mail-account permissions
* [ ] `bichon-cli` command-line tool
* Import emails from `eml`, `mbox`, `msg`, `pst`
* [ ] Manual sync controls
* Sync on demand
* Sync a single folder
* Verify completeness by comparing with the mail server
* [ ] Post-sync server cleanup
* Clean up server-side emails after successful sync
* Free up mailbox space (e.g. Gmail)
* [ ] Email export
* Export by folder
* Export by entire account
* [ ] Account-to-account email sync
* Sync emails to a specified target account
* Support mailbox migration
---
## 🛠️ Tech Stack
@@ -449,9 +428,12 @@ cargo build
Or run directly:
```bash
export BICHON_ENCRYPT_PASSWORD=dummy-password-for-testing
cargo run -- --bichon-root-dir e:\bichon-data
```
`--bichon-root-dir` specifies the directory where **all Bichon data** will be stored.
`BICHON_ENCRYPT_PASSWORD` is the password used to encrypt the sensitive data (see `cargo run -- --help` for alternative ways to specify this).
### WebUI Access

View File

@@ -16,7 +16,6 @@
// 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 mimalloc::MiMalloc;
use modules::{
common::rustls::RustMailerTls,
@@ -25,11 +24,12 @@ use modules::{
logger,
rest::start_http_server,
tasks::PeriodicTasks,
token::root::ensure_root_token,
};
use tracing::info;
use crate::modules::{common::signal::SignalManager, settings::dir::DataDirManager};
use crate::modules::{
common::signal::SignalManager, settings::dir::DataDirManager, users::manager::UserManager,
};
mod modules;
@@ -68,7 +68,7 @@ async fn initialize() -> BichonResult<()> {
// SETTINGS.validate()?;
SignalManager::initialize().await?;
DataDirManager::initialize().await?;
ensure_root_token().await?;
UserManager::initialize().await?;
RustMailerTls::initialize().await?;
EmailClientExecutors::initialize().await?;
PeriodicTasks::start_background_tasks();

View File

@@ -0,0 +1,160 @@
//
// 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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
database::{manager::DB_MANAGER, with_transaction},
error::{code::ErrorCode, BichonResult},
users::{
permissions::Permission,
role::{RoleType, UserRole},
UserModel,
},
},
raise_error, utc_now,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct BatchAccountRoleRequest {
pub account_ids: Vec<u64>,
pub user_ids: Vec<u64>,
pub role_id: u64,
}
impl BatchAccountRoleRequest {
pub async fn validate_existence(&self) -> BichonResult<()> {
let role = UserRole::find(self.role_id).await?.ok_or_else(|| {
raise_error!(
format!("Role ID {} not found", self.role_id),
ErrorCode::ResourceNotFound
)
})?;
if !matches!(role.role_type, RoleType::Account) {
return Err(raise_error!(
"Only Account roles can be assigned to individual account".into(),
ErrorCode::InvalidParameter
));
}
for id in &self.account_ids {
let exists = AccountModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("Account ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
for id in &self.user_ids {
let exists = UserModel::find(*id).await?; // Assuming an exists helper
if exists.is_none() {
return Err(raise_error!(
format!("User ID {} not found", id),
ErrorCode::ResourceNotFound
));
}
}
Ok(())
}
async fn grant_batch_account_access(
account_ids: Vec<u64>,
user_ids: Vec<u64>,
role_id: u64,
) -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
for &uid in &user_ids {
// Fetch the current user record from the database
let user = rw
.get()
.primary::<UserModel>(uid)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", uid),
ErrorCode::ResourceNotFound
)
})?;
let mut updated_user = user.clone();
// Apply the role to each specified account_id
for &aid in &account_ids {
updated_user.account_access_map.insert(aid, role_id);
}
updated_user.updated_at = utc_now!();
// Save the updated user back to the database within the transaction
rw.update(user, updated_user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await
}
pub async fn do_assign(self, context: &ClientContext) -> BichonResult<()> {
for account_id in &self.account_ids {
// Get the user's specific access for this account
let assigned_role_id =
context
.user
.account_access_map
.get(account_id)
.ok_or_else(|| {
raise_error!(
format!("No access to account {}", account_id),
ErrorCode::Forbidden
)
})?;
// Fetch the role definition from the database
let user_scoped_role = UserRole::find(*assigned_role_id).await?.ok_or_else(|| {
raise_error!(
"Assigned account role no longer exists".into(),
ErrorCode::InternalError
)
})?;
// Critical Check: Does this role grant management/sharing rights?
if !user_scoped_role
.permissions
.contains(Permission::ACCOUNT_MANAGE)
{
return Err(raise_error!(
format!("Your role on account {} does not allow sharing", account_id),
ErrorCode::Forbidden
));
}
// Optional: Ensure manager isn't giving away perms they don't have
// This is where you'd compare target_role.permissions vs manager's perms
}
Self::grant_batch_account_access(self.account_ids, self.user_ids, self.role_id).await
}
}

View File

@@ -27,11 +27,16 @@ use tracing::info;
use crate::{
encrypt,
modules::{
account::{entity::ImapConfig, since::DateSince, state::AccountRunningState},
account::{
entity::ImapConfig,
since::{DateSince, RelativeDate},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
database::{insert_impl, list_all_impl},
database::{list_all_impl, with_transaction},
error::BichonResult,
indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER},
users::{role::DEFAULT_ACCOUNT_MANAGER_ROLE_ID, UserModel, DEFAULT_ADMIN_USER_ID},
},
utc_now,
};
@@ -52,10 +57,9 @@ use crate::modules::database::{
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 = AccountV2;
pub type AccountModel = AccountV3;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Enum)]
pub enum AccountType {
@@ -121,8 +125,42 @@ impl AccountV2 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
}
pub fn new(request: AccountCreateRequest) -> BichonResult<Self> {
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
#[native_model(id = 4, version = 3, from = AccountV2)]
#[native_db(primary_key(pk -> String))]
pub struct AccountV3 {
#[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 date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountV3 {
fn pk(&self) -> String {
format!("{}_{}", self.created_at, self.id)
}
pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult<Self> {
Ok(Self {
id: id!(64),
email: request.email,
@@ -141,12 +179,15 @@ impl AccountV2 {
folder_limit: request.folder_limit,
use_dangerous: request.use_dangerous,
pgp_key: request.pgp_key,
created_by: user_id,
sync_batch_size: request.sync_batch_size,
date_before: request.date_before,
})
}
pub async fn check_account_exists(account_id: u64) -> BichonResult<AccountModel> {
let account =
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id, account_id)
.await?
.ok_or_else(|| {
raise_error!(
@@ -176,24 +217,53 @@ impl AccountV2 {
}
pub async fn find(account_id: u64) -> BichonResult<Option<AccountModel>> {
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
secondary_find_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::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
}
// /// 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) {
pub async fn create_account(
user_id: u64,
request: AccountCreateRequest,
) -> BichonResult<AccountModel> {
let entity = request.create_entity(user_id)?;
let cloned = entity.clone();
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let account_id = entity.id;
rw.insert::<AccountModel>(entity)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let user = rw
.get()
.primary::<UserModel>(user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found.", user_id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = user.clone();
updated
.account_access_map
.insert(account_id, DEFAULT_ACCOUNT_MANAGER_ROLE_ID);
updated.updated_at = utc_now!();
rw.update(user, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
if matches!(cloned.account_type, AccountType::IMAP) {
SYNC_CONTROLLER
.trigger_start(entity.id, entity.email.clone())
.trigger_start(cloned.id, cloned.email.clone())
.await;
}
Ok(entity)
Ok(cloned)
}
pub async fn update(
@@ -230,7 +300,7 @@ impl AccountV2 {
async fn delete_account(account_id: u64) -> BichonResult<()> {
delete_impl(DB_MANAGER.meta_db(), move|rw|{
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::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
}
@@ -242,7 +312,7 @@ impl AccountV2 {
MAIL_CONTEXT.clean_account(account.id).await?;
}
OAuth2AccessToken::try_delete(account.id).await?;
AccessToken::cleanup_account(account.id).await?;
UserModel::cleanup_account(account.id).await?;
MailBox::clean(account.id).await?;
ENVELOPE_INDEX_MANAGER
.delete_account_envelopes(account.id)
@@ -260,7 +330,7 @@ impl AccountV2 {
sync_folders: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::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();
@@ -275,7 +345,7 @@ impl AccountV2 {
known_folders: BTreeSet<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::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();
@@ -290,7 +360,7 @@ impl AccountV2 {
capabilities: Vec<String>,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get().secondary::<AccountModel>(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
rw.get().secondary::<AccountModel>(AccountV3Key::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();
@@ -319,7 +389,7 @@ impl AccountV2 {
}
pub async fn count() -> BichonResult<usize> {
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV2Key::id)
count_by_unique_secondary_key_impl::<AccountModel>(DB_MANAGER.meta_db(), AccountV3Key::id)
.await
}
@@ -342,6 +412,12 @@ impl AccountV2 {
if let Some(date_since) = request.date_since {
new.date_since = Some(date_since);
new.date_before = None;
}
if let Some(date_before) = request.date_before {
new.date_before = Some(date_before);
new.date_since = None;
}
if let Some(folder_limit) = request.folder_limit {
@@ -377,6 +453,11 @@ impl AccountV2 {
if let Some(sync_interval_min) = &request.sync_interval_min {
new.sync_interval_min = Some(*sync_interval_min);
}
if let Some(sync_batch_size) = &request.sync_batch_size {
new.sync_batch_size = Some(*sync_batch_size);
}
if let Some(use_proxy) = request.use_proxy {
new.use_proxy = Some(use_proxy);
}
@@ -450,3 +531,54 @@ impl From<AccountV2> for AccountV1 {
}
}
}
impl From<AccountV3> for AccountV2 {
fn from(value: AccountV3) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
}
}
}
impl From<AccountV2> for AccountV3 {
fn from(value: AccountV2) -> Self {
Self {
id: value.id,
imap: value.imap,
enabled: value.enabled,
email: value.email,
name: value.name,
capabilities: value.capabilities,
date_since: value.date_since,
folder_limit: value.folder_limit,
sync_folders: value.sync_folders,
account_type: value.account_type,
sync_interval_min: value.sync_interval_min,
known_folders: value.known_folders,
created_at: value.created_at,
updated_at: value.updated_at,
created_by: DEFAULT_ADMIN_USER_ID,
use_proxy: value.use_proxy,
use_dangerous: value.use_dangerous,
pgp_key: value.pgp_key,
sync_batch_size: None,
date_before: None,
}
}
}

View File

@@ -16,10 +16,11 @@
// 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 grant;
pub mod migration;
pub mod payload;
pub mod since;
pub mod state;
pub mod migration;
pub mod view;

View File

@@ -16,14 +16,11 @@
// 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::account::since::{DateSince, RelativeDate};
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};
@@ -36,21 +33,37 @@ pub struct AccountCreateRequest {
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
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>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountCreateRequest {
pub fn create_entity(self) -> BichonResult<AccountModel> {
pub fn create_entity(self, user_id: u64) -> BichonResult<AccountModel> {
if self.date_before.is_some() && self.date_since.is_some() {
return Err(raise_error!(
"date_before and date_since are mutually exclusive; specify only one time boundary"
.into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
if let Some(date_before) = self.date_before.as_ref() {
date_before.validate_date()?;
}
match self.account_type {
AccountType::IMAP => {
match &self.imap {
@@ -71,7 +84,7 @@ impl AccountCreateRequest {
}
AccountType::NoSync => {}
}
Ok(AccountModel::new(self)?)
Ok(AccountModel::new(user_id, self)?)
}
fn validate_request(imap: &ImapConfig, email: &str) -> BichonResult<()> {
@@ -107,6 +120,7 @@ pub struct AccountUpdateRequest {
/// - First-time sync optimization for large accounts
/// - Reducing server load during resyncs
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
/// Max emails to sync for this folder.
/// If not set, sync all emails.
/// otherwise sync up to `n` most recent emails (min 10).
@@ -129,6 +143,8 @@ pub struct AccountUpdateRequest {
/// Incremental sync interval (seconds)
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option<i64>,
#[oai(validator(minimum(value = "30"), maximum(value = "200")))]
pub sync_batch_size: Option<u32>,
/// 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.
@@ -141,9 +157,22 @@ pub struct AccountUpdateRequest {
impl AccountUpdateRequest {
pub fn validate_update_request(&self, account: &AccountModel) -> BichonResult<()> {
if self.date_before.is_some() && self.date_since.is_some() {
return Err(raise_error!(
"date_before and date_since are mutually exclusive; specify only one time boundary"
.into(),
ErrorCode::InvalidParameter
));
}
if let Some(date_since) = self.date_since.as_ref() {
date_since.validate()?;
}
if let Some(date_before) = self.date_before.as_ref() {
date_before.validate_date()?;
}
if matches!(account.account_type, AccountType::IMAP) {
if let Some(mailboxes) = self.sync_folders.as_ref() {
if mailboxes.is_empty() {
@@ -167,11 +196,11 @@ pub struct MinimalAccount {
pub fn filter_accessible_accounts<'a>(
all_accounts: &'a [MinimalAccount],
allowed: &BTreeSet<AccountInfo>,
allowed: &Vec<u64>,
) -> Vec<MinimalAccount> {
all_accounts
.iter()
.filter(|acct| allowed.iter().any(|a| a.id == acct.id))
.filter(|acct| allowed.contains(&acct.id))
.cloned()
.collect()
}

View File

@@ -16,7 +16,6 @@
// 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,

View File

@@ -0,0 +1,91 @@
//
// 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, HashMap};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
account::{
entity::ImapConfig,
migration::{AccountModel, AccountType},
since::{DateSince, RelativeDate},
},
users::UserModel,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct AccountResp {
pub id: u64,
pub imap: Option<ImapConfig>,
pub enabled: bool,
pub email: String,
pub name: Option<String>,
pub capabilities: Option<Vec<String>>,
pub date_since: Option<DateSince>,
pub date_before: Option<RelativeDate>,
pub folder_limit: Option<u32>,
pub sync_folders: Option<Vec<String>>,
pub account_type: AccountType,
pub sync_interval_min: Option<i64>,
pub sync_batch_size: Option<u32>,
pub known_folders: Option<BTreeSet<String>>,
pub created_at: i64,
pub updated_at: i64,
pub created_by: u64, //user id
pub created_user_name: String,
pub created_user_email: String,
pub use_proxy: Option<u64>,
pub use_dangerous: bool,
pub pgp_key: Option<String>,
}
impl AccountResp {
pub fn from_model(account: AccountModel, user_map: &HashMap<u64, UserModel>) -> AccountResp {
let user = user_map.get(&account.created_by);
AccountResp {
id: account.id,
imap: account.imap,
enabled: account.enabled,
email: account.email,
name: account.name,
capabilities: account.capabilities,
date_since: account.date_since,
date_before: account.date_before,
folder_limit: account.folder_limit,
sync_folders: account.sync_folders,
account_type: account.account_type,
sync_interval_min: account.sync_interval_min,
sync_batch_size: account.sync_batch_size,
known_folders: account.known_folders,
created_at: account.created_at,
updated_at: account.updated_at,
created_by: account.created_by,
created_user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
created_user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
use_proxy: account.use_proxy,
use_dangerous: account.use_dangerous,
pgp_key: account.pgp_key,
}
}
}

View File

@@ -16,7 +16,6 @@
// 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::{migration::AccountModel, state::AccountRunningState},
@@ -24,7 +23,7 @@ use crate::{
imap::{
find_intersecting_mailboxes, find_missing_mailboxes,
mailbox::MailBox,
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_since_date},
sync::rebuild::{rebuild_mailbox_cache, rebuild_mailbox_cache_by_date},
},
SEMAPHORE,
},
@@ -37,17 +36,30 @@ use crate::{
use std::time::Instant;
use tracing::{debug, error, info, warn};
pub const BATCH_SIZE: u32 = 50;
pub const DEFAULT_BATCH_SIZE: u32 = 50;
pub async fn fetch_and_save_since_date(
#[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,
) -> BichonResult<usize> {
let account_id = account.id;
let executor = MAIL_CONTEXT.imap(account_id).await?;
let search_criteria = match direction {
FetchDirection::Since => format!("SINCE {date}"),
FetchDirection::Before => format!("BEFORE {date}"),
};
let uid_list = executor
.uid_search(&mailbox.encoded_name(), format!("SINCE {date}").as_str())
.uid_search(&mailbox.encoded_name(), &search_criteria)
.await?;
let len = uid_list.len();
@@ -63,13 +75,23 @@ pub async fn fetch_and_save_since_date(
if let Some(limit) = folder_limit {
let limit = limit.max(100) as usize;
if len > limit {
uid_vec = uid_vec.split_off(len - limit as usize);
uid_vec = match direction {
FetchDirection::Since => uid_vec.split_off(len - limit),
FetchDirection::Before => {
uid_vec.truncate(limit);
uid_vec
}
};
}
}
// let semaphore = Arc::new(Semaphore::new(5));
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
AccountRunningState::set_initial_current_syncing_folder(
account_id,
mailbox.name.clone(),
@@ -105,9 +127,11 @@ pub async fn fetch_and_save_full_mailbox(
_ => total,
};
let page_size = if let Some(limit) = folder_limit {
limit.max(100).min(BATCH_SIZE as u32)
limit
.max(100)
.min(account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE))
} else {
BATCH_SIZE as u32
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE)
};
let total_batches = total_to_fetch.div_ceil(page_size);
@@ -251,17 +275,30 @@ pub async fn reconcile_mailboxes(
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
date_since,
&date_since.since_date()?,
remote_mailbox,
FetchDirection::Since,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?;
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
account,
local_mailbox.id,
&r.calculate_date()?,
remote_mailbox,
FetchDirection::Before,
)
.await?;
}
None => {
rebuild_mailbox_cache(account, local_mailbox, remote_mailbox).await?
}
},
}
} else {
perform_incremental_sync(account, local_mailbox, remote_mailbox).await?;
@@ -305,14 +342,31 @@ pub async fn reconcile_mailboxes(
let _permit = permit;
match &account.date_since {
Some(date_since) => {
rebuild_mailbox_cache_since_date(
&account, mailbox.id, date_since, &mailbox,
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&date_since.since_date()?,
&mailbox,
FetchDirection::Since,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox).await
}
None => match &account.date_before {
Some(r) => {
rebuild_mailbox_cache_by_date(
&account,
mailbox.id,
&r.calculate_date()?,
&mailbox,
FetchDirection::Before,
)
.await
}
None => {
rebuild_mailbox_cache(&account, &mailbox, &mailbox)
.await
}
},
}
});
handles.push(handle);
@@ -348,8 +402,14 @@ async fn perform_incremental_sync(
match local_max_uid {
Some(max_uid) => {
let executor = MAIL_CONTEXT.imap(account.id).await?;
let before_date = account
.date_before
.as_ref()
.map(|r| r.calculate_date())
.transpose()?;
executor
.fetch_new_mail(account.id, local_mailbox, max_uid + 1)
.fetch_new_mail(account, local_mailbox, max_uid + 1, before_date.as_deref())
.await?;
}
None => {
@@ -359,10 +419,11 @@ async fn perform_incremental_sync(
match &account.date_since {
Some(date_since) => {
fetch_and_save_since_date(
fetch_and_save_by_date(
account,
date_since.since_date()?.as_str(),
remote_mailbox,
FetchDirection::Since,
)
.await?;
}

View File

@@ -23,13 +23,13 @@ use crate::{
migration::{AccountModel, AccountType},
state::AccountRunningState,
},
cache::imap::mailbox::MailBox,
cache::imap::{mailbox::MailBox, sync::flow::FetchDirection},
error::BichonResult,
},
utc_now,
};
use flow::reconcile_mailboxes;
use rebuild::{rebuild_cache, rebuild_cache_since_date};
use rebuild::{rebuild_cache, rebuild_cache_by_date};
use std::time::Instant;
use sync_folders::get_sync_folders;
use sync_type::{determine_sync_type, SyncType};
@@ -54,9 +54,26 @@ pub async fn execute_imap_sync(account: &AccountModel) -> BichonResult<()> {
// AccountRunningState::set_initial_sync_start(account_id).await?;
let result = match &account.date_since {
Some(date_since) => {
rebuild_cache_since_date(account, &remote_mailboxes, date_since).await
rebuild_cache_by_date(
account,
&remote_mailboxes,
&date_since.since_date()?,
FetchDirection::Since,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes).await,
None => match &account.date_before {
Some(r) => {
rebuild_cache_by_date(
account,
&remote_mailboxes,
&r.calculate_date()?,
FetchDirection::Before,
)
.await
}
None => rebuild_cache(account, &remote_mailboxes).await,
},
};
match result {
Ok(_) => {

View File

@@ -16,14 +16,13 @@
// 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::{migration::AccountModel, since::DateSince},
account::migration::AccountModel,
cache::{
imap::{
mailbox::MailBox,
sync::flow::{fetch_and_save_full_mailbox, fetch_and_save_since_date},
sync::flow::{fetch_and_save_by_date, fetch_and_save_full_mailbox, FetchDirection},
},
SEMAPHORE,
},
@@ -86,14 +85,14 @@ pub async fn rebuild_cache(
Ok(())
}
pub async fn rebuild_cache_since_date(
pub async fn rebuild_cache_by_date(
account: &AccountModel,
remote_mailboxes: &[MailBox],
date_since: &DateSince,
date: &str,
direction: FetchDirection,
) -> BichonResult<()> {
let start_time = Instant::now();
let mut total_inserted = 0;
let date = date_since.since_date()?;
MailBox::batch_insert(remote_mailboxes).await?;
let mut handles = Vec::new();
@@ -107,13 +106,14 @@ pub async fn rebuild_cache_since_date(
}
let account = account.clone();
let mailbox = mailbox.clone();
let date = date.clone();
let date = date.to_string();
let direction = direction.clone();
match SEMAPHORE.clone().acquire_owned().await {
Ok(permit) => {
let handle: tokio::task::JoinHandle<Result<usize, BichonError>> =
tokio::spawn(async move {
let _permit = permit; // Ensure permit is released when task finishes
fetch_and_save_since_date(&account, date.as_str(), &mailbox).await
fetch_and_save_by_date(&account, date.as_str(), &mailbox, direction).await
});
handles.push(handle);
}
@@ -132,10 +132,14 @@ pub async fn rebuild_cache_since_date(
}
}
let elapsed_time = start_time.elapsed().as_secs();
let direction_desc = match direction {
FetchDirection::Since => "starting from the specified date",
FetchDirection::Before => "ending before the specified date",
};
info!(
"Rebuild account cache completed: {} envelopes inserted. {} secs elapsed. \
Data fetched from server starting from the specified date: {}.",
total_inserted, elapsed_time, date
Data fetched from server {}: {}.",
total_inserted, elapsed_time, direction_desc, date
);
Ok(())
}
@@ -169,11 +173,12 @@ pub async fn rebuild_mailbox_cache(
Ok(())
}
pub async fn rebuild_mailbox_cache_since_date(
pub async fn rebuild_mailbox_cache_by_date(
account: &AccountModel,
local_mailbox_id: u64,
date_since: &DateSince,
date: &str,
remote: &MailBox,
direction: FetchDirection,
) -> BichonResult<()> {
ENVELOPE_INDEX_MANAGER
.delete_mailbox_envelopes(account.id, vec![local_mailbox_id])
@@ -190,8 +195,7 @@ pub async fn rebuild_mailbox_cache_since_date(
return Ok(()); // Skip if the mailbox has no emails
}
let count =
fetch_and_save_since_date(account, date_since.since_date()?.as_str(), remote).await?;
let count = fetch_and_save_by_date(account, date, remote, direction).await?;
info!(
"Account {}: Successfully rebuild mailbox cache, inserted {} envelopes for mailbox '{}'.",
account.id, count, &remote.name

View File

@@ -16,12 +16,11 @@
// 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},
settings::{cli::SETTINGS, system::SystemSetting},
token::{root::ROOT_TOKEN, AccessToken, AccountInfo},
token::AccessTokenModel,
users::{permissions::Permission, role::UserRole, UserModel},
utils::rate_limit::RATE_LIMITER_MANAGER,
},
raise_error,
@@ -35,7 +34,11 @@ use poem::{
Endpoint, FromRequest, Middleware, Request, RequestBody, Result,
};
use serde::Deserialize;
use std::{collections::BTreeSet, net::IpAddr, sync::Arc};
use std::{
collections::{BTreeSet, HashSet},
net::IpAddr,
sync::Arc,
};
use super::create_api_error_response;
@@ -68,62 +71,101 @@ impl<E: Endpoint> Endpoint for ApiGuardEndpoint<E> {
}
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option<IpAddr>,
pub access_token: Option<AccessToken>,
pub is_root: bool,
pub user: UserModel,
}
impl ClientContext {
pub fn require_root(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(())
} else {
Err(raise_error!(
"Root access required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_authorized(&self) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root || self.access_token.is_some() {
Ok(())
} else {
Err(raise_error!(
"Authorization required".into(),
ErrorCode::PermissionDenied
))
}
}
pub fn require_account_access(&self, account_id: u64) -> BichonResult<()> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
return Ok(());
}
match &self.access_token {
Some(token) if token.can_access_account(account_id) => Ok(()),
_ => Err(raise_error!(format!(
"You do not have permission to access the requested email account (ID: {}). Please check your access rights or contact the administrator.",
account_id
), ErrorCode::PermissionDenied)),
}
}
pub fn accessible_accounts(&self) -> BichonResult<Option<&BTreeSet<AccountInfo>>> {
if !SETTINGS.bichon_enable_access_token || self.is_root {
Ok(None) // All accounts are accessible
} else {
match &self.access_token {
Some(token) => Ok(Some(&token.accounts)),
None => Err(raise_error!(
"Missing access token".into(),
ErrorCode::PermissionDenied
)),
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 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(&self, 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(&self, 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
))
}
}
}
@@ -134,98 +176,76 @@ impl<'a> FromRequest<'a> for ClientContext {
}
pub async fn extract_client_context(req: &Request) -> Result<ClientContext> {
if SETTINGS.bichon_enable_access_token {
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let ip_addr = RealIp::from_request_without_body(req)
.await
.map_err(|_| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?
.0
.ok_or_else(|| {
create_api_error_response(
"Failed to parse client IP address",
ErrorCode::InvalidParameter,
)
})?;
// Extract access token from Bearer header or query params
let bearer = req
.headers()
.typed_get::<Authorization<Bearer>>()
.map(|auth| auth.0.token().to_string())
.or_else(|| req.params::<Param>().ok().map(|param| param.access_token));
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
let token = bearer.ok_or_else(|| {
create_api_error_response("Valid access token not found", ErrorCode::PermissionDenied)
})?;
// Validate and update access token
let user = AccessTokenModel::resolve_user_from_token(&token)
.await
.map_err(|e| {
create_api_error_response(&format!("{:#?}", e), ErrorCode::PermissionDenied)
})?;
// Check for root token
if let Ok(Some(root)) = SystemSetting::get(ROOT_TOKEN) {
if root.value == token {
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: None,
is_root: true,
});
}
}
// Validate and update access token
let validated_token = AccessToken::try_update_access_timestamp(&token)
.await
.map_err(|_| {
create_api_error_response("Invalid access token", ErrorCode::PermissionDenied)
})?;
return Ok(ClientContext {
ip_addr: Some(ip_addr),
access_token: Some(validated_token),
is_root: false,
});
}
Ok(Default::default())
return Ok(ClientContext {
ip_addr: Some(ip_addr),
user,
});
}
pub async fn authorize_access(req: &Request) -> Result<ClientContext, poem::Error> {
let context = extract_client_context(&req).await?;
context.require_authorized().map_err(|error| {
create_api_error_response(&error.to_string(), ErrorCode::PermissionDenied)
})?;
if let Some(access_token) = &context.access_token {
if let Some(access_control) = &access_token.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::PermissionDenied,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(&access_token.token, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
if let Some(access_control) = &context.user.acl {
if let Some(ip_addr) = context.ip_addr {
if let Some(whitelist) = &access_control.ip_whitelist {
if !whitelist.contains(&ip_addr.to_string()) {
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
&format!("IP {} not in whitelist", ip_addr),
ErrorCode::Forbidden,
));
}
}
}
if let Some(rate_limit) = &access_control.rate_limit {
if let Err(not_until) = RATE_LIMITER_MANAGER
.check(context.user.id, rate_limit.clone())
.await
{
let wait_duration = not_until.wait_time_from(QuantaClock::default().now());
return Err(create_api_error_response(
&format!(
"Rate limit: {}/{}s. Retry after {}s",
rate_limit.quota,
rate_limit.interval,
wait_duration.as_secs()
),
ErrorCode::TooManyRequest,
));
}
}
}
Ok(context)

View File

@@ -16,14 +16,17 @@
// 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::users::permissions::Permission;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use tantivy::{schema::Value, TantivyDocument};
use crate::{
bichon_version,
modules::{
account::migration::AccountModel,
common::auth::ClientContext,
error::{code::ErrorCode, BichonResult},
indexer::{manager::ENVELOPE_INDEX_MANAGER, schema::SchemaTools},
settings::dir::DATA_DIR_MANAGER,
@@ -50,17 +53,47 @@ pub struct DashboardStats {
}
impl DashboardStats {
pub async fn get() -> BichonResult<Self> {
let mut stat = ENVELOPE_INDEX_MANAGER.get_dashboard_stats().await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER.top_10_largest_emails().await?;
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails()?;
stat.account_count = AccountModel::count().await?;
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
pub async fn get(context: ClientContext) -> BichonResult<Self> {
let has_all_accounts = context
.has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
.await;
let authorized_ids: Option<HashSet<u64>> = if has_all_accounts {
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
let mut stat = ENVELOPE_INDEX_MANAGER
.get_dashboard_stats(&authorized_ids)
.await?;
stat.top_largest_emails = ENVELOPE_INDEX_MANAGER
.top_10_largest_emails(&authorized_ids)
.await?;
stat.account_count = if has_all_accounts {
AccountModel::count().await?
} else {
authorized_ids.as_ref().map(|ids| ids.len()).unwrap_or(0)
};
stat.email_count = ENVELOPE_INDEX_MANAGER.total_emails(&authorized_ids)?;
if has_all_accounts {
stat.storage_usage_bytes = get_total_size(&DATA_DIR_MANAGER.eml_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
stat.index_usage_bytes = get_total_size(&DATA_DIR_MANAGER.envelope_dir)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
} else {
stat.storage_usage_bytes = 0;
stat.index_usage_bytes = 0;
}
stat.system_version = bichon_version!().to_string();
stat.commit_hash = env!("GIT_HASH").to_string();
Ok(stat)
}
}

View File

@@ -21,6 +21,7 @@ use crate::modules::cache::imap::MAILBOX_MODELS;
use crate::modules::error::{code::ErrorCode, BichonError};
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::dir::DATA_DIR_MANAGER;
use crate::modules::users::UserModel;
use crate::modules::{database::META_MODELS, error::BichonResult};
use crate::raise_error;
use native_db::{Builder, Database};
@@ -73,6 +74,8 @@ impl DatabaseManager {
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<AccountModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.migrate::<UserModel>()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;

View File

@@ -16,7 +16,7 @@
// 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::migration::{AccountV1, AccountV2};
use crate::modules::account::migration::{AccountV1, AccountV2, AccountV3};
use crate::modules::autoconfig::CachedMailSettings;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
@@ -25,7 +25,9 @@ use crate::modules::oauth2::pending::OAuth2PendingEntity;
use crate::modules::oauth2::token::OAuth2AccessToken;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::system::SystemSetting;
use crate::modules::token::AccessToken;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::role::UserRole;
use crate::modules::users::{BichonUser, BichonUserV2};
use crate::raise_error;
use db_type::{KeyOptions, ToKeyDefinition};
use itertools::Itertools;
@@ -58,15 +60,21 @@ impl ModelsAdapter {
}
pub fn register_metadata_models(&mut self) {
self.register_model::<AccessToken>();
//Starting from version 0.2.0, `AccessToken` is deprecated/no longer used, but its ID must not be reused, otherwise it may cause model errors.
//self.register_model::<AccessToken>();
self.register_model::<SystemSetting>();
self.register_model::<CachedMailSettings>();
self.register_model::<AccountV1>();
self.register_model::<AccountV2>();
self.register_model::<AccountV3>();
self.register_model::<OAuth2>();
self.register_model::<OAuth2PendingEntity>();
self.register_model::<OAuth2AccessToken>();
self.register_model::<Proxy>();
self.register_model::<UserRole>();
self.register_model::<BichonUser>();
self.register_model::<BichonUserV2>();
self.register_model::<AccessTokenModel>();
}
}
@@ -170,11 +178,11 @@ pub async fn update_impl<T: ToInput + Clone + std::fmt::Debug + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let current_item = current(&rw)?;
let updated_item = updated(&current_item)?;
rw.update(current_item.clone(), updated_item)
rw.update(current_item, updated_item.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(current_item)
Ok(updated_item)
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
@@ -223,20 +231,20 @@ pub async fn async_find_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub fn find_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
key: &str,
) -> BichonResult<Option<T>> {
let db = database.clone();
let r_transaction = db
.r_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let entity: Option<T> = r_transaction
.get()
.primary(key)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(entity)
}
// pub fn find_impl<T: ToInput + Clone + Send + 'static>(
// database: &Arc<Database<'static>>,
// key: &str,
// ) -> BichonResult<Option<T>> {
// let db = database.clone();
// let r_transaction = db
// .r_transaction()
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// let entity: Option<T> = r_transaction
// .get()
// .primary(key)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(entity)
// }
pub async fn delete_impl<T: ToInput + Clone + Send + 'static>(
database: &Arc<Database<'static>>,
@@ -307,6 +315,25 @@ pub async fn list_all_impl<T: ToInput + Clone + Send + 'static>(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
pub async fn with_transaction(
database: &Arc<Database<'static>>,
f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
) -> BichonResult<()> {
let db: Arc<Database<'_>> = database.clone();
tokio::task::spawn_blocking(move || {
let rw_transaction = db
.rw_transaction()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
f(&rw_transaction)?;
rw_transaction
.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
// For tables with a creation timestamp, place the creation time at the front of the primary key.
// This allows sorting by time, as the data is stored in dictionary order based on the primary key.
// If reverse sorting by time is needed, the iterator can be reversed.

View File

@@ -17,13 +17,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::common::AddrVec;
use crate::modules::envelope::utils::normalize_subject;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::utils::create_hash;
use crate::{calculate_hash, raise_error, utc_now};
use crate::{id, modules::indexer::envelope::Envelope};
use async_imap::types::Fetch;
use mail_parser::{Message, MessageParser, MimeHeaders};
use mail_parser::{HeaderName, Message, MessageParser, MimeHeaders};
pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> BichonResult<Envelope> {
let internal_date = fetch
@@ -62,7 +63,13 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
@@ -114,6 +121,8 @@ pub fn extract_envelope(fetch: &Fetch, account_id: u64, mailbox_id: u64) -> Bich
thread_id,
attachments,
tags: None,
account_email: None,
mailbox_name: None,
};
Ok(envelope)
}
@@ -150,7 +159,12 @@ pub fn extract_envelope_from_eml(
let in_reply_to = message.in_reply_to().as_text().map(String::from);
let references = extract_references(&message);
let thread_id = compute_thread_id(in_reply_to, references, &message_id);
let subject = message.subject().map(String::from).unwrap_or("".into());
let mut subject = message.subject().map(String::from).unwrap_or_default();
if subject.contains('\u{FFFD}') {
subject = normalize_subject(message.header_raw(HeaderName::Subject));
}
let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0);
let bcc: Option<Vec<String>> = message.bcc().map(|addr| {
AddrVec::from(addr)
@@ -202,6 +216,8 @@ pub fn extract_envelope_from_eml(
thread_id,
attachments,
tags: None,
account_email: None,
mailbox_name: None,
};
Ok(envelope)
}

View File

@@ -16,5 +16,5 @@
// 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 extractor;
pub mod utils;

View File

@@ -0,0 +1,114 @@
use mail_parser::parsers::MessageStream;
use regex::{Captures, Regex};
fn merge_contiguous_encoded_words(input: &str) -> String {
let block_re =
Regex::new(r"(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=)(?:\s+(?:=\?[^?]+\?[bBqQ]\?[^?]+\?=))+")
.unwrap();
let word_re = Regex::new(r"=\?([^?]+)\?([bBqQ])\?([^?]+)\?=").unwrap();
block_re
.replace_all(input, |caps: &Captures| {
let whole = caps.get(0).unwrap().as_str();
let mut charset: Option<String> = None;
let mut encoding: Option<String> = None;
let mut combined = String::new();
let mut ok = true;
for cap in word_re.captures_iter(whole) {
let cs = &cap[1];
let enc = cap[2].to_ascii_uppercase();
let text = &cap[3];
if let Some(ref c) = charset {
if c != cs {
ok = false;
break;
}
} else {
charset = Some(cs.to_string());
}
if let Some(ref e) = encoding {
if e != &enc {
ok = false;
break;
}
} else {
encoding = Some(enc);
}
combined.push_str(text);
}
if ok {
format!(
"=?{}?{}?{}?=",
charset.unwrap(),
encoding.unwrap(),
combined
)
} else {
whole.to_string()
}
})
.to_string()
}
pub fn normalize_subject(raw_subject: Option<&str>) -> String {
let subject = match raw_subject {
Some(subject) => merge_contiguous_encoded_words(subject),
None => return String::new(),
};
MessageStream::new(subject.as_bytes())
.parse_unstructured()
.as_text()
.map(String::from)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use crate::modules::envelope::utils::merge_contiguous_encoded_words;
#[tokio::test]
async fn test3() {
let s = "Hello =?UTF-8?B?SGVsbG8=?= =?UTF-8?B?V29ybGQ=?= !!!";
assert_eq!(
merge_contiguous_encoded_words(s),
"Hello =?UTF-8?B?SGVsbG8=V29ybGQ=?= !!!"
);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= =?UTF-8?B?Qw==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==Qw==?="
);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?= test =?UTF-8?B?Qw==?= =?UTF-8?B?RA==?=";
assert_eq!(
merge_contiguous_encoded_words(s),
"=?UTF-8?B?QQ==Qg==?= test =?UTF-8?B?Qw==RA==?="
);
let s = "=?UTF-8?B?QQ==?= =?GBK?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?Q?Qg?=";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?b?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
let s = "Hello =?UTF-8?B?SGVsbG8=?= !!!";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?B?QQ==?= =?UTF-8?B?Qg==?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?B?QQ==Qg==?=");
let s = "Just a normal subject line";
assert_eq!(merge_contiguous_encoded_words(s), s);
let s = "=?UTF-8?Q?Hello_?= =?UTF-8?Q?World?=";
assert_eq!(merge_contiguous_encoded_words(s), "=?UTF-8?Q?Hello_World?=");
}
}

View File

@@ -16,7 +16,6 @@
// 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 poem::http::StatusCode;
use poem_openapi::Enum;
@@ -34,12 +33,14 @@ pub enum ErrorCode {
// Authentication and authorization errors (2000020999)
PermissionDenied = 20000,
AccountDisabled = 20010,
Forbidden = 20020,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (3000030999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
AlreadyExists = 30030,
// Network connection errors (4000040999)
NetworkError = 40000,
@@ -64,11 +65,14 @@ impl ErrorCode {
| ErrorCode::MissingConfiguration
| ErrorCode::Incompatible => StatusCode::BAD_REQUEST,
ErrorCode::PermissionDenied => StatusCode::UNAUTHORIZED,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled => StatusCode::FORBIDDEN,
ErrorCode::AccountDisabled | ErrorCode::OAuth2ItemDisabled | ErrorCode::Forbidden => {
StatusCode::FORBIDDEN
}
ErrorCode::ResourceNotFound => StatusCode::NOT_FOUND,
ErrorCode::RequestTimeout => StatusCode::REQUEST_TIMEOUT,
ErrorCode::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
ErrorCode::TooManyRequest => StatusCode::TOO_MANY_REQUESTS,
ErrorCode::AlreadyExists => StatusCode::CONFLICT,
ErrorCode::InternalError
| ErrorCode::AutoconfigFetchFailed
| ErrorCode::ImapCommandFailed

View File

@@ -16,9 +16,10 @@
// 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::migration::AccountModel;
use crate::modules::account::state::AccountRunningState;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, BATCH_SIZE};
use crate::modules::cache::imap::sync::flow::{generate_uid_sequence_hashset, DEFAULT_BATCH_SIZE};
use crate::modules::envelope::extractor::extract_envelope;
use crate::modules::error::code::ErrorCode;
use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
@@ -80,15 +81,22 @@ impl ImapExecutor {
pub async fn fetch_new_mail(
&self,
account_id: u64,
account: &AccountModel,
mailbox: &MailBox,
start_uid: u64,
before: Option<&str>
) -> BichonResult<()> {
assert!(start_uid > 0, "start_uid must be greater than 0");
let query = match before {
Some(date) => format!("UID {start_uid}:* BEFORE {date}"),
None => format!("UID {start_uid}:*"),
};
let uid_list = self
.uid_search(
&mailbox.encoded_name(),
format!("UID {start_uid}:*").as_str(),
&query,
)
.await?;
@@ -98,17 +106,21 @@ impl ImapExecutor {
}
info!(
"[account {}][mailbox {}] {} envelopes need to be fetched",
account_id, mailbox.name, len
account.id, mailbox.name, len
);
let mut uid_vec: Vec<u32> = uid_list.into_iter().collect();
uid_vec.sort();
let uid_batches = generate_uid_sequence_hashset(uid_vec, BATCH_SIZE as usize, false);
let uid_batches = generate_uid_sequence_hashset(
uid_vec,
account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize,
false,
);
let too_many = len as u32 > 10 * BATCH_SIZE;
let too_many = len as u32 > 5 * account.sync_batch_size.unwrap_or(DEFAULT_BATCH_SIZE);
if too_many {
AccountRunningState::set_initial_current_syncing_folder(
account_id,
account.id,
mailbox.name.clone(),
uid_batches.len() as u32,
)
@@ -118,13 +130,13 @@ impl ImapExecutor {
for (index, batch) in uid_batches.into_iter().enumerate() {
if too_many {
AccountRunningState::set_current_sync_batch_number(
account_id,
account.id,
mailbox.name.clone(),
(index + 1) as u32,
)
.await?;
}
self.uid_batch_retrieve_emails(account_id, mailbox.id, &batch, &mailbox.encoded_name())
self.uid_batch_retrieve_emails(account.id, mailbox.id, &batch, &mailbox.encoded_name())
.await?;
}
Ok(())

View File

@@ -78,7 +78,13 @@ impl ImapConnectionManager {
})?;
let password = decrypt!(&password)?;
client.login(&username, &password).await
client.login(&username, &password).await.map_err(|e| {
error!(
"IMAP password auth failed for username '{}': {}",
username, e
);
e
})
}
AuthType::OAuth2 => {
let record = OAuth2AccessToken::get(self.account_id).await?;
@@ -90,8 +96,12 @@ impl ImapConnectionManager {
)
})?;
client
.authenticate(OAuth2::new(username, access_token))
.authenticate(OAuth2::new(username.clone(), access_token))
.await
.map_err(|e| {
error!("IMAP OAuth2 auth failed for username '{}': {}", username, e);
e
})
}
}
}

View File

@@ -16,7 +16,6 @@
// 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;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};

View File

@@ -16,11 +16,13 @@
// 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 mail_parser::MessageParser;
use mail_parser::{parsers::MessageStream, HeaderName, MessageParser};
use crate::{
base64_encode_url_safe,
modules::{account::entity::Encryption, imap::client::Client},
modules::{
account::entity::Encryption, envelope::utils::normalize_subject, imap::client::Client,
},
};
#[tokio::test]
@@ -48,3 +50,72 @@ async fn test1() {
println!("{}", part.is_multipart());
}
}
#[tokio::test]
async fn test2() {
const MESSAGE: &str = r#"From: Art Vandelay <art@vandelay.com> (Vandelay Industries)
To: "Colleagues": "James Smythe" <james@vandelay.com>; Friends:
jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= <john@example.com>;
Date: Sat, 20 Nov 2021 14:22:01 -0800
Subject: =?utf-8?B?SnVzdCAxNSBkYXlzIGxlZnQgdG8gdmlzaXQgTkFSTklBISDinYTvuI/wn462?=
Content-Type: multipart/mixed; boundary="festivus";
--festivus
Content-Type: text/html; charset="us-ascii"
Content-Transfer-Encoding: base64
PGh0bWw+PHA+SSB3YXMgdGhpbmtpbmcgYWJvdXQgcXVpdHRpbmcgdGhlICZsZHF1bztle
HBvcnRpbmcmcmRxdW87IHRvIGZvY3VzIGp1c3Qgb24gdGhlICZsZHF1bztpbXBvcnRpbm
cmcmRxdW87LDwvcD48cD5idXQgdGhlbiBJIHRob3VnaHQsIHdoeSBub3QgZG8gYm90aD8
gJiN4MjYzQTs8L3A+PC9odG1sPg==
--festivus
Content-Type: message/rfc822
From: "Cosmo Kramer" <kramer@kramerica.com>
Subject: Exporting my book about coffee tables
Content-Type: multipart/mixed; boundary="giddyup";
--giddyup
Content-Type: text/plain; charset="utf-16"
Content-Transfer-Encoding: quoted-printable
=FF=FE=0C!5=D8"=DD5=D8)=DD5=D8-=DD =005=D8*=DD5=D8"=DD =005=D8"=
=DD5=D85=DD5=D8-=DD5=D8,=DD5=D8/=DD5=D81=DD =005=D8*=DD5=D86=DD =
=005=D8=1F=DD5=D8,=DD5=D8,=DD5=D8(=DD =005=D8-=DD5=D8)=DD5=D8"=
=DD5=D8=1E=DD5=D80=DD5=D8"=DD!=00
--giddyup
Content-Type: image/gif; name*1="about "; name*0="Book ";
name*2*=utf-8''%e2%98%95 tables.gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--giddyup--
--festivus--
"#;
let message = MessageParser::default().parse(MESSAGE).unwrap();
let raw_subject = message.header_raw("Subject").unwrap().as_bytes();
let data = MessageStream::new(raw_subject)
.parse_unstructured()
.unwrap_text()
.to_string();
println!("{}", data);
// RFC2047 support for encoded text in message readers
println!("{}", message.subject().unwrap());
}
#[tokio::test]
async fn test44() {
let path = r"C:\Users\polly\Downloads\test222.eml";
let input = std::fs::read(path).unwrap();
let message = MessageParser::default().parse(&input).unwrap();
let subject = message.subject().unwrap();
println!("Subject: {}", subject);
if subject.contains('\u{FFFD}') {
let subject = normalize_subject(message.header_raw(HeaderName::Subject));
println!("Subject: {}", subject);
}
}

View File

@@ -16,7 +16,8 @@
// 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::migration::AccountModel;
use crate::modules::cache::imap::mailbox::MailBox;
use crate::modules::error::code::ErrorCode;
use crate::modules::utils::create_hash;
use crate::modules::{error::BichonResult, indexer::schema::SchemaTools};
@@ -31,7 +32,9 @@ pub struct Envelope {
pub id: u64,
pub message_id: String,
pub account_id: u64,
pub account_email: Option<String>,
pub mailbox_id: u64,
pub mailbox_name: Option<String>,
pub uid: u32,
pub subject: String,
pub text: String,
@@ -169,11 +172,20 @@ impl Envelope {
})
.flatten()
.collect();
let account_email = AccountModel::find(account_id).await?.map(|a| a.email);
let mailboxes = MailBox::list_all(account_id).await?;
let mailbox_name = mailboxes
.iter()
.find(|m| m.id == mailbox_id)
.map(|m| m.name.clone());
let envelope = Envelope {
id,
account_id,
account_email,
mailbox_id,
mailbox_name,
message_id: extract_string_field(doc, fields.f_message_id)?,
uid: extract_u64_field(doc, fields.f_uid)? as u32,
subject: extract_string_field(doc, fields.f_subject)?,

View File

@@ -16,7 +16,6 @@
// 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},
ops::Bound,
@@ -35,8 +34,8 @@ use crate::{
indexer::{
envelope::Envelope,
fields::{
F_ACCOUNT_ID, F_FROM, F_HAS_ATTACHMENT, F_INTERNAL_DATE, F_MAILBOX_ID, F_SIZE,
F_TAGS, F_THREAD_ID, F_UID,
F_ACCOUNT_ID, F_DATE, F_FROM, F_HAS_ATTACHMENT, F_MAILBOX_ID, F_SIZE, F_TAGS,
F_THREAD_ID, F_UID,
},
schema::SchemaTools,
},
@@ -58,7 +57,7 @@ use tantivy::{
AggregationCollector, Key,
},
collector::{Count, FacetCollector, TopDocs},
query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
query::{AllQuery, BooleanQuery, EmptyQuery, Occur, Query, QueryParser, RangeQuery, TermQuery},
schema::{Facet, IndexRecordOption, Value},
store::{Compressor, ZstdCompressor},
DocAddress, Index, IndexBuilder, IndexReader, IndexSettings, IndexWriter, Order,
@@ -194,9 +193,29 @@ impl EnvelopeIndexManager {
}
}
pub fn total_emails(&self) -> BichonResult<u64> {
pub fn total_emails(&self, accounts: &Option<HashSet<u64>>) -> BichonResult<u64> {
let searcher = self.create_searcher()?;
Ok(searcher.num_docs())
match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
let query = Box::new(BooleanQuery::new(subqueries)) as Box<dyn Query>;
let count = searcher
.search(&query, &Count)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(count as u64)
}
Some(_) => Ok(0),
None => Ok(searcher.num_docs()),
}
}
fn account_query(&self, account_id: u64) -> Box<TermQuery> {
@@ -223,12 +242,36 @@ impl EnvelopeIndexManager {
fn filter_query(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
parser: QueryParser,
) -> BichonResult<Box<dyn Query>> {
let f = SchemaTools::envelope_fields();
let mut subqueries: Vec<(Occur, Box<dyn Query>)> = Vec::new();
if let Some(authorized_ids) = accounts {
if authorized_ids.is_empty() {
let term = Term::from_field_u64(f.f_account_id, u64::MAX);
subqueries.push((
Occur::Must,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)),
));
} else {
let mut account_must_queries = Vec::new();
for id in authorized_ids {
let term = Term::from_field_u64(f.f_account_id, id);
account_must_queries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
subqueries.push((
Occur::Must,
Box::new(BooleanQuery::new(account_must_queries)),
));
}
}
if let Some(ref text) = filter.text {
let query = parser
.parse_query(text)
@@ -292,13 +335,13 @@ impl EnvelopeIndexManager {
}
let start_bound = if let Some(from) = filter.since {
Bound::Included(Term::from_field_i64(f.f_internal_date, from))
Bound::Included(Term::from_field_i64(f.f_date, from))
} else {
Bound::Unbounded
};
let end_bound = if let Some(to) = filter.before {
Bound::Included(Term::from_field_i64(f.f_internal_date, to))
Bound::Included(Term::from_field_i64(f.f_date, to))
} else {
Bound::Unbounded
};
@@ -426,14 +469,16 @@ impl EnvelopeIndexManager {
}
fn collect_facets_recursive(
query: &dyn Query,
searcher: &Searcher,
parent_facet: &str,
all_facets: &mut Vec<TagCount>,
) -> BichonResult<()> {
let mut facet_collector = FacetCollector::for_field(F_TAGS);
facet_collector.add_facet(parent_facet);
let facet_counts = searcher
.search(&AllQuery, &facet_collector)
.search(query, &facet_collector)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
for (facet, count) in facet_counts.get(parent_facet) {
@@ -441,16 +486,37 @@ impl EnvelopeIndexManager {
tag: facet.to_string(),
count,
});
Self::collect_facets_recursive(searcher, &facet.to_string(), all_facets)?;
Self::collect_facets_recursive(query, searcher, &facet.to_string(), all_facets)?;
}
Ok(())
}
pub async fn get_all_tags(&self) -> BichonResult<Vec<TagCount>> {
pub async fn get_all_tags(
&self,
accounts: Option<HashSet<u64>>,
) -> BichonResult<Vec<TagCount>> {
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mut all_facets = Vec::new();
Self::collect_facets_recursive(&searcher, "/", &mut all_facets)?;
Self::collect_facets_recursive(&query, &searcher, "/", &mut all_facets)?;
Ok(all_facets)
}
@@ -550,6 +616,7 @@ impl EnvelopeIndexManager {
pub async fn search(
&self,
accounts: Option<HashSet<u64>>,
filter: SearchFilter,
page: u64,
page_size: u64,
@@ -557,7 +624,7 @@ impl EnvelopeIndexManager {
) -> BichonResult<DataPage<Envelope>> {
assert!(page > 0, "Page number must be greater than 0");
assert!(page_size > 0, "Page size must be greater than 0");
let query = self.filter_query(filter, self.query_parser.clone())?;
let query = self.filter_query(accounts, filter, self.query_parser.clone())?;
let searcher = self.create_searcher()?;
let total = searcher
.search(&query, &Count)
@@ -591,7 +658,7 @@ impl EnvelopeIndexManager {
&query,
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -654,7 +721,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -719,7 +786,7 @@ impl EnvelopeIndexManager {
query.as_ref(),
&TopDocs::with_limit(page_size as usize)
.and_offset(offset as usize)
.order_by_fast_field(F_INTERNAL_DATE, order),
.order_by_fast_field(F_DATE, order),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let mut result = Vec::new();
@@ -782,15 +849,36 @@ impl EnvelopeIndexManager {
}
}
pub async fn top_10_largest_emails(&self) -> BichonResult<Vec<LargestEmail>> {
pub async fn top_10_largest_emails(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<Vec<LargestEmail>> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let searcher = self.reader.searcher();
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let mailbox_docs: Vec<(u64, DocAddress)> = searcher
.search(
&AllQuery,
&query,
&TopDocs::with_limit(10).order_by_fast_field(F_SIZE, Order::Desc),
)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -946,7 +1034,10 @@ impl EnvelopeIndexManager {
Ok(self.reader.searcher())
}
pub async fn get_dashboard_stats(&self) -> BichonResult<DashboardStats> {
pub async fn get_dashboard_stats(
&self,
accounts: &Option<HashSet<u64>>,
) -> BichonResult<DashboardStats> {
let searcher = self.create_searcher()?;
let now_ms = utc_now!();
let week_ago_ms = (Utc::now() - Duration::from_secs(60 * 60 * 24 * 30)).timestamp_millis();
@@ -957,7 +1048,7 @@ impl EnvelopeIndexManager {
},
"recent_30d_histogram": {
"histogram": {
"field": F_INTERNAL_DATE,
"field": F_DATE,
"interval": 86400000,
"hard_bounds": {
"min": week_ago_ms,
@@ -985,7 +1076,23 @@ impl EnvelopeIndexManager {
}))
.unwrap();
let query = AllQuery;
let query: Box<dyn Query> = match accounts {
Some(ref ids) if !ids.is_empty() => {
let mut subqueries = Vec::new();
for &id in ids {
let term =
Term::from_field_u64(SchemaTools::envelope_fields().f_account_id, id);
subqueries.push((
Occur::Should,
Box::new(TermQuery::new(term, IndexRecordOption::Basic)) as Box<dyn Query>,
));
}
Box::new(BooleanQuery::new(subqueries))
}
Some(_) => Box::new(EmptyQuery),
None => Box::new(AllQuery),
};
let agg_collector = AggregationCollector::from_aggs(aggregations, Default::default());
let agg_results = searcher
.search(&query, &agg_collector)

View File

@@ -16,7 +16,6 @@
// 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::migration::{AccountModel, AccountType};
use crate::modules::cache::imap::mailbox::{Attribute, AttributeEnum, MailBox};
use crate::modules::context::executors::MAIL_CONTEXT;
@@ -64,8 +63,15 @@ pub async fn convert_names_to_mailboxes(
for name in names.into_iter() {
// Convert the name into a MailBox structure
let mailbox_name = name.name().to_string();
let mut mailbox: MailBox = name.into();
tracing::debug!(
raw = &mailbox_name,
decoded = &mailbox.name,
"mailbox name comparison"
);
if contains_no_select(&mailbox.attributes) {
continue;
}

View File

@@ -16,6 +16,7 @@
// 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::HashSet;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
@@ -72,9 +73,18 @@ impl SearchRequest {
}
}
pub async fn search_messages_impl(request: SearchRequest) -> BichonResult<DataPage<Envelope>> {
pub async fn search_messages_impl(
accounts: Option<HashSet<u64>>,
request: SearchRequest,
) -> BichonResult<DataPage<Envelope>> {
request.validate()?;
ENVELOPE_INDEX_MANAGER
.search(request.filter, request.page, request.page_size, true)
.search(
accounts,
request.filter,
request.page,
request.page_size,
true,
)
.await
}

View File

@@ -36,5 +36,6 @@ pub mod rest;
pub mod settings;
pub mod tasks;
pub mod token;
pub mod users;
pub mod utils;
pub mod version;

View File

@@ -16,7 +16,6 @@
// 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, id,
modules::{
@@ -97,6 +96,27 @@ impl OAuth2 {
})
}
pub fn scrub_sensitive_fields(&mut self) {
let mask = "********";
let notice =
" [REDACTED: You do not have permission to view sensitive configuration details]";
let original_desc = self
.description
.clone()
.unwrap_or_else(|| "OAuth2 Config".to_string());
self.description = Some(format!("{}{}", original_desc, notice));
self.client_id = mask.to_string();
self.client_secret = mask.to_string();
self.auth_url = mask.to_string();
self.token_url = mask.to_string();
self.redirect_uri = mask.to_string();
self.scopes = None;
self.extra_params = None;
}
pub async fn save(&self) -> BichonResult<()> {
insert_impl(DB_MANAGER.meta_db(), self.to_owned()).await?;
Ok(())

View File

@@ -16,16 +16,12 @@
// 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::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::token::root::set_root_password;
use crate::modules::{
token::payload::AccessTokenCreateRequest,
token::{root::reset_root_token, AccessToken},
};
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::permissions::Permission;
use crate::modules::{token::payload::AccessTokenCreateRequest, token::AccessTokenModel};
use poem_openapi::payload::PlainText;
use poem_openapi::{param::Path, payload::Json, OpenApi};
@@ -33,9 +29,6 @@ pub struct AccessTokenApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::AccessToken")]
impl AccessTokenApi {
/// Lists all access tokens in the system.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list",
method = "get",
@@ -44,31 +37,15 @@ impl AccessTokenApi {
async fn list_access_tokens(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_all().await?))
) -> ApiResult<Json<Vec<AccessTokenResp>>> {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
Ok(Json(AccessTokenModel::list_all_api_tokens().await?))
}
/// Lists access tokens for a specific account.
///
/// Requires root privileges.
#[oai(
path = "/access-token-list/:account_id",
method = "get",
operation_id = "list_account_access_tokens"
)]
async fn list_account_access_tokens(
&self,
/// The ID of the account whose tokens are to be retrieved.
account_id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessToken>>> {
context.require_root()?;
Ok(Json(AccessToken::list_account_tokens(account_id.0).await?))
}
/// Deletes a specific access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "delete",
@@ -80,13 +57,18 @@ impl AccessTokenApi {
token: Path<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::delete(token.0.trim()).await?)
let token = token.0.trim();
let token = AccessTokenModel::get_token(token).await?;
if context.user.id != token.user_id {
context
.require_permission(None, Permission::TOKEN_MANAGE)
.await?;
}
Ok(AccessTokenModel::delete(&token.token).await?)
}
/// Creates a new access token.
///
/// Requires root privileges.
/// Creates a new api token.
#[oai(
path = "/access-token",
method = "post",
@@ -98,59 +80,15 @@ impl AccessTokenApi {
/// The request payload
payload: Json<AccessTokenCreateRequest>,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(AccessToken::create(payload.0).await?))
}
let current_user_id = context.user.id;
let target_user_id = payload.0.user_id.unwrap_or(current_user_id);
if target_user_id != current_user_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
/// Updates an existing access token.
///
/// Requires root privileges.
#[oai(
path = "/access-token/:token",
method = "post",
operation_id = "update_access_token"
)]
async fn update_access_token(
&self,
context: ClientContext,
/// The access token to be updated.
token: Path<String>,
/// The request payload.
payload: Json<AccessTokenUpdateRequest>,
) -> ApiResult<()> {
context.require_root()?;
Ok(AccessToken::update(token.0.trim(), payload.0).await?)
}
/// Regenerates the root access token.
///
/// Requires root privileges.
#[oai(
path = "/reset-root-token",
method = "post",
operation_id = "regenerate_root_token"
)]
async fn regenerate_root_token(&self, context: ClientContext) -> ApiResult<PlainText<String>> {
context.require_root()?;
Ok(PlainText(reset_root_token().await?))
}
/// Reset the Root user's password.
///
/// Only callable by an already authenticated Root user.
/// This endpoint updates the Root password to `password_str`
/// and regenerates the `root_token`, invalidating any previous token.
#[oai(
path = "/reset-root-password",
method = "post",
operation_id = "reset_root_password"
)]
async fn reset_root_password(
&self,
password_str: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
Ok(set_root_password(password_str.0.trim()).await?)
let token_string = AccessTokenModel::create_api_token(target_user_id, payload.0).await?;
Ok(PlainText(token_string))
}
}

View File

@@ -16,23 +16,25 @@
// 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 std::collections::{HashMap, HashSet};
use crate::modules::account::grant::BatchAccountRoleRequest;
use crate::modules::account::migration::AccountModel;
use crate::modules::account::payload::{
filter_accessible_accounts, AccountCreateRequest, AccountUpdateRequest, MinimalAccount,
};
use crate::modules::account::state::AccountRunningState;
use crate::modules::account::view::AccountResp;
use crate::modules::common::auth::ClientContext;
use crate::modules::common::paginated::paginate_vec;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::token::{AccessToken, AccountInfo};
use crate::modules::users::permissions::Permission;
use crate::modules::users::UserModel;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -53,7 +55,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
Ok(Json(AccountModel::get(account_id).await?))
}
@@ -70,7 +74,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::delete(account_id).await?)
}
@@ -82,14 +88,10 @@ impl AccountApi {
payload: Json<AccountCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<AccountModel>> {
let account = AccountModel::create_account(payload.0).await?;
if let Some(access_token) = &context.access_token {
let account_info = AccountInfo {
id: account.id,
email: account.email.clone(),
};
AccessToken::grant_account_access(&access_token.token, account_info).await?;
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let account = AccountModel::create_account(context.user.id, payload.0).await?;
Ok(Json(account))
}
@@ -108,7 +110,9 @@ impl AccountApi {
context: ClientContext,
) -> ApiResult<()> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
Ok(AccountModel::update(account_id, payload.0, true).await?)
}
@@ -123,35 +127,61 @@ impl AccountApi {
/// Optional. Whether to sort the list in descending order.
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<AccountModel>>> {
let accessible_accounts = context.accessible_accounts()?;
) -> ApiResult<Json<DataPage<AccountResp>>> {
let is_admin = context.user.is_admin().await;
let sort_desc = desc.0.unwrap_or(true);
if accessible_accounts.is_none() {
return Ok(Json(
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?,
));
}
let all_accounts = AccountModel::list_all().await?;
let allowed_ids: BTreeSet<u64> =
accessible_accounts.unwrap().iter().map(|a| a.id).collect();
let mut filtered_accounts: Vec<AccountModel> = all_accounts
let user_map: HashMap<u64, UserModel> = UserModel::list_all()
.await?
.into_iter()
.filter(|acct| allowed_ids.contains(&acct.id))
.map(|u| (u.id, u))
.collect();
let page_data: DataPage<AccountModel> = if is_admin {
AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
} else {
let authorized_ids: HashSet<u64> =
context.user.account_access_map.keys().cloned().collect();
if authorized_ids.is_empty() {
return Ok(Json(DataPage {
current_page: page.0,
page_size: page_size.0,
total_items: 0,
items: vec![],
total_pages: Some(0),
}));
}
let mut accounts: Vec<AccountModel> = AccountModel::list_all()
.await?
.into_iter()
.filter(|acct| authorized_ids.contains(&acct.id))
.collect();
accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
paginate_vec(&accounts, page.0, page_size.0).map(DataPage::from)?
};
let items = page_data
.items
.into_iter()
.map(|account| AccountResp::from_model(account, &user_map))
.collect();
let sort_desc = desc.0.unwrap_or(true);
filtered_accounts.sort_by(|a, b| {
if sort_desc {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
});
let page_data =
paginate_vec(&filtered_accounts, page.0, page_size.0).map(DataPage::from)?;
Ok(Json(page_data))
Ok(Json(DataPage {
current_page: page_data.current_page,
page_size: page_data.page_size,
total_items: page_data.total_items,
total_pages: page_data.total_pages,
items,
}))
}
/// Get the running state of an account
@@ -168,7 +198,9 @@ impl AccountApi {
) -> ApiResult<Json<AccountRunningState>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let state = AccountRunningState::get(account_id).await?.ok_or_else(|| {
raise_error!(
"account running state is not found".into(),
@@ -191,13 +223,25 @@ impl AccountApi {
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalAccount>>> {
let accessible_accounts = context.accessible_accounts()?;
let is_admin = context.user.is_admin().await;
let minimal_list = AccountModel::minimal_list().await?;
let result = match accessible_accounts {
Some(set) => filter_accessible_accounts(&minimal_list, set),
None => minimal_list,
};
if is_admin {
return Ok(Json(minimal_list));
}
let authorized_ids: Vec<u64> = context.user.account_access_map.keys().cloned().collect();
let result = filter_accessible_accounts(&minimal_list, &authorized_ids);
Ok(Json(result))
}
#[oai(path = "/accounts/access/assignments", method = "post")]
async fn batch_assign_account_role(
&self,
req: Json<BatchAccountRoleRequest>,
context: ClientContext,
) -> ApiResult<()> {
req.validate_existence().await?;
req.0.do_assign(&context).await?;
Ok(())
}
}

View File

@@ -16,14 +16,15 @@
// 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::autoconfig::entity::MailServerConfig;
use crate::modules::autoconfig::load::resolve_autoconfig;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -40,8 +41,13 @@ impl AutoConfigApi {
async fn autoconfig(
&self,
/// The email address to lookup configuration for
email_address: Path<String>
email_address: Path<String>,
context: ClientContext,
) -> ApiResult<Json<MailServerConfig>> {
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {

View File

@@ -21,6 +21,7 @@ use crate::modules::import::BatchEmlResult;
use crate::modules::import::{BatchEmlRequest, ImportEmls};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -43,7 +44,9 @@ impl ImportApi {
payload: Json<BatchEmlRequest>,
context: ClientContext,
) -> ApiResult<Json<BatchEmlResult>> {
context.require_root()?;
context
.require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
.await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}

View File

@@ -16,14 +16,13 @@
// 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::cache::imap::mailbox::MailBox;
use crate::modules::common::auth::ClientContext;
use crate::modules::mailbox::list::get_account_mailboxes;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use poem::web::Path;
use poem_openapi::param::Query;
use crate::modules::users::permissions::Permission;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
@@ -53,7 +52,9 @@ impl MailBoxApi {
context: ClientContext,
) -> ApiResult<Json<Vec<MailBox>>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_READ_DETAILS)
.await?;
let remote = remote.0.unwrap_or(false);
Ok(Json(get_account_mailboxes(account_id, remote).await?))
}

View File

@@ -31,12 +31,14 @@ use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::rest::ErrorCode;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::Body;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Attachment, AttachmentType, Json};
use poem_openapi::OpenApi;
use std::collections::HashMap;
use std::collections::HashSet;
use tantivy::schema::Facet;
pub struct MessageApi;
@@ -57,7 +59,9 @@ impl MessageApi {
) -> ApiResult<()> {
let request = payload.0;
for account_id in request.keys() {
context.require_account_access(*account_id)?;
context
.require_permission(Some(*account_id), Permission::DATA_DELETE)
.await?;
}
Ok(delete_messages_impl(request).await?)
}
@@ -80,7 +84,9 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let mailbox_id = mailbox_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
list_messages_impl(account_id, mailbox_id, page.0, page_size.0).await?,
))
@@ -98,8 +104,15 @@ impl MessageApi {
payload: Json<SearchRequest>,
context: ClientContext,
) -> ApiResult<Json<DataPage<Envelope>>> {
context.require_root()?;
Ok(Json(search_messages_impl(payload.0).await?))
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(search_messages_impl(authorized_ids, payload.0).await?))
}
/// Retrieves all messages belonging to a specific thread. Requires `thread_id`, `page`, and `page_size` query parameters.
@@ -122,7 +135,9 @@ impl MessageApi {
) -> ApiResult<Json<DataPage<Envelope>>> {
let account_id = account_id.0;
let thread_id = thread_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(
get_thread_messages(account_id, thread_id, page.0, page_size.0).await?,
))
@@ -143,7 +158,9 @@ impl MessageApi {
context: ClientContext,
) -> ApiResult<Json<FullMessageContent>> {
let account_id = account_id.0;
context.require_account_access(account_id)?;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
Ok(Json(retrieve_email_content(account_id, message_id.0).await?))
}
@@ -194,9 +211,10 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let message_id = message_id.0;
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id).await?;
context
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
.await?;
let reader = EML_INDEX_MANAGER.get_reader(account_id, message_id.0).await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
.attachment_type(AttachmentType::Attachment)
@@ -222,11 +240,12 @@ impl MessageApi {
) -> ApiResult<Attachment<Body>> {
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
context.require_account_access(account_id)?;
let message_id = message_id.0;
context
.require_permission(Some(account_id), Permission::DATA_READ)
.await?;
let name = name.0.trim();
let reader = EML_INDEX_MANAGER
.get_attachment(account_id, message_id, name)
.get_attachment(account_id, message_id.0, name)
.await?;
let body = Body::from_async_read(reader);
let attachment = Attachment::new(body)
@@ -236,8 +255,18 @@ impl MessageApi {
}
/// Returns all facets in the index along with their document counts.
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
async fn get_all_tags(&self) -> ApiResult<Json<Vec<TagCount>>> {
Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
let authorized_ids: Option<HashSet<u64>> = if context
.has_permission(None, Permission::DATA_READ_ALL)
.await
{
None
} else {
Some(context.user.account_access_map.keys().cloned().collect())
};
Ok(Json(
ENVELOPE_INDEX_MANAGER.get_all_tags(authorized_ids).await?,
))
}
/// Adds or removes facet tags for multiple emails across accounts.
@@ -246,12 +275,23 @@ impl MessageApi {
method = "post",
operation_id = "update_envelope_tags"
)]
async fn update_envelope_tags(&self, req: Json<UpdateTagsRequest>) -> ApiResult<()> {
async fn update_envelope_tags(
&self,
req: Json<UpdateTagsRequest>,
context: ClientContext,
) -> ApiResult<()> {
let req = req.0;
for tag in &req.tags {
Facet::from_text(tag)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InvalidParameter))?;
}
for account_id in req.updates.keys() {
context
.require_permission(Some(*account_id), Permission::DATA_MANAGE)
.await?;
}
ENVELOPE_INDEX_MANAGER
.update_envelope_tags(req.updates, req.tags)
.await?;

View File

@@ -25,7 +25,10 @@ use oauth2::OAuth2Api;
use poem_openapi::{OpenApiService, Tags};
use system::SystemApi;
use crate::{bichon_version, modules::rest::api::import::ImportApi};
use crate::{
bichon_version,
modules::rest::api::{import::ImportApi, users::UsersApi},
};
pub mod access_token;
pub mod account;
@@ -35,6 +38,7 @@ pub mod mailbox;
pub mod message;
pub mod oauth2;
pub mod system;
pub mod users;
#[derive(Tags)]
pub enum ApiTags {
@@ -46,6 +50,7 @@ pub enum ApiTags {
Message,
System,
Import,
Users,
}
type RustMailOpenApi = (
@@ -57,6 +62,7 @@ type RustMailOpenApi = (
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
);
pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
@@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService<RustMailOpenApi, ()> {
OAuth2Api,
MessageApi,
ImportApi,
UsersApi,
),
"BichonApi",
bichon_version!(),

View File

@@ -16,7 +16,7 @@
// 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::migration::AccountModel;
use crate::modules::common::auth::ClientContext;
use crate::modules::error::code::ErrorCode;
use crate::modules::oauth2::entity::{OAuth2, OAuth2CreateRequest, OAuth2UpdateRequest};
@@ -25,9 +25,9 @@ use crate::modules::oauth2::token::{ExternalOAuth2Request, OAuth2AccessToken};
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::response::DataPage;
use crate::modules::rest::ApiResult;
use crate::modules::users::permissions::Permission;
use crate::raise_error;
use poem::web::Path;
use poem_openapi::param::Query;
use poem_openapi::param::{Path, Query};
use poem_openapi::payload::{Json, PlainText};
use poem_openapi::OpenApi;
@@ -50,14 +50,26 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<OAuth2>> {
context.require_root()?;
let id = id.0;
Ok(Json(OAuth2::get(id).await?.ok_or_else(|| {
let mut oauth2 = OAuth2::get(id).await?.ok_or_else(|| {
raise_error!(
format!("OAuth2 configuration id='{id}' not found"),
ErrorCode::ResourceNotFound
)
})?))
})?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(oauth2));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
oauth2.scrub_sensitive_fields();
Ok(Json(oauth2))
}
/// Deletes an OAuth2 configuration by name.
@@ -75,7 +87,9 @@ impl OAuth2Api {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::delete(id.0).await?)
}
@@ -94,7 +108,9 @@ impl OAuth2Api {
request: Json<OAuth2CreateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = OAuth2::new(request.0)?;
Ok(entity.save().await?)
}
@@ -116,7 +132,9 @@ impl OAuth2Api {
payload: Json<OAuth2UpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(OAuth2::update(id.0, payload.0).await?)
}
@@ -139,10 +157,23 @@ impl OAuth2Api {
desc: Query<Option<bool>>,
context: ClientContext,
) -> ApiResult<Json<DataPage<OAuth2>>> {
context.require_root()?;
Ok(Json(
OAuth2::paginate_list(page.0, page_size.0, desc.0).await?,
))
let mut list = OAuth2::paginate_list(page.0, page_size.0, desc.0).await?;
if context
.has_permission(None, Permission::ROOT)
.await
{
return Ok(Json(list));
}
context
.require_permission(None, Permission::ACCOUNT_CREATE)
.await?;
for item in &mut list.items {
item.scrub_sensitive_fields();
}
Ok(Json(list))
}
/// Generates an OAuth2 authorization URL for a specific account.
@@ -160,8 +191,14 @@ impl OAuth2Api {
request: Json<AuthorizeUrlRequest>,
context: ClientContext,
) -> ApiResult<PlainText<String>> {
context.require_root()?;
let request = request.0;
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(Some(request.account_id), Permission::ACCOUNT_MANAGE),
])
.await?;
let flow = OAuth2Flow::new(request.oauth2_id);
Ok(PlainText(flow.authorize_url(request.account_id).await?))
}
@@ -181,7 +218,9 @@ impl OAuth2Api {
context: ClientContext,
) -> ApiResult<Json<OAuth2AccessToken>> {
let account = account_id.0;
context.require_account_access(account)?;
context
.require_permission(Some(account), Permission::ACCOUNT_MANAGE)
.await?;
Ok(Json(OAuth2AccessToken::get(account).await?.ok_or_else(
|| {
raise_error!(
@@ -219,10 +258,13 @@ impl OAuth2Api {
request: Json<ExternalOAuth2Request>,
context: ClientContext,
) -> ApiResult<()> {
let account = account_id.0;
let account_id = account_id.0;
AccountModel::check_account_exists(account_id).await?;
// Check account access permissions
context.require_account_access(account)?;
OAuth2AccessToken::upsert_external_oauth_token(account, request.0).await?;
context
.require_permission(Some(account_id), Permission::ACCOUNT_MANAGE)
.await?;
OAuth2AccessToken::upsert_external_oauth_token(account_id, request.0).await?;
Ok(())
}
}

View File

@@ -16,13 +16,15 @@
// 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::common::auth::ClientContext;
use crate::modules::dashboard::DashboardStats;
use crate::modules::error::code::ErrorCode;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::settings::proxy::Proxy;
use crate::modules::settings::SystemConfigurations;
use crate::modules::users::permissions::Permission;
use crate::modules::version::{fetch_notifications, Notifications};
use crate::raise_error;
use poem_openapi::param::Path;
@@ -60,14 +62,20 @@ impl SystemApi {
path = "/dashboard-stats",
operation_id = "get_dashboard_stats"
)]
async fn get_dashboard_stats(&self) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get().await?;
async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult<Json<DashboardStats>> {
let stats = DashboardStats::get(context).await?;
Ok(Json(stats))
}
/// Get the full list of SOCKS5 proxy configurations.
#[oai(method = "get", path = "/list-proxy", operation_id = "list_proxy")]
async fn list_proxy(&self) -> ApiResult<Json<Vec<Proxy>>> {
async fn list_proxy(&self, context: ClientContext) -> ApiResult<Json<Vec<Proxy>>> {
context
.require_any_permission(vec![
(None, Permission::ACCOUNT_CREATE),
(None, Permission::ROOT),
])
.await?;
let proxies = Proxy::list_all()
.await
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
@@ -82,7 +90,9 @@ impl SystemApi {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::delete(id.0).await?)
}
@@ -94,14 +104,18 @@ impl SystemApi {
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Proxy>> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Json(Proxy::get(id.0).await?))
}
/// Create a new proxy configuration. Requires root permission.
#[oai(path = "/proxy", method = "post", operation_id = "create_proxy")]
async fn create_proxy(&self, url: PlainText<String>, context: ClientContext) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
let entity = Proxy::new(url.0);
Ok(entity.save().await?)
}
@@ -114,7 +128,28 @@ impl SystemApi {
url: PlainText<String>,
context: ClientContext,
) -> ApiResult<()> {
context.require_root()?;
context
.require_permission(None, Permission::ROOT)
.await?;
Ok(Proxy::update(id.0, url.0).await?)
}
/// Get system configurations.
///
/// Returns a read-only snapshot of the server configuration
/// resolved at startup. Sensitive values are not exposed.
#[oai(
method = "get",
path = "/system-configurations",
operation_id = "get_system_configurations"
)]
async fn get_system_configurations(
&self,
context: ClientContext,
) -> ApiResult<Json<SystemConfigurations>> {
context
.require_permission(None, Permission::ROOT)
.await?;
let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
Ok(Json(config))
}
}

View File

@@ -0,0 +1,217 @@
//
// 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::BTreeMap;
use crate::modules::common::auth::ClientContext;
use crate::modules::rest::api::ApiTags;
use crate::modules::rest::ApiResult;
use crate::modules::token::AccessTokenModel;
use crate::modules::users::minimal::MinimalUser;
use crate::modules::users::payload::{
RoleCreateRequest, RoleUpdateRequest, UserCreateRequest, UserUpdateRequest,
};
use crate::modules::users::permissions::Permission;
use crate::modules::users::role::UserRole;
use crate::modules::users::view::UserView;
use crate::modules::users::UserModel;
use poem::web::Path;
use poem_openapi::payload::Json;
use poem_openapi::OpenApi;
pub struct UsersApi;
#[OpenApi(prefix_path = "/api/v1", tag = "ApiTags::Users")]
impl UsersApi {
#[oai(path = "/list-roles", method = "get", operation_id = "list_roles")]
async fn list_roles(&self, context: ClientContext) -> ApiResult<Json<Vec<UserRole>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(UserRole::list_all().await?))
}
#[oai(path = "/roles/:id", method = "delete", operation_id = "remove_role")]
async fn remove_role(
&self,
/// The Role ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::delete(id).await?)
}
/// Create a new account
#[oai(path = "/roles", method = "post", operation_id = "create_role")]
async fn create_role(
&self,
/// Role creation request payload
payload: Json<RoleCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserRole>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let role = UserRole::create(payload.0).await?;
Ok(Json(role))
}
/// Update an existing account
#[oai(path = "/roles/:id", method = "post", operation_id = "update_role")]
async fn update_role(
&self,
/// The Role ID to update
id: Path<u64>,
/// Role update request payload
payload: Json<RoleUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserRole::update(id, payload.0).await?)
}
#[oai(path = "/list-users", method = "get", operation_id = "list_users")]
async fn list_users(&self, context: ClientContext) -> ApiResult<Json<Vec<UserView>>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
let users = UserModel::list_all().await?;
let users = users
.into_iter()
.map(|u| u.to_view(&role_lookup))
.collect();
Ok(Json(users))
}
#[oai(
path = "/user-tokens/:id",
method = "get",
operation_id = "get_user_tokens"
)]
async fn get_user_tokens(
&self,
id: Path<u64>,
context: ClientContext,
) -> ApiResult<Json<Vec<AccessTokenModel>>> {
let target_user_id = id.0;
let tokens = AccessTokenModel::get_user_api_tokens(target_user_id).await?;
if context.user.id == target_user_id {
return Ok(Json(tokens));
}
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(Json(tokens))
}
#[oai(path = "/users/:id", method = "delete", operation_id = "remove_user")]
async fn remove_user(
&self,
/// The User ID to delete
id: Path<u64>,
context: ClientContext,
) -> ApiResult<()> {
let id = id.0;
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
Ok(UserModel::remove(id).await?)
}
#[oai(path = "/users", method = "post", operation_id = "create_user")]
async fn create_user(
&self,
payload: Json<UserCreateRequest>,
context: ClientContext,
) -> ApiResult<Json<UserView>> {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
let user = UserModel::create(payload.0).await?;
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(user.to_view(&role_lookup)))
}
#[oai(path = "/users/:id", method = "post", operation_id = "update_user")]
async fn update_user(
&self,
id: Path<u64>,
payload: Json<UserUpdateRequest>,
context: ClientContext,
) -> ApiResult<()> {
let target_id = id.0;
let current_user_id = context.user.id;
if current_user_id != target_id {
context
.require_permission(None, Permission::USER_MANAGE)
.await?;
}
let mut update_data = payload.0;
if current_user_id == target_id
&& !context.has_permission(None, Permission::USER_MANAGE).await
{
update_data.global_roles = None;
update_data.account_access_map = None;
update_data.acl = None;
}
Ok(UserModel::update(target_id, update_data).await?)
}
#[oai(
path = "/current-user",
method = "get",
operation_id = "get_current_user"
)]
async fn get_current_user(&self, context: ClientContext) -> ApiResult<Json<UserView>> {
let roles = UserRole::list_all().await?;
let role_lookup: BTreeMap<u64, UserRole> = roles.into_iter().map(|r| (r.id, r)).collect();
Ok(Json(context.user.to_view(&role_lookup)))
}
#[oai(
path = "/minimal-user-list",
method = "get",
operation_id = "get_minimal_user_list"
)]
async fn get_minimal_user_list(
&self,
context: ClientContext,
) -> ApiResult<Json<Vec<MinimalUser>>> {
let is_admin = context.user.is_admin().await;
let minimal_list = MinimalUser::list_all().await?;
if is_admin {
return Ok(Json(minimal_list));
}
context
.require_permission(None, Permission::USER_VIEW)
.await?;
Ok(Json(minimal_list))
}
}

View File

@@ -78,8 +78,7 @@ pub async fn start_http_server() -> BichonResult<()> {
.with(Timeout)
.with(Tracing);
let cors_origins: Option<HashSet<String>> =
SETTINGS.bichon_cors_origins.clone();
let cors_origins: Option<HashSet<String>> = SETTINGS.bichon_cors_origins.clone();
let cors_origins: Vec<String> = cors_origins.unwrap_or_default().into_iter().collect();

View File

@@ -16,29 +16,41 @@
// 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::token::root::check_root_password;
use poem::{handler, IntoResponse, Response};
use crate::modules::users::UserModel;
use poem::{handler, web::Json, IntoResponse, Response};
use serde::Deserialize;
use tracing::error;
/// Login endpoint for Root user
#[derive(Deserialize)]
pub struct LoginPayload {
pub username: String,
pub password: String,
}
/// Login endpoint
///
/// Accepts a plain text password and returns the `root_token`
/// on successful authentication.
#[handler]
pub async fn login(password: String) -> Response {
match check_root_password(&password) {
Ok(root_token) => Response::builder()
.status(http::StatusCode::OK)
.content_type("text/plain")
.body(root_token)
.into_response(),
pub async fn login(payload: Json<LoginPayload>) -> Response {
let payload = payload.0;
match UserModel::authenticate_user(payload.username, payload.password).await {
Ok(result) => match serde_json::to_string(&result) {
Ok(json_string) => Response::builder()
.status(http::StatusCode::OK)
.content_type("application/json")
.body(json_string)
.into_response(),
Err(_) => Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Internal server error during response serialization.")
.into_response(),
},
Err(e) => {
error!("Root login failed: {:?}", e);
error!("Authentication failed with system error: {:?}", e);
Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.content_type("text/plain")
.body(e.to_string())
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.body("Authentication system failed.".to_string())
.into_response()
}
}

View File

@@ -19,7 +19,7 @@
use clap::{builder::ValueParser, Parser, ValueEnum};
use std::{collections::HashSet, env, fmt, path::PathBuf, sync::LazyLock};
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::parse);
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(Settings::init);
#[derive(Debug, Parser)]
#[clap(
@@ -132,11 +132,27 @@ pub struct Settings {
/// bichon encryption password
#[clap(
long,
default_value = "change-this-default-password-now",
env,
help = "Set the encryption password for bichon. ⚠️ Change this default in production!"
default_value = "change-this-default-password-now",
help = "Set the encryption password for bichon. Alternatively, you can use --bichon-encrypt-password-file. If both are set, this parameter takes precedence over the file."
)]
pub bichon_encrypt_password: String,
pub bichon_encrypt_password: Option<String>,
#[clap(
long,
env,
help = "The file containing the encryption password. An alternative to --bichon-encrypt-password."
)]
pub bichon_encrypt_password_file: Option<String>,
/// WebUI token expiration time in seconds (default: 7 days)
#[clap(
long,
default_value = "168",
env,
help = "Set the WebUI token expiration time in hours"
)]
pub bichon_webui_token_expiration_hours: u32,
#[clap(
long,
@@ -174,19 +190,6 @@ pub struct Settings {
)]
pub bichon_envelope_cache_size: Option<usize>,
/// Enables or disables the access token mechanism for HTTP endpoints.
///
/// When set to `true`, HTTP requests will be subject to access token validation.
/// If the `Authorization` header is missing or the token is invalid, the service will return a 401 Unauthorized response.
/// When set to `false`, access token validation will be skipped.
#[clap(
long,
default_value = "false",
env,
help = "Enables or disables the access token mechanism for HTTP endpoints."
)]
pub bichon_enable_access_token: bool,
/// Enables or disables HTTPS for REST API endpoints.
///
/// When set to `true`, the REST API will use HTTPS with a valid SSL/TLS certificate for secure communication.
@@ -217,6 +220,18 @@ pub struct Settings {
pub bichon_sync_concurrency: Option<u16>,
}
impl Settings {
pub fn init() -> Self {
let s = Self::parse();
if s.bichon_encrypt_password.is_none() && s.bichon_encrypt_password_file.is_none() {
panic!(
"One of --bichon_encrypt_password or --bichon_encrypt_password_file has to be set"
);
}
s
}
}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum CompressionAlgorithm {
#[clap(name = "none")]

View File

@@ -16,8 +16,68 @@
// 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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::settings::cli::Settings;
pub mod cli;
pub mod dir;
pub mod proxy;
pub mod system;
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct SystemConfigurations {
pub bichon_log_level: String,
pub bichon_http_port: i32,
pub bichon_bind_ip: Option<String>,
pub bichon_public_url: String,
pub bichon_cors_origins: Option<Vec<String>>,
pub bichon_cors_max_age: i32,
pub bichon_ansi_logs: bool,
pub bichon_log_to_file: bool,
pub bichon_json_logs: bool,
pub bichon_max_server_log_files: usize,
pub bichon_encrypt_password_set: bool,
pub bichon_webui_token_expiration_hours: u32,
pub bichon_root_dir: String,
pub bichon_metadata_cache_size: Option<usize>,
pub bichon_envelope_cache_size: Option<usize>,
pub bichon_enable_rest_https: bool,
pub bichon_http_compression_enabled: bool,
pub bichon_sync_concurrency: Option<u16>,
}
impl From<&Settings> for SystemConfigurations {
fn from(s: &Settings) -> Self {
Self {
bichon_log_level: s.bichon_log_level.clone(),
bichon_http_port: s.bichon_http_port,
bichon_bind_ip: s.bichon_bind_ip.clone(),
bichon_public_url: s.bichon_public_url.clone(),
bichon_cors_origins: s
.bichon_cors_origins
.as_ref()
.map(|set| set.iter().cloned().collect()),
bichon_cors_max_age: s.bichon_cors_max_age,
bichon_ansi_logs: s.bichon_ansi_logs,
bichon_log_to_file: s.bichon_log_to_file,
bichon_json_logs: s.bichon_json_logs,
bichon_max_server_log_files: s.bichon_max_server_log_files,
bichon_encrypt_password_set: s.bichon_encrypt_password.is_some()
|| s.bichon_encrypt_password_file.is_some(),
bichon_webui_token_expiration_hours: s.bichon_webui_token_expiration_hours,
bichon_root_dir: s.bichon_root_dir.clone(),
bichon_metadata_cache_size: s.bichon_metadata_cache_size,
bichon_envelope_cache_size: s.bichon_envelope_cache_size,
bichon_enable_rest_https: s.bichon_enable_rest_https,
bichon_http_compression_enabled: s.bichon_http_compression_enabled,
bichon_sync_concurrency: s.bichon_sync_concurrency,
}
}
}

View File

@@ -16,7 +16,6 @@
// 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::Object;
@@ -132,10 +131,7 @@ mod tests {
#[test]
fn test_valid_proxy_urls() {
let urls = vec![
"socks5://127.0.0.1:1080",
"http://127.0.0.1:8080",
];
let urls = vec!["socks5://127.0.0.1:1080", "http://127.0.0.1:8080"];
for url in urls {
let proxy = Proxy::new(url.to_string());

View File

@@ -17,10 +17,10 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{find_impl, upsert_impl};
use crate::modules::error::BichonResult;
use crate::utc_now;
// use crate::modules::database::manager::DB_MANAGER;
// use crate::modules::database::{find_impl, upsert_impl};
// use crate::modules::error::BichonResult;
// use crate::utc_now;
use native_db::*;
use native_model::{native_model, Model};
use serde::{Deserialize, Serialize};
@@ -37,34 +37,34 @@ pub struct SystemSetting {
}
impl SystemSetting {
pub fn new(key: String, value: String) -> Self {
Self {
key,
value,
created_at: utc_now!(),
updated_at: utc_now!(),
}
}
// pub fn new(key: String, value: String) -> Self {
// Self {
// key,
// value,
// created_at: utc_now!(),
// updated_at: utc_now!(),
// }
// }
//overwrite
pub async fn set(&self) -> BichonResult<()> {
upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
}
// pub async fn set(&self) -> BichonResult<()> {
// upsert_impl(DB_MANAGER.meta_db(), self.to_owned()).await
// }
pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
find_impl(DB_MANAGER.meta_db(), key)
}
// pub fn get(key: &str) -> BichonResult<Option<SystemSetting>> {
// find_impl(DB_MANAGER.meta_db(), key)
// }
// pub async fn list() -> RustMailerResult<Vec<SystemSetting>> {
// list_all_impl(DB_MANAGER.metadata_db()).await
// }
pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
let setting = Self::get(key)?;
Ok(setting.map(|s| s.value))
}
// pub fn get_existing_value(key: &str) -> BichonResult<Option<String>> {
// let setting = Self::get(key)?;
// Ok(setting.map(|s| s.value))
// }
pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
let setting = Self::new(key.to_string(), value);
setting.set().await
}
// pub async fn set_value(key: &str, value: String) -> BichonResult<()> {
// let setting = Self::new(key.to_string(), value);
// setting.set().await
// }
}

View File

@@ -16,12 +16,17 @@
// 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;
use crate::modules::account::migration::AccountModel;
use crate::modules::database::delete_impl;
use super::error::code::ErrorCode;
use crate::modules::database::manager::DB_MANAGER;
use crate::modules::database::{
async_find_impl, delete_impl, filter_by_secondary_key_impl, with_transaction,
};
use crate::modules::database::{insert_impl, list_all_impl, update_impl};
use crate::modules::token::payload::AccessTokenUpdateRequest;
use crate::modules::settings::cli::SETTINGS;
use crate::modules::token::view::AccessTokenResp;
use crate::modules::users::UserModel;
use crate::raise_error;
use crate::{
generate_token, modules::error::BichonResult,
@@ -29,259 +34,227 @@ use crate::{
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::net::IpAddr;
use super::error::code::ErrorCode;
pub mod payload;
pub mod root;
pub mod view;
// Starting from version 0.2.0, this model is deprecated/no longer used
// #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
// #[native_model(id = 1, version = 1)]
// #[native_db]
// pub struct AccessToken {
// /// The unique token string used for authentication
// #[primary_key]
// pub token: String,
// /// A set of account information associated with the token.
// pub accounts: BTreeSet<AccountInfo>,
// /// The timestamp (in milliseconds since epoch) when the token was created.
// pub created_at: i64,
// /// The timestamp (in milliseconds since epoch) when the token was last updated.
// pub updated_at: i64,
// /// An optional description of the token's purpose or usage.
// pub description: Option<String>,
// /// The timestamp (in milliseconds since epoch) when the token was last used.
// pub last_access_at: i64,
// /// Optional access control settings
// pub acl: Option<AccessControl>,
// }
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Enum)]
pub enum TokenType {
WebUI,
Api,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
#[native_model(id = 1, version = 1)]
#[native_model(id = 11, version = 1)]
#[native_db]
pub struct AccessToken {
pub struct AccessTokenModel {
/// The ID of the user who owns this token
#[secondary_key]
pub user_id: u64,
/// The unique token string used for authentication
#[primary_key]
pub token: String,
/// A set of account information associated with the token.
pub accounts: BTreeSet<AccountInfo>,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// An optional description of the token's purpose or usage.
pub description: Option<String>,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccountInfo {
/// The unique identifier for the account.
pub id: u64,
/// The email address associated with the account.
pub email: String,
}
impl Ord for AccountInfo {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.id.cmp(&other.id)
}
}
impl PartialOrd for AccountInfo {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
impl AccessToken {
pub fn new(
impl AccessTokenModel {
pub fn new_api_token(
token: String,
accounts: BTreeSet<AccountInfo>,
description: Option<String>,
acl: Option<AccessControl>,
user_id: u64,
name: Option<String>,
expire_at: Option<i64>,
) -> Self {
Self {
token,
accounts,
created_at: utc_now!(),
updated_at: utc_now!(),
description,
last_access_at: Default::default(),
acl,
name,
user_id,
token_type: TokenType::Api,
expire_at,
}
}
pub async fn try_update_access_timestamp(token: &str) -> BichonResult<AccessToken> {
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessToken>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!("Token not exist.".into(), ErrorCode::ResourceNotFound)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await
pub fn new_webui_token(user_id: u64) -> AccessTokenModel {
let now = utc_now!();
AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: None,
user_id,
token_type: TokenType::WebUI,
expire_at: None,
}
}
pub async fn grant_account_access(token: &str, account: AccountInfo) -> BichonResult<()> {
let token = token.to_string();
update_impl(
pub async fn reset_webui_token(user_id: u64) -> BichonResult<String> {
let old_token = Self::get_user_webui_token(user_id).await?;
let new_token = Self::new_webui_token(user_id);
let new_token_str = new_token.token.clone();
match old_token {
Some(old) => {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
rw.remove(old)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.insert(new_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
}
None => {
insert_impl(DB_MANAGER.meta_db(), new_token).await?;
}
}
Ok(new_token_str)
}
pub async fn get_user_webui_token(user_id: u64) -> BichonResult<Option<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.accounts.insert(account);
updated.updated_at = utc_now!();
Ok(updated)
},
AccessTokenModelKey::user_id,
user_id,
)
.await?;
Ok(())
Ok(tokens
.into_iter()
.find(|t| t.token_type == TokenType::WebUI))
}
pub async fn update(token: &str, request: AccessTokenUpdateRequest) -> BichonResult<()> {
if request.should_skip_update() {
return Err(raise_error!(
"No changes detected in access scopes, description, or accounts. \
Please modify at least one of these fields to perform an update."
.into(),
ErrorCode::InvalidParameter
));
}
request.validate().await?;
pub async fn get_user_api_tokens(user_id: u64) -> BichonResult<Vec<AccessTokenModel>> {
let tokens = filter_by_secondary_key_impl::<AccessTokenModel>(
DB_MANAGER.meta_db(),
AccessTokenModelKey::user_id,
user_id,
)
.await?;
let account_infos = if let Some(accounts) = &request.accounts {
let mut account_infos = BTreeSet::new();
for account_id in accounts {
let account = AccountModel::get(*account_id).await?;
account_infos.insert(AccountInfo {
id: *account_id,
email: account.email,
});
Ok(tokens
.into_iter()
.filter(|t| t.token_type == TokenType::Api)
.collect())
}
pub async fn resolve_user_from_token(token: &str) -> BichonResult<UserModel> {
let token = token.to_string();
let token_option = async_find_impl::<AccessTokenModel>(DB_MANAGER.meta_db(), token).await?;
let token = match token_option {
Some(token) => token,
None => {
return Err(raise_error!(
"Permission denied: no valid access token provided.".into(),
ErrorCode::PermissionDenied
))
}
account_infos
} else {
BTreeSet::new()
};
let token = token.to_string();
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!(
"The access token with token={} that you want to modify was not found.",
token
),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(description) = request.description {
updated.description = Some(description);
}
if matches!(token.token_type, TokenType::WebUI) {
let life = utc_now!() - token.created_at;
let max_life = SETTINGS.bichon_webui_token_expiration_hours * 60 * 60 * 1000;
if request.accounts.is_some() {
updated.accounts = account_infos;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn create(request: AccessTokenCreateRequest) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let AccessTokenCreateRequest {
accounts,
description,
acl,
} = request;
let mut account_infos = BTreeSet::new();
for &account_id in &accounts {
let account = AccountModel::get(account_id).await?;
account_infos.insert(AccountInfo {
id: account_id,
email: account.email,
});
if life > (max_life as i64) {
return Err(raise_error!(
"Permission denied: the WebUI token has expired.".into(),
ErrorCode::PermissionDenied
));
}
}
if matches!(token.token_type, TokenType::Api) {
if let Some(expire_at) = token.expire_at {
if utc_now!() > expire_at {
return Err(raise_error!(
"Your API token has expired and is no longer valid.".into(),
ErrorCode::PermissionDenied
));
}
}
let token = token.token.clone();
update_impl(
DB_MANAGER.meta_db(),
|rw| {
rw.get()
.primary::<AccessTokenModel>(token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
"The access token does not exist or has been reset.".into(),
ErrorCode::ResourceNotFound
)
})
},
|current| {
let mut updated = current.clone();
updated.last_access_at = utc_now!();
Ok(updated)
},
)
.await?;
}
let user = UserModel::find(token.user_id)
.await?
.ok_or_else(|| raise_error!("The user associated with this access token does not exist or may have been deleted.".into(), ErrorCode::ResourceNotFound))?;
Ok(user)
}
pub async fn create_api_token(
user_id: u64,
request: AccessTokenCreateRequest,
) -> BichonResult<String> {
// Validate request parameters first
request.validate().await?;
let expire_at = request
.expire_in
.map(|hours| utc_now!() + (hours as i64) * 60 * 60 * 1000);
let token = generate_token!(128);
let access_token = AccessToken::new(token.clone(), account_infos, description, acl);
let access_token =
AccessTokenModel::new_api_token(token.clone(), user_id, request.name, expire_at);
insert_impl(DB_MANAGER.meta_db(), access_token).await?;
Ok(token)
}
@@ -290,7 +263,7 @@ impl AccessToken {
let token = token.to_string();
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<AccessToken>(token.clone())
.primary::<AccessTokenModel>(token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
@@ -302,56 +275,47 @@ impl AccessToken {
.await
}
pub async fn list_all() -> BichonResult<Vec<AccessToken>> {
list_all_impl(DB_MANAGER.meta_db()).await
pub async fn get_token(token: &str) -> BichonResult<AccessTokenModel> {
async_find_impl(DB_MANAGER.meta_db(), token.to_string())
.await?
.ok_or_else(|| {
raise_error!(
format!("Access token '{}' not found", token),
ErrorCode::ResourceNotFound
)
})
}
pub async fn list_account_tokens(account_id: u64) -> BichonResult<Vec<AccessToken>> {
let all = AccessToken::list_all().await?;
let result: Vec<AccessToken> = all
pub async fn list_all_api_tokens() -> BichonResult<Vec<AccessTokenResp>> {
let users = UserModel::list_all().await?;
let mut all = list_all_impl::<AccessTokenModel>(DB_MANAGER.meta_db()).await?;
all.retain(|t| t.token_type == TokenType::Api);
let user_map: HashMap<u64, UserModel> = users.into_iter().map(|u| (u.id, u)).collect();
let resp = all
.into_iter()
.filter(|e| {
e.accounts
.iter()
.any(|account_info| account_info.id == account_id)
.map(|token| {
let user = user_map.get(&token.user_id);
AccessTokenResp {
user_name: user
.map(|u| u.username.clone())
.unwrap_or_else(|| "Unknown".to_string()),
user_email: user
.map(|u| u.email.clone())
.unwrap_or_else(|| "N/A".to_string()),
user_id: token.user_id,
name: token.name,
token: token.token,
token_type: token.token_type,
created_at: token.created_at,
updated_at: token.updated_at,
expire_at: token.expire_at,
last_access_at: token.last_access_at,
}
})
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let tokens = Self::list_account_tokens(account_id).await?;
if tokens.is_empty() {
return Ok(());
}
for token in tokens {
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<AccessToken>(token.token.clone())
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("Cannot find access token, {}", token.token),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
updated.updated_at = utc_now!();
updated.accounts.retain(|account| account.id != account_id);
Ok(updated)
},
)
.await?;
}
Ok(())
}
pub fn can_access_account(&self, account_id: u64) -> bool {
self.accounts.iter().any(|account| account.id == account_id)
Ok(resp)
}
}

View File

@@ -16,15 +16,8 @@
// 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::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
token::AccessControl,
},
modules::error::{code::ErrorCode, BichonResult},
raise_error,
};
use poem_openapi::Object;
@@ -32,91 +25,28 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenCreateRequest {
/// A set of account information associated with the token.
pub accounts: BTreeSet<u64>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
#[oai(validator(max_length = "32"))]
pub name: Option<String>,
/// The expiration interval for this token, in hours.
/// None means the token does not expire (this applies only to API tokens).
pub expire_in: Option<u64>,
/// The ID of the user for whom the token is being created.
/// If not specified, the token will be created for the current authenticated user.
/// Accessing this for another user typically requires `USER_MANAGE` permissions.
pub user_id: Option<u64>,
}
impl AccessTokenCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if self.accounts.is_empty() {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in &self.accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, Object)]
pub struct AccessTokenUpdateRequest {
/// A set of account information associated with the token.
pub accounts: Option<BTreeSet<u64>>,
/// An optional description of the token's purpose or usage.
#[oai(validator(max_length = "255"))]
pub description: Option<String>,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
impl AccessTokenUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(accounts) = &self.accounts {
if accounts.is_empty() {
if let Some(expire_in) = self.expire_in {
if expire_in == 0 {
return Err(raise_error!(
"Account list cannot be empty. Please provide at least one valid account ID."
.into(),
"expire_in must be a positive duration in hours; zero is not allowed.".into(),
ErrorCode::InvalidParameter
));
}
let mut not_found = Vec::new();
for account_id in accounts {
if AccountModel::find(*account_id).await?.is_none() {
not_found.push(*account_id);
}
}
if !not_found.is_empty() {
return Err(raise_error!(
format!("The following account IDs were not found: {}. Please provide valid account IDs.", not_found.iter().map(u64::to_string).collect::<Vec<_>>().join(", ")).into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
impl AccessTokenUpdateRequest {
pub fn should_skip_update(&self) -> bool {
self.description.is_none() && self.accounts.is_none() && self.acl.is_none()
}
}

View File

@@ -16,110 +16,109 @@
// 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::{
// decrypt, encrypt, generate_token,
// modules::{
// error::{code::ErrorCode, BichonResult},
// settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
// },
// raise_error,
// };
// use std::fs::File;
// use std::io::Write;
use crate::{
decrypt, encrypt, generate_token,
modules::{
error::{code::ErrorCode, BichonResult},
settings::{dir::DATA_DIR_MANAGER, system::SystemSetting},
},
raise_error,
};
use std::fs::File;
use std::io::Write;
// pub const ROOT_TOKEN: &str = "root-token";
// pub const ROOT_PASSWORD: &str = "root-password";
// pub const DEFAULT_ROOT_PASSWORD: &str = "root";
// pub const ROOT_TOKEN_FILE: &str = "root";
pub const ROOT_TOKEN: &str = "root-token";
pub const ROOT_PASSWORD: &str = "root-password";
pub const DEFAULT_ROOT_PASSWORD: &str = "root";
pub const ROOT_TOKEN_FILE: &str = "root";
// async fn get_or_generate(
// key: &str,
// generate: impl Fn() -> String,
// save_file_name: Option<&str>,
// force: bool,
// ) -> BichonResult<String> {
// if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
// if force {
// // If force is true, write the existing value to the file
// if let Some(filename) = save_file_name {
// save_to_file(&existing_value.to_string(), filename).await?;
// }
// }
// Ok(existing_value)
// } else {
// // If no value exists, generate a new value
// let new_value = generate();
// SystemSetting::set_value(key, new_value.clone()).await?;
async fn get_or_generate(
key: &str,
generate: impl Fn() -> String,
save_file_name: Option<&str>,
force: bool,
) -> BichonResult<String> {
if let Some(existing_value) = SystemSetting::get_existing_value(key)? {
if force {
// If force is true, write the existing value to the file
if let Some(filename) = save_file_name {
save_to_file(&existing_value.to_string(), filename).await?;
}
}
Ok(existing_value)
} else {
// If no value exists, generate a new value
let new_value = generate();
SystemSetting::set_value(key, new_value.clone()).await?;
// // Write the new value to the file, if specified
// if let Some(filename) = save_file_name {
// save_to_file(&new_value.to_string(), filename).await?;
// }
// Ok(new_value)
// }
// }
// Write the new value to the file, if specified
if let Some(filename) = save_file_name {
save_to_file(&new_value.to_string(), filename).await?;
}
Ok(new_value)
}
}
// pub async fn ensure_root_token() -> BichonResult<()> {
// get_or_generate(
// ROOT_TOKEN,
// || generate_token!(128),
// Some(ROOT_TOKEN_FILE),
// true,
// )
// .await?;
// Ok(())
// }
pub async fn ensure_root_token() -> BichonResult<()> {
get_or_generate(
ROOT_TOKEN,
|| generate_token!(128),
Some(ROOT_TOKEN_FILE),
true,
)
.await?;
Ok(())
}
// pub async fn reset_root_token() -> BichonResult<String> {
// let new_token = generate_token!(128);
// save_new_token(&new_token).await?;
// save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
// Ok(new_token)
// }
pub async fn reset_root_token() -> BichonResult<String> {
let new_token = generate_token!(128);
save_new_token(&new_token).await?;
save_to_file(&new_token, ROOT_TOKEN_FILE).await?;
Ok(new_token)
}
// async fn save_new_token(token: &str) -> BichonResult<()> {
// let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
// setting.set().await
// }
async fn save_new_token(token: &str) -> BichonResult<()> {
let setting = SystemSetting::new(ROOT_TOKEN.to_string(), token.to_string());
setting.set().await
}
// async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
// let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
// let mut file = File::create(&file_path)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// writeln!(file, "{}", content)
// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Ok(())
// }
async fn save_to_file(content: &str, filename: &str) -> BichonResult<()> {
let file_path = DATA_DIR_MANAGER.root_dir.join(filename);
let mut file = File::create(&file_path)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
writeln!(file, "{}", content)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
}
// pub fn check_root_password(password: &str) -> BichonResult<String> {
// let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
// let matched = match stored_encrypted_password {
// Some(ref stored) => {
// let decrypted = decrypt!(stored)?;
// decrypted == password
// }
// None => DEFAULT_ROOT_PASSWORD == password,
// };
pub fn check_root_password(password: &str) -> BichonResult<String> {
let stored_encrypted_password = SystemSetting::get_existing_value(ROOT_PASSWORD)?;
let matched = match stored_encrypted_password {
Some(ref stored) => {
let decrypted = decrypt!(stored)?;
decrypted == password
}
None => DEFAULT_ROOT_PASSWORD == password,
};
// if !matched {
// return Err(raise_error!(
// "Invalid password".into(),
// ErrorCode::PermissionDenied
// ));
// }
if !matched {
return Err(raise_error!(
"Invalid password".into(),
ErrorCode::PermissionDenied
));
}
// let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
// raise_error!(
// "Root token not found — this should never happen".into(),
// ErrorCode::InternalError
// )
// })?;
let root_token = SystemSetting::get_existing_value(ROOT_TOKEN)?.ok_or_else(|| {
raise_error!(
"Root token not found — this should never happen".into(),
ErrorCode::InternalError
)
})?;
// Ok(root_token)
// }
Ok(root_token)
}
pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
let encrypted_password = encrypt!(new_password)?;
SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
}
// pub async fn set_root_password(new_password: &str) -> BichonResult<()> {
// let encrypted_password = encrypt!(new_password)?;
// SystemSetting::set_value(ROOT_PASSWORD, encrypted_password).await
// }

44
src/modules/token/view.rs Normal file
View File

@@ -0,0 +1,44 @@
//
// 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::token::TokenType;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessTokenResp {
pub user_id: u64,
pub token: String,
/// An optional name of the token.
pub name: Option<String>,
/// Token type: WebUI or API
pub token_type: TokenType,
/// The timestamp (in milliseconds since epoch) when the token was created.
pub created_at: i64,
/// The timestamp (in milliseconds since epoch) when the token was last updated.
pub updated_at: i64,
/// The timestamp (in milliseconds since epoch) when the token expires.
/// None means the token does not expire (this applies only to API tokens).
pub expire_at: Option<i64>,
/// The timestamp (in milliseconds since epoch) when the token was last used.
pub last_access_at: i64,
pub user_name: String,
pub user_email: String,
}

73
src/modules/users/acl.rs Normal file
View File

@@ -0,0 +1,73 @@
//
// 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, net::IpAddr};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::{modules::error::{BichonResult, code::ErrorCode}, raise_error};
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct RateLimit {
/// The time window in seconds for the rate limit.
pub interval: u64,
/// The maximum number of allowed requests within the time window.
pub quota: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Object)]
pub struct AccessControl {
/// An optional set of valid IPv4 or IPv6 addresses allowed to use the access token.
pub ip_whitelist: Option<BTreeSet<String>>,
/// An optional rate limit configuration for the access token.
pub rate_limit: Option<RateLimit>,
}
impl AccessControl {
pub fn validate(&self) -> BichonResult<()> {
if let Some(ip_whitelist) = &self.ip_whitelist {
for ip in ip_whitelist {
if ip.parse::<IpAddr>().is_err() {
return Err(raise_error!(
format!("Invalid IP address: {}", ip),
ErrorCode::InvalidParameter
));
}
}
}
// Validate rate limit
if let Some(rate_limit) = &self.rate_limit {
if rate_limit.interval < 1 {
return Err(raise_error!(
"Rate limit interval must be at least 1 second".into(),
ErrorCode::InvalidParameter
));
}
if rate_limit.quota < 1 {
return Err(raise_error!(
"Rate limit quota must be at least 1".into(),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}

View File

@@ -16,30 +16,17 @@
// 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::{
context::Initialize,
error::BichonResult,
users::{role::UserRole, UserModel},
};
interface AccountInfo {
id: number;
email: string;
pub struct UserManager;
impl Initialize for UserManager {
async fn initialize() -> BichonResult<()> {
UserRole::ensure_default_roles_exists().await?;
UserModel::ensure_default_admin_exists().await
}
}
interface RateLimit {
quota: number;
interval: number;
}
interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
interface AccessToken {
token: string;
accounts: AccountInfo[];
created_at: number;
updated_at: number;
description?: string;
last_access_at: number;
acl?: AccessControl;
}
export type { AccessToken, AccountInfo, AccessControl, RateLimit };

View File

@@ -0,0 +1,49 @@
//
// 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 poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::{
database::{list_all_impl, manager::DB_MANAGER},
error::BichonResult,
users::BichonUser,
};
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct MinimalUser {
pub id: u64,
pub username: String,
pub email: String,
}
impl MinimalUser {
pub async fn list_all() -> BichonResult<Vec<MinimalUser>> {
let all_users = list_all_impl::<BichonUser>(DB_MANAGER.meta_db()).await?;
let minimal_list = all_users
.into_iter()
.map(|user| MinimalUser {
id: user.id,
username: user.username,
email: user.email,
})
.collect();
Ok(minimal_list)
}
}

692
src/modules/users/mod.rs Normal file
View File

@@ -0,0 +1,692 @@
//
// 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::{
decrypt, encrypt, generate_token, id,
modules::{
database::{
async_find_impl, batch_delete_impl, delete_impl, list_all_impl, manager::DB_MANAGER,
secondary_find_impl, update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
token::{AccessTokenModel, AccessTokenModelKey, TokenType},
users::{
acl::AccessControl,
payload::{UserCreateRequest, UserUpdateRequest},
permissions::Permission,
role::{UserRole, DEFAULT_ADMIN_ROLE_ID},
view::UserView,
},
},
raise_error, utc_now,
};
use itertools::Itertools;
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use tracing::warn;
pub mod acl;
pub mod manager;
pub mod minimal;
pub mod payload;
pub mod permissions;
pub mod role;
pub mod view;
pub type UserModel = BichonUserV2;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct LoginResult {
pub success: bool,
pub error_message: Option<String>,
pub access_token: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
pub const DEFAULT_ADMIN_USER_ID: u64 = 100000000000000;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 1)]
#[native_db]
pub struct BichonUser {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 10, version = 2, from = BichonUser)]
#[native_db]
pub struct BichonUserV2 {
#[primary_key]
pub id: u64,
#[secondary_key(unique)]
pub username: String,
#[secondary_key(unique)]
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub description: Option<String>,
/// System Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl BichonUserV2 {
pub async fn list_all() -> BichonResult<Vec<UserModel>> {
Ok(list_all_impl::<UserModel>(DB_MANAGER.meta_db()).await?)
}
async fn get_all_permissions(&self) -> HashSet<String> {
let mut all_perms = HashSet::new();
for &role_id in &self.global_roles {
if let Ok(Some(role)) = UserRole::find(role_id).await {
for perm in role.permissions {
all_perms.insert(perm);
}
}
}
all_perms
}
pub fn to_view(self, role_lookup: &BTreeMap<u64, UserRole>) -> UserView {
let global_roles_names = self
.global_roles
.iter()
.filter_map(|role_id| role_lookup.get(role_id))
.map(|role| role.name.clone())
.collect();
let account_roles_summary = self
.account_access_map
.iter()
.map(|(acc_id, role_id)| {
let role_name = role_lookup
.get(role_id)
.map(|r| r.name.clone())
.unwrap_or_else(|| "Unknown Role".to_string());
(*acc_id, role_name)
})
.collect();
let global_permissions = {
let mut perms = BTreeSet::new();
for role_id in &self.global_roles {
if let Some(role) = role_lookup.get(role_id) {
perms.extend(role.permissions.iter().cloned());
}
}
perms.into_iter().collect()
};
let account_permissions = {
let mut map: BTreeMap<u64, BTreeSet<String>> = BTreeMap::new();
for (account_id, role_id) in &self.account_access_map {
if let Some(role) = role_lookup.get(role_id) {
let entry = map.entry(*account_id).or_default();
entry.extend(role.permissions.iter().cloned());
}
}
map.into_iter()
.map(|(acc_id, perms)| (acc_id, perms.into_iter().collect()))
.collect()
};
UserView {
id: self.id,
username: self.username,
email: self.email,
password: self.password.map(|_| "************".to_string()),
account_access_map: self.account_access_map,
account_roles_summary,
description: self.description,
global_roles: self.global_roles,
global_roles_names,
avatar: self.avatar,
created_at: self.created_at,
updated_at: self.updated_at,
acl: self.acl,
account_permissions,
global_permissions,
theme: self.theme,
language: self.language,
}
}
pub async fn is_admin(&self) -> bool {
self.get_all_permissions().await.contains(Permission::ROOT)
}
pub async fn ensure_default_admin_exists() -> BichonResult<()> {
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
// 1. Try to get the existing admin user
let admin = rw
.get()
.primary::<UserModel>(DEFAULT_ADMIN_USER_ID)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
if admin.is_none() {
// 2. Insert the BichonUser with the updated schema
rw.insert(UserModel {
id: DEFAULT_ADMIN_USER_ID,
username: "admin".into(),
email: "placeholder@example.com".into(),
password: Some(encrypt!("admin@bichon")?),
// Use global_roles as defined in our new schema
global_roles: vec![DEFAULT_ADMIN_ROLE_ID],
// Admin usually doesn't need specific scoped access
account_access_map: BTreeMap::new(),
avatar: None,
created_at: now,
updated_at: now,
description: Some("System default administrator".into()),
acl: None,
theme: None,
language: None,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// 3. Generate and insert an initial access token for the first-time setup
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Initial Setup Token".into()),
user_id: DEFAULT_ADMIN_USER_ID,
token_type: TokenType::WebUI,
expire_at: None, // Admin setup token usually persistent until changed
};
rw.upsert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
Ok(())
})
.await?;
Ok(())
}
pub async fn authenticate_user(
username: String,
password: String,
) -> BichonResult<LoginResult> {
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.clone(),
)
.await?;
let user = match user_option {
Some(u) => u,
None => {
match secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
username,
)
.await?
{
Some(u) => u,
None => {
return Ok(LoginResult {
success: false,
error_message: Some("User or email not found.".to_string()),
access_token: None,
theme: None,
language: None,
});
}
}
}
};
match user.password.as_ref() {
Some(encrypted_password) => {
let decrypted = decrypt!(encrypted_password)?;
if password == decrypted {
let new_token = AccessTokenModel::reset_webui_token(user.id).await?;
Ok(LoginResult {
success: true,
error_message: None,
access_token: Some(new_token),
theme: user.theme,
language: user.language,
})
} else {
warn!(
"Login failed: Incorrect password for user '{}'.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some("Incorrect password.".to_string()),
access_token: None,
theme: None,
language: None,
})
}
}
None => {
warn!(
"Login failed: User '{}' has no password set.",
user.username
);
Ok(LoginResult {
success: false,
error_message: Some(
format!(
"User '{}' has no password set. Please try logging in with an alternative method (e.g., OAuth/SSO).",
user.username
)
),
access_token: None,
theme: None,
language: None,
})
}
}
}
pub async fn find(user_id: u64) -> BichonResult<Option<UserModel>> {
async_find_impl(DB_MANAGER.meta_db(), user_id).await
}
pub async fn check_username_conflict(username: &str) -> BichonResult<()> {
// Check username duplicate
if secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn check_email_conflict(email: &str) -> BichonResult<()> {
// Check email duplicate
if secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
email.to_string(),
)
.await?
.is_some()
{
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
Ok(())
}
pub async fn create(request: UserCreateRequest) -> BichonResult<UserModel> {
request.validate().await?;
Self::check_username_conflict(&request.username).await?;
Self::check_email_conflict(&request.email).await?;
let password_hash = Some(encrypt!(&request.password)?);
let now = utc_now!();
let user = UserModel {
id: id!(96),
username: request.username,
email: request.email,
password: password_hash,
global_roles: request.global_roles,
avatar: request.avatar_base64,
description: request.description,
acl: request.acl,
created_at: now,
updated_at: now,
account_access_map: request.account_access_map,
theme: request.theme,
language: request.language,
};
let user_clone = user.clone();
// 4. Atomic transaction for User and Initial Token
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let user_id = user.id;
// Insert User
rw.insert(user)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
// Create initial WebUI access token
let access_token = AccessTokenModel {
token: generate_token!(128),
created_at: now,
updated_at: now,
last_access_at: Default::default(),
name: Some("Default WebUI Token".into()),
user_id,
token_type: TokenType::WebUI,
expire_at: None,
};
rw.insert(access_token)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(())
})
.await?;
Ok(user_clone)
}
//delete user
pub async fn remove(id: u64) -> BichonResult<()> {
if DEFAULT_ADMIN_USER_ID == id {
return Err(raise_error!(
format!("The default admin user (id={}) cannot be removed", id),
ErrorCode::PermissionDenied
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<BichonUser>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("The User with id={id} that you want to delete was not found."),
ErrorCode::ResourceNotFound
)
})
})
.await?;
batch_delete_impl(DB_MANAGER.meta_db(), move |rw| {
let tokens: Vec<AccessTokenModel> = rw
.scan()
.secondary::<AccessTokenModel>(AccessTokenModelKey::user_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.start_with(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.try_collect()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
Ok(tokens)
})
.await?;
Ok(())
}
pub async fn update(id: u64, request: UserUpdateRequest) -> BichonResult<()> {
let _ = &request.validate().await?;
let password_changed = request.password.is_some();
if DEFAULT_ADMIN_USER_ID == id && request.global_roles.is_some() {
return Err(raise_error!(
format!("The role assignments for default admin (id={}) are immutable to ensure system accessibility.", id),
ErrorCode::Forbidden
));
}
if let Some(username) = &request.username {
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::username,
username.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Username '{}' is already taken.", username).into(),
ErrorCode::AlreadyExists
));
}
}
}
if let Some(email) = &request.email {
let user_option = secondary_find_impl::<UserModel>(
DB_MANAGER.meta_db(),
BichonUserV2Key::email,
email.to_string(),
)
.await?;
if let Some(u) = user_option {
if u.id != id {
return Err(raise_error!(
format!("Email '{}' is already registered.", email).into(),
ErrorCode::AlreadyExists
));
}
}
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserModel>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(username) = request.username {
updated.username = username;
}
if let Some(email) = request.email {
updated.email = email;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(password) = request.password {
updated.password = Some(encrypt!(&password)?);
}
if let Some(global_roles) = request.global_roles {
updated.global_roles = global_roles;
}
if let Some(acl) = request.acl {
updated.acl = Some(acl);
}
if let Some(account_access_map) = request.account_access_map {
updated.account_access_map = account_access_map;
}
if let Some(avatar_base64) = request.avatar_base64 {
updated.avatar = Some(avatar_base64);
}
if let Some(theme) = request.theme {
updated.theme = Some(theme);
}
if let Some(language) = request.language {
updated.language = Some(language);
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
if password_changed {
AccessTokenModel::reset_webui_token(id).await?;
}
Ok(())
}
async fn list_authorized_users(account_id: u64) -> BichonResult<Vec<UserModel>> {
let all = Self::list_all().await?;
let result: Vec<UserModel> = all
.into_iter()
.filter(|e| e.account_access_map.contains_key(&account_id))
.collect();
Ok(result)
}
pub async fn cleanup_account(account_id: u64) -> BichonResult<()> {
let users = Self::list_authorized_users(account_id).await?;
if users.is_empty() {
return Ok(());
}
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for user in users {
let current = rw
.get()
.primary::<UserModel>(user.id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("User {} not found", user.id),
ErrorCode::ResourceNotFound
)
})?;
let mut updated = current.clone();
if updated.account_access_map.remove(&account_id).is_some() {
updated.updated_at = now;
rw.update(current, updated)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
}
impl From<BichonUserV2> for BichonUser {
fn from(value: BichonUserV2) -> Self {
BichonUser {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
}
}
}
impl From<BichonUser> for BichonUserV2 {
fn from(value: BichonUser) -> Self {
BichonUserV2 {
id: value.id,
username: value.username,
email: value.email,
password: value.password,
account_access_map: value.account_access_map,
description: value.description,
global_roles: value.global_roles,
avatar: value.avatar,
created_at: value.created_at,
updated_at: value.updated_at,
acl: value.acl,
theme: None,
language: None,
}
}
}

View File

@@ -0,0 +1,453 @@
//
// 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::migration::AccountModel,
error::{code::ErrorCode, BichonResult},
users::{
acl::AccessControl,
permissions::{Permission, VALID_PERMISSION_SET},
role::{RoleType, UserRole},
},
utils::decode_avatar_bytes,
},
raise_error,
};
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
fn allowed_themes() -> HashSet<&'static str> {
["light", "dark"].into_iter().collect()
}
fn allowed_languages() -> HashSet<&'static str> {
[
"ar", "da", "de", "en", "es", "fi", "fr", "it", "jp", "ko", "nl", "no", "pl", "pt", "ru",
"sv", "zh", "zh-tw",
]
.into_iter()
.collect()
}
fn validate_option_in_set(
value: &Option<String>,
allowed: &std::collections::HashSet<&'static str>,
field_name: &str,
) -> BichonResult<()> {
if let Some(v) = value {
if !allowed.contains(v.as_str()) {
return Err(raise_error!(
format!("invalid {} value: '{}'", field_name, v),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
fn validate_theme(theme: &Option<String>) -> BichonResult<()> {
validate_option_in_set(theme, &allowed_themes(), "theme")
}
fn validate_language(language: &Option<String>) -> BichonResult<()> {
validate_option_in_set(language, &allowed_languages(), "language")
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleCreateRequest {
pub name: String,
pub role_type: RoleType,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
}
impl RoleCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let trimmed_name = self.name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be empty or consist only of whitespace.".into(),
ErrorCode::InvalidParameter
));
}
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
if self.permissions.is_empty() {
return Err(raise_error!(
"Role must be assigned at least one permission.".into(),
ErrorCode::InvalidParameter
));
}
for permission in &self.permissions {
if !VALID_PERMISSION_SET.contains(permission.as_str()) {
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the request.",
permission
),
ErrorCode::InvalidParameter
));
}
}
Permission::validate_role_permissions(&self.role_type, &self.permissions)?;
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct RoleUpdateRequest {
pub name: Option<String>,
pub description: Option<String>,
pub permissions: Option<BTreeSet<String>>,
}
impl RoleUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
// 1. Ensure at least one field is provided for the update
if self.name.is_none() && self.description.is_none() && self.permissions.is_none() {
return Err(raise_error!(
"Update request must contain at least one field to modify (name, description, or permissions).".into(),
ErrorCode::InvalidParameter
));
}
// 2. Validate Name if present
if let Some(name) = &self.name {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
return Err(raise_error!(
"Role name cannot be set to an empty string or consist only of whitespace."
.into(),
ErrorCode::InvalidParameter
));
}
// Prevent renaming to reserved system names
let name_lower = trimmed_name.to_lowercase();
if name_lower == "admin" || name_lower == "manager" || name_lower == "viewer" {
return Err(raise_error!(
format!(
"The name '{}' is reserved for system builtin roles.",
trimmed_name
),
ErrorCode::InvalidParameter
));
}
}
// 3. Validate Permissions if present
if let Some(permissions) = &self.permissions {
// Ensure the role doesn't end up with zero permissions
if permissions.is_empty() {
return Err(raise_error!(
"Permissions list cannot be empty. A role must have at least one permission."
.into(),
ErrorCode::InvalidParameter
));
}
// Check for invalid permission strings using a functional approach
if let Some(invalid_permission) = permissions
.iter()
.find(|p| !VALID_PERMISSION_SET.contains(p.as_str()))
{
return Err(raise_error!(
format!(
"Invalid permission '{}' specified in the update request.",
invalid_permission
),
ErrorCode::InvalidParameter
));
}
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserCreateRequest {
pub username: String,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: String,
pub password: String,
/// Global Roles: System-wide permissions (e.g., Admin, User Manager).
pub global_roles: Vec<u64>,
/// Scoped Access: List of accounts paired with specific roles.
/// This allows different permissions per account.
pub account_access_map: BTreeMap<u64, u64>,
pub acl: Option<AccessControl>,
pub avatar_base64: Option<String>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl UserCreateRequest {
pub async fn validate(&self) -> BichonResult<()> {
let username_len = self.username.len();
// 1. Username constraints
if username_len < 5 {
return Err(raise_error!(
"Username must be at least 5 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if username_len > 32 {
return Err(raise_error!(
"Username cannot exceed 32 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 2. Password constraints
let password_len = self.password.len();
if password_len < 8 {
return Err(raise_error!(
"Password must be at least 8 characters long.".into(),
ErrorCode::InvalidParameter
));
}
if password_len > 256 {
return Err(raise_error!(
"Password cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
// 3. Global Roles validation
if self.global_roles.is_empty() {
return Err(raise_error!(
"Global roles list cannot be empty. At least one role must be selected.".into(),
ErrorCode::InvalidParameter
));
}
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
for rid in &self.global_roles {
match role_type_map.get(rid) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", rid),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", rid),
ErrorCode::InvalidParameter
))
}
}
}
for (aid, rid) in &self.account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description cannot exceed 256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(avatar_base64) = &self.avatar_base64 {
decode_avatar_bytes(&avatar_base64)?;
}
Ok(())
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct UserUpdateRequest {
pub username: Option<String>,
#[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
pub email: Option<String>,
pub password: Option<String>,
pub avatar_base64: Option<String>,
pub global_roles: Option<Vec<u64>>,
/// Scoped Access
pub account_access_map: Option<BTreeMap<u64, u64>>,
pub acl: Option<AccessControl>,
pub description: Option<String>,
pub theme: Option<String>,
pub language: Option<String>,
}
impl UserUpdateRequest {
pub async fn validate(&self) -> BichonResult<()> {
if let Some(username) = &self.username {
let len = username.len();
if len < 5 || len > 32 {
return Err(raise_error!(
"Username must be 5-32 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(password) = &self.password {
let len = password.len();
if len < 8 || len > 256 {
return Err(raise_error!(
"Password must be 8-256 characters.".into(),
ErrorCode::InvalidParameter
));
}
}
validate_theme(&self.theme)?;
validate_language(&self.language)?;
let all_roles = UserRole::list_all().await?;
let role_type_map: HashMap<u64, RoleType> =
all_roles.into_iter().map(|r| (r.id, r.role_type)).collect();
if let Some(roles) = &self.global_roles {
if roles.is_empty() {
return Err(raise_error!(
"Roles list cannot be empty.".into(),
ErrorCode::InvalidParameter
));
}
for role_id in roles {
match role_type_map.get(role_id) {
Some(RoleType::Global) => {}
Some(_) => {
return Err(raise_error!(
format!("Role {} is not a System role", role_id),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("System Role {} not found", role_id),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(account_access_map) = &self.account_access_map {
for (aid, rid) in account_access_map {
if AccountModel::find(*aid).await?.is_none() {
return Err(raise_error!(
format!("Account {} not found", aid),
ErrorCode::InvalidParameter
));
}
match role_type_map.get(rid) {
Some(RoleType::Account) => {}
Some(_) => {
return Err(raise_error!(
format!(
"Role {} assigned to account {} must be an Account role",
rid, aid
),
ErrorCode::InvalidParameter
))
}
None => {
return Err(raise_error!(
format!("Role {} for account {} not found", rid, aid),
ErrorCode::InvalidParameter
))
}
}
}
}
if let Some(desc) = &self.description {
if desc.len() > 256 {
return Err(raise_error!(
"Description too long.".into(),
ErrorCode::InvalidParameter
));
}
}
if let Some(acl) = &self.acl {
acl.validate()?;
}
if let Some(avatar) = &self.avatar_base64 {
decode_avatar_bytes(avatar)?;
}
Ok(())
}
}

View File

@@ -0,0 +1,239 @@
//
// 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, HashSet},
sync::LazyLock,
};
use crate::{
modules::{
error::{code::ErrorCode, BichonResult},
users::role::RoleType,
},
raise_error,
};
pub static VALID_PERMISSION_SET: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
Permission::all_permissions()
.into_iter()
.map(|(key, _)| key)
.collect()
});
pub struct Permission;
impl Permission {
// ----------------------------------------------------------------------
// 1. Global Management Permissions (System, Users, Tokens)
// ----------------------------------------------------------------------
/// Basic platform access. Required for any user to log in and access the dashboard.
/// This provides no administrative powers.
pub const SYSTEM_ACCESS: &str = "system:access";
/// Manage core system configurations (OAuth Client ID/Secret, Proxy settings).
pub const ROOT: &str = "system:root";
/// Create, modify, and delete all users and their roles (Admin only).
pub const USER_MANAGE: &str = "user:manage";
/// View the minimal user list and basic profiles (Managers and Admins).
pub const USER_VIEW: &str = "user:view";
/// View and revoke all access tokens in the system.
pub const TOKEN_MANAGE: &str = "token:manage";
/// Create new email account connections.
pub const ACCOUNT_CREATE: &str = "account:create";
// ----------------------------------------------------------------------
// 2. Global "ALL" Scoped Permissions (Reserved for Admin)
// ----------------------------------------------------------------------
/// Manage configuration for all accounts (Global control).
pub const ACCOUNT_MANAGE_ALL: &str = "account:manage:all";
/// Read mail data from all accounts (Search, view messages).
pub const DATA_READ_ALL: &str = "data:read:all";
/// Download raw EML/MIME files from all accounts.
pub const DATA_RAW_DOWNLOAD_ALL: &str = "data:raw:download:all";
/// Delete messages from all accounts.
pub const DATA_DELETE_ALL: &str = "data:delete:all";
/// Manage metadata (e.g., tags, categories, notes) for messages in ALL email accounts.
pub const DATA_MANAGE_ALL: &str = "data:manage:all";
/// Export messages in batches from all accounts.
pub const DATA_EXPORT_BATCH_ALL: &str = "data:export:batch:all";
// ----------------------------------------------------------------------
// 3. Scoped/Limited Permissions (Manager & Viewer)
// Authorization requires checking the user's Account Access List (ACL)
// ----------------------------------------------------------------------
/// Manage (modify/delete/sync) configuration for a specific set of accounts.
pub const ACCOUNT_MANAGE: &str = "account:manage";
/// Read details and sync status for a specific set of accounts.
pub const ACCOUNT_READ_DETAILS: &str = "account:read_details";
/// Manage mail data metadata (e.g., updating tags, adding notes)
/// for specific accounts.
pub const DATA_MANAGE: &str = "data:manage";
/// Read mail data (Search, view) from a specific set of accounts.
pub const DATA_READ: &str = "data:read";
/// Download raw EML/MIME files from a specific set of accounts.
pub const DATA_RAW_DOWNLOAD: &str = "data:raw:download";
/// Delete messages from a specific set of accounts.
pub const DATA_DELETE: &str = "data:delete";
/// Export messages in batches from a specific set of accounts.
pub const DATA_EXPORT_BATCH: &str = "data:export:batch";
/// Import EML/PST data into a SPECIFIC account.
/// Authorization requires checking access to the target account_id.
pub const DATA_IMPORT_BATCH: &str = "data:import:batch";
pub fn global_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::SYSTEM_ACCESS,
"Basic platform access for dashboard and personal settings.",
),
(Self::ROOT, "Full system access and configuration."),
(Self::USER_MANAGE, "Create, update, and delete users."),
(
Self::USER_VIEW,
"Read-only access to user list and profiles.",
),
(Self::TOKEN_MANAGE, "View and revoke all active API tokens."),
(
Self::ACCOUNT_CREATE,
"Connect new email accounts to the system.",
),
(
Self::ACCOUNT_MANAGE_ALL,
"Manage configurations for all email accounts.",
),
(
Self::DATA_READ_ALL,
"Search and read messages across all accounts.",
),
(
Self::DATA_MANAGE_ALL,
"Manage metadata and tags for all accounts.",
),
(
Self::DATA_RAW_DOWNLOAD_ALL,
"Download raw EML data from any account.",
),
(
Self::DATA_DELETE_ALL,
"Permanently delete messages from any account.",
),
(
Self::DATA_EXPORT_BATCH_ALL,
"Export bulk message data from all accounts.",
),
]
}
pub fn account_permissions() -> Vec<(&'static str, &'static str)> {
vec![
(
Self::ACCOUNT_MANAGE,
"Update or sync settings for authorized accounts.",
),
(
Self::ACCOUNT_READ_DETAILS,
"View status and details of authorized accounts.",
),
(
Self::DATA_READ,
"Read messages from authorized email accounts.",
),
(
Self::DATA_MANAGE,
"Manage tags and metadata for authorized accounts.",
),
(
Self::DATA_RAW_DOWNLOAD,
"Download raw EML files from authorized accounts.",
),
(
Self::DATA_DELETE,
"Delete messages from authorized email accounts.",
),
(
Self::DATA_EXPORT_BATCH,
"Export messages from authorized accounts.",
),
(
Self::DATA_IMPORT_BATCH,
"Import external EML/PST data into authorized accounts.",
),
]
}
pub fn all_permissions() -> Vec<(&'static str, &'static str)> {
let mut all = Self::global_permissions();
all.extend(Self::account_permissions());
all
}
fn is_account_permission(perm: &str) -> bool {
Self::account_permissions().iter().any(|(p, _)| *p == perm)
}
fn is_global_permission(perm: &str) -> bool {
Self::global_permissions().iter().any(|(p, _)| *p == perm)
}
pub fn validate_role_permissions(
role_type: &RoleType,
permissions: &BTreeSet<String>,
) -> BichonResult<()> {
for p in permissions {
match role_type {
RoleType::Global => {
if !Self::is_global_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Global permission", p),
ErrorCode::InvalidParameter
));
}
}
RoleType::Account => {
if !Self::is_account_permission(p) {
return Err(raise_error!(
format!("Permission '{}' is not a valid Account permission", p),
ErrorCode::InvalidParameter
));
}
}
}
}
Ok(())
}
}

359
src/modules/users/role.rs Normal file
View File

@@ -0,0 +1,359 @@
//
// 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, HashSet},
fmt::{self, Display},
};
use native_db::*;
use native_model::{native_model, Model};
use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize};
use crate::{
id,
modules::{
database::{
async_find_impl, delete_impl, insert_impl, list_all_impl, manager::DB_MANAGER,
update_impl, with_transaction,
},
error::{code::ErrorCode, BichonResult},
users::{
payload::{RoleCreateRequest, RoleUpdateRequest},
permissions::*,
},
},
raise_error, utc_now,
};
/// Enumerates the built-in roles in the Bichon system.
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum BuiltinRole {
Admin,
Manager,
Member,
AccountManager,
AccountViewer,
}
impl Display for BuiltinRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
BuiltinRole::Admin => "admin",
BuiltinRole::Manager => "manager",
BuiltinRole::Member => "member",
BuiltinRole::AccountManager => "account_manager",
BuiltinRole::AccountViewer => "account_viewer",
};
write!(f, "{}", s)
}
}
impl BuiltinRole {
pub fn description(&self) -> &'static str {
match self {
BuiltinRole::Admin => {
"Full system administrator with unrestricted access to all accounts, user management, and system configurations."
}
BuiltinRole::Manager => {
"Standard operational manager. Can manage users, create accounts, and perform data operations on authorized email accounts."
}
BuiltinRole::Member => {
"Regular platform member. Provides basic login access to the system without any administrative or global management privileges."
}
BuiltinRole::AccountManager => {
"Specific account manager. Has full administrative control over a particular email account, including configuration and data deletion."
}
BuiltinRole::AccountViewer => {
"Specific account observer. Has read-only access to messages and metadata for a particular email account."
}
}
}
/// Retrieves the set of static permissions associated with the role.
pub fn get_permissions(&self) -> HashSet<&'static str> {
match self {
BuiltinRole::Admin => Self::admin_permissions(),
BuiltinRole::Manager => Self::manager_permissions(),
BuiltinRole::Member => Self::member_permissions(),
BuiltinRole::AccountManager => Self::account_owner_permissions(),
BuiltinRole::AccountViewer => Self::account_viewer_permissions(),
}
}
/// Admin Role: Full control over the system and all data.
fn admin_permissions() -> HashSet<&'static str> {
[
// System-Wide
Permission::ROOT,
Permission::USER_MANAGE,
Permission::USER_VIEW,
Permission::TOKEN_MANAGE,
// Account Configuration
Permission::ACCOUNT_CREATE,
Permission::ACCOUNT_MANAGE_ALL, // Global account management
// Data Access (Global ALL)
Permission::DATA_READ_ALL,
Permission::DATA_MANAGE_ALL,
Permission::DATA_RAW_DOWNLOAD_ALL,
Permission::DATA_DELETE_ALL,
Permission::DATA_EXPORT_BATCH_ALL,
]
.into_iter()
.collect()
}
/// Manager Role: Data and account configuration management, limited user management.
/// ALL data/account access must be scoped by the user's ACL.
fn manager_permissions() -> HashSet<&'static str> {
[Permission::USER_VIEW, Permission::ACCOUNT_CREATE]
.into_iter()
.collect()
}
fn member_permissions() -> HashSet<&'static str> {
[Permission::SYSTEM_ACCESS].into_iter().collect()
}
fn account_owner_permissions() -> HashSet<&'static str> {
[
Permission::ACCOUNT_MANAGE,
Permission::ACCOUNT_READ_DETAILS,
Permission::DATA_READ,
Permission::DATA_MANAGE,
Permission::DATA_RAW_DOWNLOAD,
Permission::DATA_DELETE,
Permission::DATA_EXPORT_BATCH,
Permission::DATA_IMPORT_BATCH,
]
.into_iter()
.collect()
}
fn account_viewer_permissions() -> HashSet<&'static str> {
[Permission::ACCOUNT_READ_DETAILS, Permission::DATA_READ]
.into_iter()
.collect()
}
}
// Global Roles (Starting with 1)
pub const DEFAULT_ADMIN_ROLE_ID: u64 = 100_000_000_000_000; // System Admin
pub const DEFAULT_MANAGER_ROLE_ID: u64 = 100_100_000_000_000; // System Manager
pub const DEFAULT_MEMBER_ROLE_ID: u64 = 100_200_000_000_000; // Regular Member (system:access)
// Account-specific Roles (Starting with 2)
pub const DEFAULT_ACCOUNT_MANAGER_ROLE_ID: u64 = 200_100_000_000_000;
pub const DEFAULT_ACCOUNT_VIEWER_ROLE_ID: u64 = 200_200_000_000_000;
fn is_builtin(id: u64) -> bool {
matches!(
id,
DEFAULT_ADMIN_ROLE_ID
| DEFAULT_MANAGER_ROLE_ID
| DEFAULT_MEMBER_ROLE_ID
| DEFAULT_ACCOUNT_MANAGER_ROLE_ID
| DEFAULT_ACCOUNT_VIEWER_ROLE_ID
)
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Enum)]
pub enum RoleType {
#[default]
Global,
Account,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
#[native_model(id = 9, version = 1)]
#[native_db]
pub struct UserRole {
#[primary_key]
pub id: u64,
pub name: String,
pub description: Option<String>,
pub permissions: BTreeSet<String>,
pub is_builtin: bool,
pub created_at: i64,
pub role_type: RoleType,
pub updated_at: i64,
}
impl UserRole {
pub async fn ensure_default_roles_exists() -> BichonResult<()> {
let builtin_roles = vec![
(BuiltinRole::Admin, DEFAULT_ADMIN_ROLE_ID, RoleType::Global),
(
BuiltinRole::Manager,
DEFAULT_MANAGER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::Member,
DEFAULT_MEMBER_ROLE_ID,
RoleType::Global,
),
(
BuiltinRole::AccountManager,
DEFAULT_ACCOUNT_MANAGER_ROLE_ID,
RoleType::Account,
),
(
BuiltinRole::AccountViewer,
DEFAULT_ACCOUNT_VIEWER_ROLE_ID,
RoleType::Account,
),
];
with_transaction(DB_MANAGER.meta_db(), move |rw| {
let now = utc_now!();
for (role, role_id, role_type) in builtin_roles {
let exists = rw
.get()
.primary::<UserRole>(role_id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.is_some();
if !exists {
let permissions: BTreeSet<String> = role
.get_permissions()
.into_iter()
.map(|s| s.to_string())
.collect();
rw.insert(UserRole {
id: role_id,
name: role.to_string(),
description: Some(role.description().to_string()),
permissions,
created_at: now,
updated_at: now,
is_builtin: true,
role_type,
})
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
}
}
Ok(())
})
.await?;
Ok(())
}
pub async fn list_all() -> BichonResult<Vec<UserRole>> {
list_all_impl(DB_MANAGER.meta_db()).await
}
pub async fn find(role_id: u64) -> BichonResult<Option<UserRole>> {
async_find_impl(DB_MANAGER.meta_db(), role_id).await
}
pub async fn create(request: RoleCreateRequest) -> BichonResult<UserRole> {
let _ = &request.validate().await?;
let now = utc_now!();
let new_role = UserRole {
id: id!(64),
name: request.name,
description: request.description,
permissions: request.permissions,
created_at: now,
updated_at: now,
is_builtin: false,
role_type: request.role_type,
};
insert_impl(DB_MANAGER.meta_db(), new_role.clone()).await?;
Ok(new_role)
}
pub async fn update(id: u64, request: RoleUpdateRequest) -> BichonResult<()> {
if is_builtin(id) && request.permissions.is_some() {
return Err(raise_error!(
"The permissions of a builtin role are immutable. Please create a custom role instead.".into(),
ErrorCode::Forbidden
));
}
let _ = &request.validate().await?;
if let Some(permissions) = &request.permissions {
let role = Self::find(id).await?.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})?;
Permission::validate_role_permissions(&role.role_type, permissions)?;
}
update_impl(
DB_MANAGER.meta_db(),
move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole with id={} not found", id),
ErrorCode::ResourceNotFound
)
})
},
move |current| {
let mut updated = current.clone();
if let Some(name) = request.name {
updated.name = name;
}
if let Some(desc) = request.description {
updated.description = Some(desc);
}
if let Some(permissions) = request.permissions {
updated.permissions = permissions;
}
updated.updated_at = utc_now!();
Ok(updated)
},
)
.await?;
Ok(())
}
pub async fn delete(id: u64) -> BichonResult<()> {
if is_builtin(id) {
return Err(raise_error!(
format!("Cannot delete a default system role (ID: {}).", id),
ErrorCode::InvalidParameter
));
}
delete_impl(DB_MANAGER.meta_db(), move |rw| {
rw.get()
.primary::<UserRole>(id)
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
.ok_or_else(|| {
raise_error!(
format!("UserRole '{}' not found during deletion process.", id),
ErrorCode::ResourceNotFound
)
})
})
.await
}
}

54
src/modules/users/view.rs Normal file
View File

@@ -0,0 +1,54 @@
//
// 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::BTreeMap;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
use crate::modules::users::acl::AccessControl;
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize, Object)]
pub struct UserView {
pub id: u64,
pub username: String,
pub email: String,
pub password: Option<String>,
/// Scoped Access: Defines per-account permissions.
/// Example:
/// { account_id: 1, role_id: role_manager_id } -> Manager on Account 1
/// { account_id: 2, role_id: role_viewer_id } -> Viewer on Account 2
pub account_access_map: BTreeMap<u64, u64>,
pub account_roles_summary: BTreeMap<u64, String>,
pub account_permissions: BTreeMap<u64, Vec<String>>,
pub description: Option<String>,
/// Global Roles: Permissions that apply to the whole system
/// (e.g., system settings, creating new users).
pub global_roles: Vec<u64>,
pub global_roles_names: Vec<String>,
pub global_permissions: Vec<String>,
pub avatar: Option<String>,
pub created_at: i64,
pub updated_at: i64,
/// Optional access control settings
pub acl: Option<AccessControl>,
pub theme: Option<String>,
pub language: Option<String>,
}

View File

@@ -16,18 +16,34 @@
// 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 base64::{engine::general_purpose, Engine as _};
use ring::aead::{Aad, BoundKey, Nonce, NonceSequence, OpeningKey, SealingKey, AES_256_GCM};
use ring::pbkdf2::{self, derive};
use ring::rand::{SecureRandom, SystemRandom};
use std::fs;
use std::num::NonZeroU32;
use std::sync::LazyLock;
use crate::modules::error::code::ErrorCode;
use crate::modules::error::BichonResult;
use crate::modules::settings::cli::SETTINGS;
use crate::raise_error;
static ENCRYPT_PASSWORD: LazyLock<String> = LazyLock::new(|| {
if let Some(file_path) = &SETTINGS.bichon_encrypt_password_file {
return fs::read_to_string(file_path)
.expect("failed to read the file with the encrypt password")
.trim()
.to_string();
}
if let Some(p) = &SETTINGS.bichon_encrypt_password {
return p.clone();
}
panic!("Neither encrypt_password nor encrypt_password_file is set. This should have been validated by SETTINGS.");
});
struct SingleNonceSequence([u8; 12]);
impl SingleNonceSequence {
@@ -43,12 +59,12 @@ impl NonceSequence for SingleNonceSequence {
}
pub fn encrypt_string(plaintext: &str) -> BichonResult<String> {
internal_encrypt_string(&SETTINGS.bichon_encrypt_password, plaintext)
internal_encrypt_string(&ENCRYPT_PASSWORD, plaintext)
.map_err(|_| raise_error!("Failed to encrypt string.".into(), ErrorCode::InternalError))
}
pub fn decrypt_string(data: &str) -> BichonResult<String> {
internal_decrypt_string(&SETTINGS.bichon_encrypt_password, data).map_err(|_| {
internal_decrypt_string(&ENCRYPT_PASSWORD, data).map_err(|_| {
raise_error!(
"Decryption failed, likely due to incorrect encryption key or corrupted data".into(),
ErrorCode::InternalError

View File

@@ -16,9 +16,10 @@
// 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::{fs, io, path::PathBuf};
use crate::modules::error::BichonResult;
use base64::engine::general_purpose::STANDARD;
use base64::{engine::general_purpose, Engine};
use rand::{rng, Rng};
@@ -310,3 +311,26 @@ pub fn get_total_size(path: &PathBuf) -> io::Result<u64> {
Ok(total_size)
}
const MAX_AVATAR_BYTES: usize = 128 * 1024;
pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
let bytes = STANDARD.decode(base64_str).map_err(|e| {
raise_error!(
format!("Invalid avatar base64 encoding: {}", e),
ErrorCode::InvalidParameter
)
})?;
if bytes.len() > MAX_AVATAR_BYTES {
return Err(raise_error!(
format!(
"Avatar image exceeds maximum size ({} KB).",
MAX_AVATAR_BYTES / 1024
),
ErrorCode::InvalidParameter
));
}
Ok(bytes)
}

View File

@@ -16,7 +16,6 @@
// 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 dashmap::DashMap;
use governor::{
clock::{QuantaClock, QuantaInstant},
@@ -30,14 +29,14 @@ use std::{
time::Duration,
};
use crate::modules::token::RateLimit;
use crate::modules::users::acl::RateLimit;
pub static RATE_LIMITER_MANAGER: LazyLock<TokenRateLimiter> = LazyLock::new(TokenRateLimiter::new);
pub static RATE_LIMITER_MANAGER: LazyLock<UserRateLimiter> = LazyLock::new(UserRateLimiter::new);
pub struct TokenRateLimiter {
pub struct UserRateLimiter {
limiters: Arc<
DashMap<
String,
u64,
(
Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>>,
RateLimit,
@@ -46,29 +45,29 @@ pub struct TokenRateLimiter {
>,
}
impl TokenRateLimiter {
impl UserRateLimiter {
pub fn new() -> Self {
TokenRateLimiter {
UserRateLimiter {
limiters: Arc::new(DashMap::new()),
}
}
pub async fn check(
&self,
token: &str,
user_id: u64,
limit: RateLimit,
) -> Result<(), NotUntil<QuantaInstant>> {
let limiter = self.get_or_update_limiter(token, limit).await;
let limiter = self.get_or_update_limiter(user_id, limit).await;
limiter.check()
}
async fn get_or_update_limiter(
&self,
token: &str,
user_id: u64,
limit: RateLimit,
) -> Arc<RateLimiter<NotKeyed, InMemoryState, QuantaClock, NoOpMiddleware>> {
self.limiters
.entry(token.to_string())
.entry(user_id)
.and_modify(|(existing_limiter, current_limit)| {
if current_limit.interval != limit.interval || current_limit.quota != limit.quota {
let quota = Quota::with_period(Duration::from_secs(limit.interval))
@@ -100,4 +99,4 @@ impl TokenRateLimiter {
.0
.clone()
}
}
}

View File

@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
"registries": {
"@reui": "https://reui.io/r/{name}.json"
}
}

View File

@@ -58,9 +58,10 @@
"i18next": "^25.6.3",
"js-cookie": "^3.0.5",
"lucide-react": "^0.468.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-ace": "^13.0.0",
"react-day-picker": "8.10.1",
"react-day-picker": "9.13.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.0",
"react-i18next": "^16.3.5",

1825
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +0,0 @@
//
// 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/>.
import axiosInstance from "@/api/axiosInstance";
import { AccessToken } from "@/features/access-tokens/data/schema";
export const login = async (password: string) => {
const response = await axiosInstance.post(`/api/login`, password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const reset_root_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-root-token");
return response.data;
};
export const reset_root_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-root-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const delete_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}

View File

@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { AccountModel } from "@/features/accounts/data/schema";
import { PaginatedResponse } from "..";
export interface MinimalAccount {
@@ -56,6 +55,59 @@ export interface MailboxBatchProgress {
current_batch: number;
}
type Encryption = 'Ssl' | 'StartTls' | 'None';
type AuthType = 'Password' | 'OAuth2';
type Unit = 'Days' | 'Months' | 'Years';
type AccountType = 'IMAP' | 'NoSync';
// Interface definitions
interface AuthConfig {
auth_type: AuthType;
password?: string;
}
export interface ImapConfig {
host: string;
port: number; // integer, 0-65535
encryption: Encryption;
auth: AuthConfig;
use_proxy?: number;
}
interface RelativeDate {
unit: Unit;
value: number; // integer, minimum 1
}
interface DateSelection {
fixed?: string; // format: "YYYY-MM-DD"
relative?: RelativeDate;
}
export interface AccountModel {
id: number;
account_type: AccountType;
imap?: ImapConfig;
enabled: boolean;
name?: string,
email: string;
capabilities?: string[];
date_since?: DateSelection;
date_before?: RelativeDate;
folder_limit?: number,
sync_folders: string[];
sync_interval_min?: number;
sync_batch_size?: number;
created_by: number;
created_user_name: string;
created_user_email: string;
created_at: number;
updated_at: number;
use_proxy?: number
use_dangerous: boolean
}
export const account_state = async (account_id: number) => {
const response = await axiosInstance.get<AccountRunningState>(`/api/v1/account-state/${account_id}`);
return response.data;
@@ -103,3 +155,8 @@ export const autoconfig = async (email: string) => {
const response = await axiosInstance.get<AutoConfigResult>(`/api/v1/autoconfig/${email}`);
return response.data;
};
export const access_assign = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/accounts/access/assignments", data);
return response.data;
};

View File

@@ -17,7 +17,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { getAccessToken } from "@/stores/authStore";
import { getToken } from "@/stores/authStore";
import axios from "axios";
// Create an Axios instance
@@ -36,9 +36,9 @@ const axiosInstance = axios.create({
// Add a request interceptor to include the access token in headers
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = getAccessToken(); // Retrieve access token from localStorage
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
const stored = getToken(); // Retrieve access token from localStorage
if (stored) {
config.headers.Authorization = `Bearer ${stored.accessToken}`;
}
return config;
},

View File

@@ -30,6 +30,8 @@ export interface EmailEnvelope {
id: number;
message_id: string;
account_id: number;
account_email?: string;
mailbox_name?: string;
uid: number;
subject: string;
text: string;

View File

@@ -18,7 +18,6 @@
import axiosInstance from "@/api/axiosInstance";
import { Proxy } from "@/features/settings/proxy/data/schema";
export interface Release {
tag_name: string;
@@ -73,6 +72,34 @@ export interface LargestEmail {
size_bytes: number; // Email size in bytes
}
export interface Proxy {
id: number;
url: string;
created_at: number;
updated_at: number;
}
export type ServerConfigurations = {
bichon_log_level: string
bichon_http_port: number
bichon_bind_ip?: string | null
bichon_public_url: string
bichon_cors_origins?: string[] | null
bichon_cors_max_age: number
bichon_ansi_logs: boolean
bichon_log_to_file: boolean
bichon_json_logs: boolean
bichon_max_server_log_files: number
bichon_encrypt_password_set: boolean
bichon_webui_token_expiration_hours: number
bichon_root_dir: string
bichon_metadata_cache_size?: number | null
bichon_envelope_cache_size?: number | null
bichon_enable_rest_https: boolean
bichon_http_compression_enabled: boolean
bichon_sync_concurrency?: number | null
}
export const get_dashboard_stats = async () => {
const response = await axiosInstance.get<DashboardStats>(`/api/v1/dashboard-stats`);
return response.data;
@@ -104,4 +131,10 @@ export const add_proxy = async (url: string) => {
},
});
return response.data;
};
export const get_system_configurations = async () => {
const response = await axiosInstance.get<ServerConfigurations>(`/api/v1/system-configurations`);
return response.data;
};

208
web/src/api/users/api.ts Normal file
View File

@@ -0,0 +1,208 @@
import axiosInstance from "@/api/axiosInstance";
export type RoleType = 'Global' | 'Account';
export interface UserRole {
id: number;
name: string;
description?: string | null;
permissions: string[];
is_builtin: boolean;
role_type: RoleType;
created_at: number;
updated_at: number;
}
export function getPermissions(t: (key: string) => string) {
return [
// 1. Global Management
{ label: t('permission.system.access'), value: 'system:access' },
{ label: t('permission.system.root'), value: 'system:root' },
{ label: t('permission.user.manage'), value: 'user:manage' },
{ label: t('permission.user.view'), value: 'user:view' },
{ label: t('permission.token.manage'), value: 'token:manage' },
{ label: t('permission.account.create'), value: 'account:create' },
// 2. Global "ALL" Scoped (Admin)
{ label: t('permission.account.manage_all'), value: 'account:manage:all' },
{ label: t('permission.data.read_all'), value: 'data:read:all' },
{ label: t('permission.data.manage_all'), value: 'data:manage:all' },
{ label: t('permission.data.raw_download_all'), value: 'data:raw:download:all' },
{ label: t('permission.data.delete_all'), value: 'data:delete:all' },
{ label: t('permission.data.export_batch_all'), value: 'data:export:batch:all' },
// 3. Scoped / Limited
{ label: t('permission.account.manage'), value: 'account:manage' },
{ label: t('permission.account.read_details'), value: 'account:read_details' },
{ label: t('permission.data.read'), value: 'data:read' },
{ label: t('permission.data.manage'), value: 'data:manage' },
{ label: t('permission.data.raw_download'), value: 'data:raw:download' },
{ label: t('permission.data.delete'), value: 'data:delete' },
{ label: t('permission.data.export_batch'), value: 'data:export:batch' },
{ label: t('permission.data.import_batch'), value: 'data:import:batch' },
]
}
export interface RateLimit {
quota: number;
interval: number;
}
export interface AccessControl {
ip_whitelist?: string[];
rate_limit?: RateLimit;
}
export type TokenType = "WebUI" | "Api";
export interface AccessToken {
user_id: number;
user_name: string,
user_email: string,
token: string;
created_at: number;
updated_at: number;
name?: string;
last_access_at: number;
expire_at?: number | null;
token_type: TokenType;
}
export interface User {
id: number;
username: string;
email: string;
password?: string | null;
description?: string | null;
global_roles: number[];
global_roles_names: string[];
avatar?: string;
acl?: AccessControl;
account_access_map: Record<number, number>;
account_roles_summary: Record<number, string>;
global_permissions: string[]
account_permissions: Record<number, string[]>
created_at: number;
updated_at: number;
}
type Theme = 'dark' | 'light'
export interface LoginResult {
success: boolean;
error_message?: string | null;
access_token?: string | null;
theme?: Theme,
language?: string,
}
export interface MinimalUser {
id: number;
username: string;
email: string;
}
export const login = async (data: Record<string, any>) => {
const response = await axiosInstance.post<LoginResult>(`/api/login`, data);
return response.data;
};
export const reset_admin_token = async () => {
const response = await axiosInstance.post("/api/v1/reset-admin-token");
return response.data;
};
export const reset_admin_password = async (password: string) => {
const response = await axiosInstance.post("/api/v1/reset-admin-password", password, {
headers: {
"Content-Type": "text/plain",
},
});
return response.data;
};
export const list_access_tokens = async () => {
const response = await axiosInstance.get<AccessToken[]>("/api/v1/access-token-list");
return response.data;
};
export const create_access_token = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/access-token", data);
return response.data;
}
export const update_access_token = async (token: string, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/access-token/${token}`, data);
return response.data;
}
export const remove_access_token = async (token: string) => {
const response = await axiosInstance.delete(`/api/v1/access-token/${token}`);
return response.data;
}
export const list_roles = async () => {
const response = await axiosInstance.get<UserRole[]>("/api/v1/list-roles");
return response.data;
};
export const remove_role = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/roles/${id}`);
return response.data;
};
export const create_role = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/roles", data);
return response.data;
};
export const update_role = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/roles/${id}`, data);
return response.data;
};
export const list_users = async () => {
const response = await axiosInstance.get<User[]>("/api/v1/list-users");
return response.data;
};
export const list_minimal_users = async () => {
const response = await axiosInstance.get<MinimalUser[]>("/api/v1/minimal-user-list");
return response.data;
};
export const remove_user = async (id: number) => {
const response = await axiosInstance.delete(`/api/v1/users/${id}`);
return response.data;
};
export const create_user = async (data: Record<string, any>) => {
const response = await axiosInstance.post("/api/v1/users", data);
return response.data;
};
export const update_user = async (id: number, data: Record<string, any>) => {
const response = await axiosInstance.post(`/api/v1/users/${id}`, data);
return response.data;
};
export const get_user_tokens = async (id: number) => {
const response = await axiosInstance.get<AccessToken[]>(`/api/v1/user-tokens/${id}`);
return response.data;
};
export const get_current_user = async () => {
const response = await axiosInstance.get<User>("/api/v1/current-user");
return response.data;
};

View File

@@ -22,10 +22,11 @@ import { FixedHeader } from "./layout/fixed-header";
import { Main } from "./layout/main";
import Logo from '@/assets/logo.svg'
import { useTranslation } from 'react-i18next'
import { Separator } from "./ui/separator";
export default function APIDocs() {
const { t } = useTranslation()
const docsOptions = [
{ name: t('apiDocs.swaggerUI'), path: "/api-docs/swagger" },
{ name: t('apiDocs.reDoc'), path: "/api-docs/redoc" },
@@ -51,6 +52,7 @@ export default function APIDocs() {
</p>
</div>
</div>
<Separator className='mt-2 mb-4 lg:mt-3 lg:mb-6' />
<div className='-mx-4 flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-6 p-4'>
<div className="grid w-full gap-4 sm:grid-cols-1 md:grid-cols-2 xl:max-w-4xl">

View File

@@ -7,6 +7,9 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import i18n from '@/i18n'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
type DatePickerProps = {
selected: Date | undefined
@@ -19,6 +22,10 @@ export function DatePicker({
onSelect,
placeholder = 'Pick a date',
}: DatePickerProps) {
const currentLang = i18n.language.toLowerCase().replace('_', '-');
const dateLocale = dateFnsLocaleMap[currentLang] || enUS;
return (
<Popover>
<PopoverTrigger asChild>
@@ -28,7 +35,7 @@ export function DatePicker({
className='data-[empty=true]:text-muted-foreground w-[240px] justify-start text-start font-normal'
>
{selected ? (
format(selected, 'MMM d, yyyy')
format(selected, 'PPP', { locale: dateLocale })
) : (
<span>{placeholder}</span>
)}

View File

@@ -20,16 +20,18 @@
import {
IconHelp,
IconLayoutDashboard,
IconLockAccess,
IconSettings
} from '@tabler/icons-react'
import { IdCard, Inbox, Mailbox, Search } from 'lucide-react'
import { IdCard, Inbox, Mailbox, Search, Users2 } from 'lucide-react'
import { type SidebarData } from '../types'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
export function useSidebarData(): SidebarData {
const { t } = useTranslation()
const { require_any_permission } = useCurrentUser()
return {
navGroups: [
{
@@ -69,11 +71,18 @@ export function useSidebarData(): SidebarData {
title: t('navigation.oauth2'),
url: '/oauth2',
icon: IdCard,
},
visible: require_any_permission(['system:root', 'account:create']),
}
]
},
{
title: t('navigation.users'),
items: [
{
title: t('navigation.accessTokens'),
url: '/access-tokens',
icon: IconLockAccess,
title: t('navigation.users'),
url: '/users',
icon: Users2,
visible: require_any_permission(['system:root', 'user:manage']),
}
]
},

View File

@@ -1,22 +1,3 @@
//
// 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/>.
import { ReactNode } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
@@ -50,11 +31,16 @@ import { NavCollapsible, NavItem, NavLink, type NavGroup } from './types'
export function NavGroup({ title, items }: NavGroup) {
const { state } = useSidebar()
const href = useLocation({ select: (location) => location.href })
const visibleItems = items.filter(item => item.visible !== false)
if (visibleItems.length === 0) return null
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
{visibleItems.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
@@ -103,6 +89,10 @@ const SidebarMenuCollapsible = ({
href: string
}) => {
const { setOpenMobile } = useSidebar()
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<Collapsible
asChild
@@ -120,7 +110,7 @@ const SidebarMenuCollapsible = ({
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
{visibleSubItems.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
@@ -148,6 +138,10 @@ const SidebarMenuCollapsedDropdown = ({
item: NavCollapsible
href: string
}) => {
const visibleSubItems = item.items.filter(sub => sub.visible !== false)
if (visibleSubItems.length === 0) return null
return (
<SidebarMenuItem>
<DropdownMenu>
@@ -167,7 +161,7 @@ const SidebarMenuCollapsedDropdown = ({
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
{visibleSubItems.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
@@ -189,11 +183,11 @@ const SidebarMenuCollapsedDropdown = ({
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
href === item.url ||
href.split('?')[0] === item.url ||
!!item?.items?.filter((i) => i.url === href).length ||
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
}

View File

@@ -23,6 +23,7 @@ interface BaseNavItem {
title: string
badge?: string
icon?: React.ElementType
visible?: boolean
}
type NavLink = BaseNavItem & {
@@ -31,7 +32,7 @@ type NavLink = BaseNavItem & {
}
type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: LinkProps['to'] })[]
items: (BaseNavItem & { url: LinkProps['to']; visible?: boolean })[]
url?: never
}

View File

@@ -20,6 +20,8 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { Button } from '@/components/ui/button'
import {
@@ -30,6 +32,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
import { showNumbers } from '@/lib/utils'
interface PaginationProps {
totalItems: number
@@ -66,6 +69,9 @@ export function EnvelopeListPagination({
setPageIndex(newPageIndex)
}
const currentPage = pageIndex + 1;
const pageNumbers = showNumbers(currentPage, pageCount)
return (
<div className='flex items-center justify-between space-x-2 overflow-auto px-2'>
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
@@ -94,6 +100,14 @@ export function EnvelopeListPagination({
{t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(0)}
disabled={pageIndex === 0}
>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -103,6 +117,22 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='px-1 text-sm text-muted-foreground'>...</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2'
onClick={() => setPageIndex((pageNumber as number) - 1)}
>
{pageNumber}
</Button>
)}
</div>
))}
<Button
variant='outline'
className='h-8 w-8 p-0'
@@ -112,6 +142,14 @@ export function EnvelopeListPagination({
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => setPageIndex(pageCount - 1)}
disabled={!hasNextPage()}
>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>

View File

@@ -22,48 +22,75 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { LogoutConfirmDialog } from '@/features/auth/sign-in/components/logout';
import { resetAccessToken } from '@/stores/authStore';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useCurrentUser } from '@/hooks/use-current-user';
import useDialogState from '@/hooks/use-dialog-state';
import { useMemo } from 'react';
import { SignOutDialog } from './sign-out-dialog';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
export function ProfileDropdown() {
const navigate = useNavigate()
const [open, setOpen] = useDialogState()
const { t } = useTranslation()
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false)
const handleLogout = () => {
resetAccessToken()
navigate({ to: '/sign-in' })
}
const { data: user } = useCurrentUser()
const avatarSrc = useMemo(() => {
const base64 = user?.avatar;
if (!base64 || base64.length === 0) return null;
return `data:image/png;base64,${base64}`;
}, [user]);
const fallbackName = user?.username ? user.username.charAt(0).toUpperCase() : 'U';
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
<Avatar className='h-8 w-8'>
<AvatarFallback className='text-xs'>root</AvatarFallback>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
<Avatar className="h-8 w-8">
{avatarSrc ? (
<img src={avatarSrc} alt={t('profile.avatar_alt')} className="h-full w-full object-cover" />
) : (
<AvatarFallback className="text-xs">{fallbackName}</AvatarFallback>
)}
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className='w-56' align='end' forceMount>
<DropdownMenuItem onClick={() => setIsLogoutDialogOpen(true)}>
{t('auth.logout')}
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
<DropdownMenuLabel className='font-normal'>
<div className='flex flex-col gap-1.5'>
<p className='text-sm leading-none font-medium'>{user?.username}</p>
<p className='text-muted-foreground text-xs leading-none'>
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to='/settings/profile'>{t('profile.menu.profile')}</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to='/settings'>{t('profile.menu.settings')}</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setOpen(true)}>
{t('profile.menu.sign_out')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LogoutConfirmDialog
open={isLogoutDialogOpen}
onOpenChange={setIsLogoutDialogOpen}
handleConfirm={handleLogout}
/>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
)
}

View File

@@ -0,0 +1,36 @@
import { useNavigate, useLocation } from '@tanstack/react-router'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetToken } from '@/stores/authStore'
interface SignOutDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
const navigate = useNavigate()
const location = useLocation()
const handleSignOut = () => {
resetToken()
const currentPath = location.href
navigate({
to: '/sign-in',
search: { redirect: currentPath },
replace: true,
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title='Sign out'
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
confirmText='Sign out'
destructive
handleConfirm={handleSignOut}
className='sm:max-w-sm'
/>
)
}

View File

@@ -55,4 +55,4 @@ const AlertDescription = React.forwardRef<
))
AlertDescription.displayName = 'AlertDescription'
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription }

View File

@@ -1,69 +1,210 @@
import * as React from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: CalendarProps) {
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md'
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal aria-selected:opacity-100'
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className='h-4 w-4' />,
IconRight: () => <ChevronRight className='h-4 w-4' />,
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-[--cell-size] items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
Calendar.displayName = 'Calendar'
export { Calendar }
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -273,8 +273,7 @@ export function VirtualizedSelect({
.filter(Boolean);
if (selectedLabels.length === 0) return placeholder;
if (selectedLabels.length <= 3) return selectedLabels.join(', ');
return `${selectedLabels[0]}, ${selectedLabels[1]} +${selectedLabels.length - 2} more`;
return `${selectedLabels[0]} +${selectedLabels.length - 1} more`;
};
return (

View File

@@ -1,50 +0,0 @@
//
// 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/>.
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountInfo } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
export const getColumns = (t: (key: string) => string): ColumnDef<AccountInfo>[] => [
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accessTokens.accountId')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.id}</LongText>
),
meta: { className: 'w-80' },
enableHiding: false,
enableSorting: false
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.getValue('email')}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
]

View File

@@ -1,69 +0,0 @@
//
// 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/>.
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { AccountsDetailTable } from './accounts-detail-table'
import { getColumns } from './accounts-detail-columns'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const columns = getColumns(t)
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-4xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.accounts')}</DialogTitle>
<DialogDescription>
{t('accessTokens.theListOfAccountsThatCanBeQueried')}
</DialogDescription>
</DialogHeader>
<div className="h-[33rem] overflow-x-auto overflow-y-auto">
<AccountsDetailTable data={currentRow.accounts} columns={columns} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">{t('common.close')}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,99 +0,0 @@
//
// 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/>.
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AccessToken } from '../data/schema'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { useTranslation } from 'react-i18next'
interface Props {
currentRow: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
export function AclDetailDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
return (
<Dialog
open={open}
onOpenChange={(state) => {
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-xl'>
<DialogHeader className='text-left'>
<DialogTitle>{t('settings.acl')}</DialogTitle>
<DialogDescription>
{t('accessTokens.aclRulesForAccessTokens')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[33rem] w-full pr-4 -mr-4 py-1'>
<div className="space-y-4">
{/* IP Whitelist */}
<div className="grid w-full items-center">
<Label className="mb-2">IP Whitelist</Label>
<Textarea
className="col-span-5 max-h-[240px] min-h-[300px]"
value={currentRow.acl?.ip_whitelist?.join('\n')}
/>
</div>
{/* Quota */}
<div className="grid w-full items-center">
<Label className="mb-2">Quota</Label>
<Input
type="number"
value={currentRow.acl?.rate_limit?.quota}
className="col-span-5"
/>
</div>
{/* Interval (seconds) */}
<div className="grid w-full items-center">
<Label className="mb-2">Interval (seconds)</Label>
<Input
type="number"
className="col-span-5"
value={currentRow.acl?.rate_limit?.interval}
/>
</div>
</div>
</ScrollArea>
<DialogFooter>
<DialogClose asChild>
<Button variant='outline' className="px-2 py-1 text-sm h-auto">Close</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,404 +0,0 @@
//
// 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/>.
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from '@/hooks/use-toast'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
// import { MultiSelect } from '@/components/multi-select'
import { Textarea } from '@/components/ui/textarea'
import { AccessToken } from '../data/schema'
import useMinimalAccountList from '@/hooks/use-minimal-account-list'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { create_access_token, update_access_token } from '@/api/access-tokens/api'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { VirtualizedSelect } from '@/components/virtualized-select'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const isValidIP = (ip: string) => {
const ipv4Regex = /^(?:(?:\d{1,3}\.){3}\d{1,3})$/;
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
return ipv4Regex.test(ip) || ipv6Regex.test(ip);
};
const RateLimitBaseSchema = z.object({
quota: z.optional(z.number()),
interval: z.optional(z.number()),
});
const AccessControlBaseSchema = z.object({
ip_whitelist: z.string().optional(),
rate_limit: z.optional(RateLimitBaseSchema),
});
const AccessTokenBaseSchema = z.object({
accounts: z.array(z.number()),
description: z.optional(z.string()),
acl: z.optional(AccessControlBaseSchema),
});
export type AccessTokenForm = z.infer<typeof AccessTokenBaseSchema>;
const getRateLimitSchema = (t: (key: string) => string) => z.object({
quota: z.optional(z.number().int().positive({ message: t('accessTokens.quotaMustBeAPositiveInteger') })),
interval: z.optional(z.number().int().positive({ message: t('accessTokens.intervalMustBeAPositiveInteger') })),
});
const getAccessControlSchema = (t: (key: string) => string) => AccessControlBaseSchema.extend({
rate_limit: getRateLimitSchema(t).optional(),
}).transform((data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist
.split('\n')
.map((ip) => ip.trim())
.filter((ip) => ip !== '');
return {
...data,
ip_whitelist: ips.join('\n'),
};
}
return data;
}).refine(
(data) => {
if (data.ip_whitelist) {
const ips = data.ip_whitelist.split('\n');
const invalidIPs = ips.filter((ip) => !isValidIP(ip));
return invalidIPs.length === 0;
}
return true;
},
{
message: t('accessTokens.invalidIpAddressesFound'),
path: ['ip_whitelist'],
}
).transform((data) => {
if (data.rate_limit && !data.rate_limit.interval && !data.rate_limit.quota) {
return {
...data,
rate_limit: undefined,
};
}
return data;
})
.transform((data) => {
if (!data.ip_whitelist && !data.rate_limit) {
return undefined;
}
return data;
});
const getAccessTokenFormSchema = (t: (key: string) => string) => AccessTokenBaseSchema.extend({
accounts: z
.array(z.number())
.min(1, { message: t('accessTokens.atLeastOneAccountIsRequired') }),
description: z
.optional(z.string().max(255, { message: t('accessTokens.descriptionMustNotExceed255Characters') })),
acl: z.optional(getAccessControlSchema(t)),
});
interface Props {
currentRow?: AccessToken
open: boolean
onOpenChange: (open: boolean) => void
}
const defaultValues = {
accounts: [],
description: undefined,
access_scopes: [],
acl: undefined,
};
export function TokensActionDialog({ currentRow, open, onOpenChange }: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow
const queryClient = useQueryClient();
const form = useForm<AccessTokenForm>({
resolver: zodResolver(getAccessTokenFormSchema(t)),
defaultValues: isEdit
? {
accounts: currentRow.accounts.map(value => value.id),
description: currentRow.description ?? undefined,
acl: currentRow.acl
? {
ip_whitelist: currentRow.acl.ip_whitelist
? currentRow.acl.ip_whitelist.join('\n')
: undefined,
rate_limit: currentRow.acl.rate_limit ? currentRow.acl.rate_limit : undefined
}
: undefined,
}
: defaultValues,
});
const createMutation = useMutation({
mutationFn: create_access_token,
onSuccess: handleSuccess,
onError: handleError
});
const updateMutation = useMutation({
mutationFn: (data: Record<string, any>) => update_access_token(currentRow?.token ?? '', data),
onSuccess: handleSuccess,
onError: handleError
})
function handleSuccess() {
toast({
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updated') : t('accessTokens.created')}`,
description: t('accessTokens.yourAccessTokenHasBeenSuccessfully', { action: isEdit ? t('accessTokens.updated').toLowerCase() : t('accessTokens.created').toLowerCase() }),
action: <ToastAction altText={t('common.close')}>{t('common.close')}</ToastAction>,
});
queryClient.invalidateQueries({ queryKey: ['access-tokens'] });
form.reset();
onOpenChange(false);
}
function handleError(error: AxiosError) {
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('accessTokens.updateOrCreationFailed', { action: isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed') });
toast({
variant: "destructive",
title: `${t('accessTokens.title')} ${isEdit ? t('accessTokens.updateFailed') : t('accessTokens.creationFailed')}`,
description: errorMessage as string,
action: <ToastAction altText={t('common.tryAgain')}>{t('common.tryAgain')}</ToastAction>,
});
console.error(error);
}
const { accountsOptions, isLoading } = useMinimalAccountList();
const onSubmit = (values: AccessTokenForm) => {
const payload = {
accounts: values.accounts,
description: values.description,
acl: values.acl
? {
...values.acl,
ip_whitelist: values.acl.ip_whitelist
? (() => {
const ipSet = new Set(
values.acl.ip_whitelist
.split('\n')
.map(ip => ip.trim())
.filter(ip => ip !== ''),
);
return ipSet.size > 0 ? Array.from(ipSet) : undefined;
})()
: undefined,
}
: undefined,
};
if (isEdit) {
updateMutation.mutate(payload);
} else {
createMutation.mutate(payload);
}
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='max-w-4xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? t('accessTokens.editToken') : t('accessTokens.addNew')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accessTokens.updateTheAccessTokenHere') : t('accessTokens.createNewAccessTokenHere')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className='h-[28rem] w-full pr-4 -mr-4 py-1'>
<Form {...form}>
<form
id='token-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 p-0.5'
>
<FormField
control={form.control}
name='accounts'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('accessTokens.accounts')}:</FormLabel>
<FormControl>
<VirtualizedSelect
multiple
options={accountsOptions}
className='w-full'
isLoading={isLoading}
onSelectOption={(options) => {
const numberArray = options.map((v) => parseInt(v, 10));
return field.onChange(numberArray);
}}
value={field.value.map(String)}
placeholder={t('accessTokens.selectAccounts')}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('accessTokens.selectMultipleAccountsForTheAccessToken')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.ip_whitelist"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0">
<FormLabel className='mb-1'>{t('settings.acl')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.enterOneIpAddressPerLine')}
{...field}
className="max-h-[500px] min-h-[180px]"
/>
</FormControl>
<FormDescription>
{t('accessTokens.aListOfIpAddressesAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<FormField
control={form.control}
name="acl.rate_limit.quota"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.quota')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterQuota')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theMaximumNumberOfRequestsAllowed')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="acl.rate_limit.interval"
render={({ field }) => (
<FormItem className="flex flex-col gap-y-1 space-y-0 w-1/2">
<FormLabel className='mb-1'>{t('accessTokens.interval')}:</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('accessTokens.enterIntervalInSeconds')}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value, 10))}
/>
</FormControl>
<FormDescription>
{t('accessTokens.theTimeWindowForTheRateLimit')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem className='flex flex-col gap-y-1 space-y-0'>
<FormLabel className='mb-1'>{t('settings.description')}:</FormLabel>
<FormControl>
<Textarea
placeholder={t('accessTokens.describeThePurposeOfTheAccessToken')}
{...field}
className="max-h-[240px] min-h-[80px]"
/>
</FormControl>
<FormDescription>{t('oauth2.optional')}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</ScrollArea>
<DialogFooter>
<Button
type="submit"
form="token-form"
disabled={isEdit ? updateMutation.isPending : createMutation.isPending}
className="min-w-[100px] relative transition-all"
>
<span className="inline-flex items-center justify-center gap-2">
{(isEdit ? updateMutation.isPending : createMutation.isPending) && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
<span>
{isEdit
? updateMutation.isPending
? t('accessTokens.updating')
: t('accessTokens.saveChanges')
: createMutation.isPending
? t('accessTokens.creating')
: t('accessTokens.save')}
</span>
</span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,127 +0,0 @@
//
// 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/>.
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccessToken } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format, formatDistanceToNow, Locale } from 'date-fns'
import { AccountCellAction } from './account-action'
import { AclCellAction } from './acl-action'
export const getColumns = (t: (key: string) => string, locale: Locale): ColumnDef<AccessToken>[] => [
{
accessorKey: 'token',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.token')} />
),
cell: ({ row }) => {
return <LongText className='w-40'>{row.original.token}</LongText>
},
meta: { className: 'w-40' },
enableHiding: false,
enableSorting: false,
},
{
accessorKey: 'accounts',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.accounts')} />
),
cell: AccountCellAction,
meta: { className: 'w-10 text-center' },
filterFn: (row, columnId, filterValue) => {
const accounts = row.getValue(columnId) as { account_id: number; email: string }[];
if (!filterValue) return true;
return accounts.some(
(account) =>
`${account.account_id}`.includes(filterValue) ||
account.email.includes(filterValue)
);
},
},
{
id: 'acl',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.acl')} />
),
cell: AclCellAction,
meta: { className: 'w-8 text-center' },
enableSorting: false
},
{
accessorKey: 'description',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.description')} />
),
cell: ({ row }) => (
<LongText className='max-w-80'>{row.original.description}</LongText>
),
meta: { className: 'w-80' },
enableHiding: true,
enableSorting: false
},
{
accessorKey: 'created_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.createdAt')} />
),
cell: ({ row }) => {
const created_at = row.original.created_at;
const date = format(new Date(created_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'updated_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.updatedAt')} />
),
cell: ({ row }) => {
const updated_at = row.original.updated_at;
const date = format(new Date(updated_at), 'yyyy-MM-dd HH:mm:ss');
return <LongText className='max-w-36'>{date}</LongText>;
},
meta: { className: 'w-36' },
enableHiding: false,
},
{
accessorKey: 'last_access_at',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('settings.lastAccess')} />
),
cell: ({ row }) => {
const last_access_at = row.original.last_access_at;
if (last_access_at === 0) {
return <LongText className='max-w-40'>{t('accessTokens.notUsedYet')}</LongText>;
}
const result = formatDistanceToNow(new Date(last_access_at), { addSuffix: true, locale });
return <LongText className='max-w-40'>{result}</LongText>;
},
meta: { className: 'w-40' },
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]

View File

@@ -1,103 +0,0 @@
//
// 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/>.
import {
ChevronLeftIcon,
ChevronRightIcon,
} from '@radix-ui/react-icons'
import { Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected: boolean,
showPageSizeSelector: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation();
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredRowModel().rows.length} {t("table.results")}
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 {t("table.rowsPerPage")}.
</div>}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>{t("table.rowsPerPage")}</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
{t("table.page")} {table.getState().pagination.pageIndex + 1}{" "}
{t("table.of")} {table.getPageCount()}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>{t("table.prevPage")}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='h-8 w-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>{t("table.nextPage")}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -1,160 +0,0 @@
//
// 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/>.
import { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { Button } from '@/components/ui/button'
import { Main } from '@/components/layout/main'
import { TokensActionDialog } from './components/action-dialog'
import { getColumns } from './components/columns'
import { TokenDeleteDialog } from './components/delete-dialog'
import { AccessTokensTable } from './components/access-token-table'
import AccessTokensProvider, {
type AccessTokensDialogType,
} from './context'
import Logo from '@/assets/logo.svg'
import { Plus } from 'lucide-react'
import { AccessToken } from './data/schema'
import { AccountDetailDialog } from './components/accounts-detail-dialog'
import { AclDetailDialog } from './components/acl-detail-dialog'
import { useQuery } from '@tanstack/react-query'
import { list_access_tokens } from '@/api/access-tokens/api'
import { TableSkeleton } from '@/components/table-skeleton'
import { FixedHeader } from '@/components/layout/fixed-header'
import { useTranslation } from 'react-i18next'
import { dateFnsLocaleMap } from '@/lib/utils'
import { enUS } from 'date-fns/locale'
export default function AccessTokens() {
const { t, i18n } = useTranslation()
const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS;
// Dialog states
const [currentRow, setCurrentRow] = useState<AccessToken | null>(null)
const [open, setOpen] = useDialogState<AccessTokensDialogType>(null)
const { data: accessTokens, isLoading } = useQuery({
queryKey: ['access-tokens'],
queryFn: list_access_tokens,
})
const columns = getColumns(t, locale)
return (
<AccessTokensProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
{/* ===== Top Heading ===== */}
<FixedHeader />
<Main>
<div className="mx-auto mb-2 flex max-w-5xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-2">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t('accessTokens.title')}</h2>
<p className="text-muted-foreground">
{t('accessTokens.description')}
</p>
</div>
<div className="flex gap-2">
<Button className="space-x-1" onClick={() => setOpen('add')}>
<span>{t('common.add')}</span> <Plus size={18} />
</Button>
</div>
</div>
<div className="mx-auto flex-1 overflow-auto px-4 py-1 flex-row lg:space-x-12 space-y-0 max-w-5xl">
{isLoading ? (
<TableSkeleton columns={columns.length} rows={10} />
) : accessTokens?.length ? (
<AccessTokensTable data={accessTokens} columns={columns} />
) : (
<div className="flex h-[450px] shrink-0 items-center justify-center rounded-md border border-dashed">
<div className="mx-auto flex max-w-[420px] flex-col items-center justify-center text-center">
<img
src={Logo}
className="max-h-[100px] w-auto opacity-20 saturate-0 transition-all duration-300 hover:opacity-100 hover:saturate-100 object-contain"
alt="Bichon Logo"
/>
<h3 className="mt-4 text-lg font-semibold">{t('accessTokens.noTokens')}</h3>
<p className="mb-4 mt-2 text-sm text-muted-foreground">
{t('accessTokens.noTokensDesc')}
</p>
<Button onClick={() => setOpen('add')}>{t('accessTokens.create')}</Button>
</div>
</div>
)}
</div>
</Main>
<TokensActionDialog
key='token-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
{currentRow && (
<>
<TokensActionDialog
key={`token-edit-${currentRow.token}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<TokenDeleteDialog
key={`token-delete-${currentRow.token}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<AccountDetailDialog
key={`accounts-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'account-detail'}
onOpenChange={() => {
setOpen('account-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
<AclDetailDialog
key={`acl-detail-${currentRow.token}`}
currentRow={currentRow}
open={open === 'acl-detail'}
onOpenChange={() => {
setOpen('acl-detail')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}} />
</>
)}
</AccessTokensProvider>
)
}

View File

@@ -0,0 +1,275 @@
//
// 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/>.
import React from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Loader2, ShieldCheck, Users, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Input } from '@/components/ui/input'
import { useToast } from '@/hooks/use-toast'
import { useRoles } from '@/hooks/use-roles'
import { useMinimalUsers } from '@/hooks/use-minimal-users'
import { access_assign, AccountModel } from '@/api/account/api'
interface Props {
currentRow: AccountModel
open: boolean
onOpenChange: (open: boolean) => void
}
export function AccountAccessAssignmentDialog({
currentRow,
open,
onOpenChange,
}: Props) {
const { t } = useTranslation()
const { toast } = useToast()
const queryClient = useQueryClient()
const { accountRoles, isLoading: isLoadingRoles } = useRoles()
const { users, isLoading: isLoadingUsers } = useMinimalUsers()
const [keyword, setKeyword] = React.useState('')
// 1. 定义校验 Schema (集成国际化错误提示)
const assignmentSchema = z.object({
account_ids: z.array(z.number()),
user_ids: z.array(z.number()).min(1, {
message: t('accounts.access_control.validation.user_required'),
}),
role_id: z.number({
required_error: t('accounts.access_control.validation.role_required'),
}),
})
type AssignmentFormValues = z.infer<typeof assignmentSchema>
const form = useForm<AssignmentFormValues>({
resolver: zodResolver(assignmentSchema),
defaultValues: {
account_ids: [currentRow.id],
user_ids: [],
role_id: undefined as any,
},
})
const filteredUsers = React.useMemo(() => {
if (!keyword.trim()) return users
const lowerKeyword = keyword.toLowerCase()
return users.filter(
(user) =>
user.username.toLowerCase().includes(lowerKeyword) ||
user.email.toLowerCase().includes(lowerKeyword)
)
}, [users, keyword])
const { mutate, isPending } = useMutation({
mutationFn: access_assign,
onSuccess: () => {
toast({
title: t('accounts.access_control.toast.success_title'),
description: t('accounts.access_control.toast.success_desc', { email: currentRow.email }),
})
queryClient.invalidateQueries({ queryKey: ['account-access-list'] })
onOpenChange(false)
},
onError: (error: any) => {
toast({
variant: 'destructive',
title: t('accounts.access_control.toast.failed_title'),
description: error.response?.data?.message || error.message,
})
},
})
const onSubmit = (data: AssignmentFormValues) => {
mutate(data)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md gap-0 p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-blue-600" />
{t('accounts.access_control.title')}
</DialogTitle>
<DialogDescription>
{t('accounts.access_control.description', { email: currentRow.email })}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="px-6 space-y-6">
<FormField
control={form.control}
name="role_id"
render={({ field }) => (
<FormItem>
<FormLabel>{t('accounts.access_control.role_label')}</FormLabel>
<Select
disabled={isLoadingRoles}
onValueChange={(value) => field.onChange(Number(value))}
value={field.value?.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
isLoadingRoles
? t('accounts.access_control.role_loading')
: t('accounts.access_control.role_placeholder')
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{accountRoles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-3">
<FormLabel className="flex items-center gap-2">
<Users className="w-4 h-4" />
{t('accounts.access_control.user_label')}
</FormLabel>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('accounts.access_control.user_search_placeholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="pl-10"
/>
</div>
<div className="border rounded-md">
<ScrollArea className="h-64">
{isLoadingUsers ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="p-3 space-y-1">
{filteredUsers.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
{t('accounts.access_control.user_empty')}
</div>
) : (
filteredUsers.map((user) => (
<FormField
key={user.id}
control={form.control}
name="user_ids"
render={({ field }) => (
<FormItem className="flex flex-row items-center space-x-3 space-y-0 rounded-md hover:bg-accent/50 px-2 py-2 transition-colors">
<FormControl>
<Checkbox
checked={field.value?.includes(user.id) ?? false}
onCheckedChange={(checked) => {
if (checked) {
field.onChange([...(field.value ?? []), user.id])
} else {
field.onChange(
field.value?.filter((id: number) => id !== user.id) ?? []
)
}
}}
/>
</FormControl>
<label className="flex-1 cursor-pointer select-none space-y-1">
<div className="font-medium text-sm">{user.username}</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
</label>
</FormItem>
)}
/>
))
)}
</div>
)}
</ScrollArea>
</div>
<FormMessage>{form.formState.errors.user_ids?.message}</FormMessage>
{form.watch('user_ids')?.length > 0 && (
<div className="text-sm text-muted-foreground">
{t('accounts.access_control.user_selected_count', { count: form.watch('user_ids').length })}
</div>
)}
</div>
</div>
<DialogFooter className="bg-muted/50 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('accounts.access_control.buttons.cancel')}
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('accounts.access_control.buttons.save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -17,7 +17,6 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { AccountModel } from '../data/schema'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@@ -25,6 +24,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTranslation } from 'react-i18next'
import { AccountModel } from '@/api/account/api'
interface Props {
open: boolean
@@ -34,6 +34,30 @@ interface Props {
export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
const { t } = useTranslation()
const sinceText = (() => {
if (currentRow.date_since?.fixed) {
return currentRow.date_since.fixed;
}
if (currentRow.date_since?.relative?.value) {
return `${t('accounts.sinceRelativeValue', {
value: currentRow.date_since!.relative!.value,
unit: t(`accounts.${currentRow.date_since!.relative!.unit!.toLowerCase()}`)
})}`;
}
return t('accounts.syncAll');
})();
const hasSince = !!currentRow.date_since;
const hasBefore = !!currentRow.date_before?.value;
return (
<Dialog
open={open}
@@ -77,6 +101,10 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
<span className="text-muted-foreground">{t('accounts.incrementalSyncInterval')}:</span>
<span>{t('accounts.everyMinutes', { minutes: currentRow.sync_interval_min })}</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.syncBatchSize')}:</span>
<span>{currentRow.sync_batch_size}</span>
</div>
<div className="flex flex-col gap-2">
<span className="text-muted-foreground">{t('accounts.capabilities')}:</span>
<code className="rounded-md bg-muted/50 px-2 py-1 text-sm border overflow-x-auto inline-block">
@@ -84,14 +112,33 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</code>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.dateSelection')}:</span>
<span>
{currentRow.date_since?.fixed
? t('accounts.since') + ' ' + currentRow.date_since.fixed
: currentRow.date_since?.relative
? t('accounts.recent') + ' ' + currentRow.date_since.relative.value + ' ' + currentRow.date_since.relative.unit
: t('accounts.notAvailable')}
</span>
<span className="text-muted-foreground">{t('accounts.syncScope')}:</span>
{hasSince && (
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
{t('accounts.sinceFixed')}:
</span>
<span className="text-sm">{sinceText}</span>
</div>
)}
{hasBefore && (
<div className="flex flex-col border-t pt-2">
<span className="text-xs text-muted-foreground">
{t('accounts.beforeRelative')}:
</span>
<span className="text-sm">
{t('accounts.beforeRelativeValue', {
value: currentRow.date_before!.value,
unit: t(`accounts.${currentRow.date_before!.unit!.toLowerCase()}`)
})}
</span>
</div>
)}
{!hasSince && !hasBefore && (
<span className="text-sm text-muted-foreground">
{t('accounts.syncAll')}
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t('accounts.folderLimit')}:</span>
@@ -100,8 +147,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</div>
</CardContent>
</Card>
{/* Server Configuration Card */}
<Card>
<CardHeader>
<CardTitle>{t('accounts.serverConfiguration')}</CardTitle>
@@ -143,8 +188,6 @@ export function AccountDetailDrawer({ open, onOpenChange, currentRow }: Props) {
</div>
</CardContent>
</Card>
{/* Sync Folders Card */}
<Card>
<CardHeader>
<CardTitle>{t('accounts.syncFoldersTitle')}</CardTitle>

View File

@@ -16,14 +16,12 @@
// 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/>.
import { zodResolver } from '@hookform/resolvers/zod';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { AccountModel, ImapConfig } from '../data/schema';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/hooks/use-toast';
@@ -31,11 +29,12 @@ import Step1 from './step1';
import Step2 from './step2';
import Step3 from './step3';
import Step4 from './step4';
import { create_account, autoconfig, update_account } from '@/api/account/api';
import { create_account, autoconfig, update_account, AccountModel, ImapConfig } from '@/api/account/api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ToastAction } from '@/components/ui/toast';
import { AxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { cn } from "@/lib/utils";
const encryptionSchema = z.union([
z.literal('Ssl'),
@@ -80,7 +79,7 @@ const getRelativeDateSchema = (t: (key: string) => string) => z.object({
});
const getDateSelectionSchema = (t: (key: string) => string) => z.union([
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) },),
z.object({ fixed: z.string({ message: t('accounts.selectDate') }) }),
z.object({ relative: getRelativeDateSchema(t) }),
z.undefined(),
]);
@@ -107,8 +106,13 @@ export type Account = {
value?: number;
};
};
date_before?: {
unit?: 'Days' | 'Months' | 'Years';
value?: number;
};
folder_limit?: number;
sync_interval_min: number;
sync_batch_size: number;
};
const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
@@ -119,12 +123,18 @@ const getAccountSchema = (isEdit: boolean, t: (key: string) => string) =>
enabled: z.boolean(),
use_dangerous: z.boolean(),
date_since: getDateSelectionSchema(t).optional(),
date_before: getRelativeDateSchema(t).optional(),
folder_limit: z
.number({ invalid_type_error: t('validation.folderLimitMustBeNumber') })
.int()
.min(100, { message: t('validation.folderLimitMustBeAtLeast100') })
.optional(),
sync_interval_min: z.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') }).int().min(10, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
sync_batch_size: z
.number({ invalid_type_error: t('validation.incrementalSyncMustBeNumber') })
.int()
.min(30, { message: t('validation.incrementalSyncMustBeAtLeast10') })
.max(200, { message: t('validation.incrementalSyncMustBeAtLeast10') }),
});
type Step = {
@@ -133,14 +143,12 @@ type Step = {
fields: (keyof Account)[];
};
export type Steps = [
...Step[]
];
export type Steps = [...Step[]];
const getSteps = (t: (key: string) => string): Steps => [
{ id: "step-1", name: t('accounts.steps.emailAddress'), fields: ["email"] },
{ id: "step-2", name: t('accounts.steps.imap'), fields: ["imap", "use_dangerous", "name"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "folder_limit", "sync_interval_min"] },
{ id: "step-3", name: t('accounts.steps.syncPreferences'), fields: ["enabled", "date_since", "date_before", "folder_limit", "sync_interval_min", "sync_batch_size"] },
{ id: "step-4", name: t('accounts.steps.summary'), fields: [] },
];
@@ -168,8 +176,10 @@ const defaultValues: Account = {
enabled: true,
use_dangerous: false,
date_since: undefined,
date_before: undefined,
folder_limit: undefined,
sync_interval_min: 10,
sync_batch_size: 50,
};
const emptyImap: ImapConfig = {
@@ -194,8 +204,10 @@ const mapCurrentRowToFormValues = (currentRow: AccountModel): Account => {
enabled: currentRow.enabled,
use_dangerous: currentRow.use_dangerous,
date_since: currentRow.date_since ?? undefined,
date_before: currentRow.date_before ?? undefined,
folder_limit: currentRow.folder_limit ?? undefined,
sync_interval_min: currentRow.sync_interval_min ?? 10,
sync_batch_size: currentRow.sync_batch_size ?? 50,
};
};
@@ -272,8 +284,10 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
enabled: data.enabled,
use_dangerous: data.use_dangerous,
date_since: data.date_since,
date_before: data.date_before,
folder_limit: data.folder_limit,
sync_interval_min: data.sync_interval_min,
sync_batch_size: data.sync_batch_size,
};
if (isEdit) {
updateMutation.mutate(commonData);
@@ -330,61 +344,67 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
<Dialog
open={open}
onOpenChange={(state) => {
form.reset();
setCurrentStep(1);
if (!state) {
form.reset();
setCurrentStep(1);
}
onOpenChange(state);
}}
>
<DialogContent className='max-w-5xl'>
<DialogHeader className='text-left mb-4'>
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[38rem] w-full pr-4 -mr-4 py-1">
<>
<div className="flex my-5 space-x-4 md:hidden">
{steps.map((step, index) => (
<DialogContent className="max-w-[95vw] md:max-w-5xl w-full p-0 overflow-hidden flex flex-col h-[90vh]">
<div className="p-6 pb-2 flex-shrink-0">
<DialogHeader className="text-left">
<DialogTitle>{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}</DialogTitle>
<DialogDescription>
{isEdit ? t('accounts.updateAccount') : t('accounts.addAccount')}
{t('accounts.clickSaveWhenDone')}
</DialogDescription>
</DialogHeader>
</div>
<div className="flex flex-col md:flex-row flex-1 min-h-0 overflow-hidden border-y">
<div className="md:hidden flex px-6 py-2 space-x-2 overflow-x-auto border-b flex-shrink-0 bg-background/50">
{steps.map((step, index) => (
<div key={step.id} className="flex flex-col items-center flex-shrink-0 min-w-[70px]">
<Button
key={step.id}
className={`size-9 rounded-full border font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
variant={currentStep === index + 1 ? "default" : "secondary"}
className="size-8 rounded-full font-bold p-0"
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
))}
</div>
<div className="w-full max-w-full p-4">
<div className="flex md:h-min rounded-xl md:rounded-2xl p-4">
<div className="hidden md:block w-[260px] flex-shrink-0 rounded-xl p-5 pt-7 fixed">
{steps.map((step, index) => (
<div className="my-3 ml-2 flex items-center" key={step.id}>
<Button
className={`size-8 border rounded-full text-sm font-bold ${currentStep === index + 1 ? "bg-primary text-white" : "bg-gray-200 text-black"
}`}
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-5">
<span className="text-xs">{t('accounts.step', { index: index + 1 })}</span>
<span className="font-bold text-sm tracking-wider">{step.name}</span>
</div>
</div>
))}
</div>
<span className="text-[10px] mt-1 text-muted-foreground line-clamp-1">{step.name}</span>
</div>
))}
</div>
<div className="hidden md:block w-[240px] flex-shrink-0 px-8 py-4 border-r overflow-y-auto">
{steps.map((step, index) => (
<div className="mb-8 flex items-center" key={step.id}>
<Button
variant={currentStep === index + 1 ? "default" : "secondary"}
className="size-9 rounded-full text-sm font-bold"
disabled={currentStep === index + 1}
onClick={() => setCurrentStep(index + 1)}
>
{index + 1}
</Button>
<div className="flex flex-col items-baseline uppercase ml-4">
<span className="text-[10px] text-muted-foreground">{t('accounts.step', { index: index + 1 })}</span>
<span className={cn("font-bold text-sm tracking-wider", currentStep === index + 1 ? "text-foreground" : "text-muted-foreground")}>
{step.name}
</span>
</div>
</div>
))}
</div>
<div className="flex-1 min-h-0 relative">
<ScrollArea className="h-full w-full">
<div className="p-6 md:p-10 lg:p-14">
<Form {...form}>
<form
id="account-register-form"
className="flex-grow flex flex-col px-4 md:px-8 lg:px-12 ml-[240px]"
onSubmit={form.handleSubmit(onSubmit)}
>
<form id="account-register-form" onSubmit={form.handleSubmit(onSubmit)}>
{currentStep === 1 && <Step1 isEdit={isEdit} />}
{currentStep === 2 && <Step2 isEdit={isEdit} />}
{currentStep === 3 && <Step3 />}
@@ -392,14 +412,16 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
</form>
</Form>
</div>
</div>
</>
</ScrollArea>
<DialogFooter className="flex flex-wrap gap-2">
</ScrollArea>
</div>
</div>
<DialogFooter className="p-4 md:p-6 bg-background flex flex-row sm:justify-end gap-2 flex-shrink-0">
{currentStep > 1 && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 shadow-none text-nowrap text-sm"
variant="outline"
className="flex-1 sm:flex-none"
onClick={() => setCurrentStep(currentStep - 1)}
>
{t('accounts.goBack')}
@@ -408,7 +430,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
{currentStep < LAST_STEP && (
<Button
type="button"
className="flex-grow sm:flex-grow-0 rounded-md md:rounded-lg px-6 text-sm"
className="flex-1 sm:flex-none px-8"
onClick={handleContinue}
>
{autoConfigLoading ? t('accounts.autoConfiguring') : t('accounts.continue')}
@@ -418,7 +440,7 @@ export function AccountActionDialog({ currentRow, open, onOpenChange }: Props) {
<Button
type="submit"
form="account-register-form"
className="flex-grow sm:flex-grow-0 rounded-md text-sm px-7 md:rounded-lg"
className="flex-1 sm:flex-none px-10"
>
{isEdit ? t('accounts.saveChanges') : t('accounts.submit')}
</Button>

View File

@@ -19,8 +19,6 @@
import { ColumnDef } from '@tanstack/react-table'
import LongText from '@/components/long-text'
import { AccountModel } from '../data/schema'
import { DataTableColumnHeader } from './data-table-column-header'
import { DataTableRowActions } from './data-table-row-actions'
import { format } from 'date-fns'
@@ -28,6 +26,7 @@ import { OAuth2Action } from './oauth2-action'
import { RunningStateCellAction } from './running-state-action'
import { EnableAction } from './enable-action'
import { useTranslation } from 'react-i18next'
import { AccountModel } from '@/api/account/api'
export function useColumns(): ColumnDef<AccountModel>[] {
const { t } = useTranslation()
@@ -47,7 +46,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.email')} />
<DataTableColumnHeader column={column} title={t('accounts.email')} className="justify-center" />
),
cell: ({ row }) => {
return <LongText>{row.original.email}</LongText>
@@ -57,7 +56,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader className="text-center" column={column} title={t('accounts.enabled')} />
<DataTableColumnHeader className="justify-center" column={column} title={t('accounts.enabled')} />
),
cell: EnableAction,
meta: { className: 'w-18 text-center' },
@@ -69,7 +68,7 @@ export function useColumns(): ColumnDef<AccountModel>[] {
<DataTableColumnHeader column={column} title={t('accounts.auth')} />
),
cell: OAuth2Action,
meta: { className: 'w-18 text-center' },
meta: { className: 'text-center' },
enableHiding: false,
enableSorting: false
},
@@ -88,14 +87,14 @@ export function useColumns(): ColumnDef<AccountModel>[] {
{
accessorKey: "sync_interval_sec",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.incSync')} />
<DataTableColumnHeader column={column} title={t('accounts.incSync')} className="justify-center" />
),
cell: ({ row }) => {
let account_type = row.original.account_type;
if (account_type === "NoSync") {
return <LongText>n/a</LongText>
return <LongText className="text-center">n/a</LongText>
}
return <LongText>{row.original.sync_interval_min} min</LongText>
return <LongText className="text-center">{row.original.sync_interval_min} min</LongText>
},
//meta: { className: 'w-18 text-center' },
enableHiding: false,
@@ -106,7 +105,28 @@ export function useColumns(): ColumnDef<AccountModel>[] {
<DataTableColumnHeader column={column} title={t('accounts.state')} />
),
cell: RunningStateCellAction,
meta: { className: 'w-36' },
meta: { className: 'text-center' },
enableHiding: false,
},
{
accessorKey: 'created_by',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('accounts.owner')} className="justify-center" />
),
cell: ({ row }) => {
const { created_user_name, created_user_email } = row.original;
return (
<div className="flex flex-col py-1 text-center">
<span className="text-sm font-medium text-foreground">
{created_user_name}
</span>
<span className="text-[11px] text-muted-foreground font-mono">
{created_user_email}
</span>
</div>
);
},
meta: { className: 'w-60 text-center' },
enableHiding: false,
},
{

View File

@@ -32,51 +32,66 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useTranslation } from 'react-i18next'
interface DataTablePaginationProps<TData> {
table: Table<TData>
showSelected?: boolean,
showSelected?: boolean
showPageSizeSelector?: boolean
}
export function DataTablePagination<TData>({
table,
showSelected = true,
showSelected = false,
showPageSizeSelector = true
}: DataTablePaginationProps<TData>) {
const { t } = useTranslation()
return (
<div className='flex items-center justify-between overflow-auto px-2'>
{showSelected && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>}
{!showPageSizeSelector && <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
10 rows per page.
</div>}
{showSelected && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.selected', {
selected: table.getFilteredSelectedRowModel().rows.length,
total: table.getFilteredRowModel().rows.length,
})}
</div>
)}
{!showPageSizeSelector && (
<div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
{t('table.pagination.fixed_page_size', { size: 10 })}
</div>
)}
<div className='flex items-center sm:space-x-6 lg:space-x-8 ml-auto'>
{showPageSizeSelector && <div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>}
<div className='flex w-[100px] items-center justify-center text-sm font-medium'>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
{showPageSizeSelector && (
<div className='flex items-center space-x-2'>
<p className='hidden text-sm font-medium sm:block'>
{t('table.pagination.rows_per_page')}
</p>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-[70px]'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className='flex w-[130px] items-center justify-center text-sm font-medium'>
{t('table.pagination.page_info', {
page: table.getState().pagination.pageIndex + 1,
total: table.getPageCount(),
})}
</div>
<div className='flex items-center space-x-2'>
<Button
@@ -85,7 +100,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<span className='sr-only'>{t('table.pagination.first')}</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -94,7 +109,7 @@ export function DataTablePagination<TData>({
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<span className='sr-only'>{t('table.pagination.previous')}</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
<Button
@@ -103,7 +118,7 @@ export function DataTablePagination<TData>({
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<span className='sr-only'>{t('table.pagination.next')}</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
@@ -112,7 +127,7 @@ export function DataTablePagination<TData>({
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<span className='sr-only'>{t('table.pagination.last')}</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>

View File

@@ -19,7 +19,7 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { Row } from '@tanstack/react-table'
import { IconEdit, IconTrash } from '@tabler/icons-react'
import { IconEdit, IconShieldLock, IconTrash } from '@tabler/icons-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -30,9 +30,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { Mailbox, MessageSquareMore } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { AccountModel } from '@/api/account/api'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -43,11 +44,21 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useAccountContext()
const account_type = row.original.account_type;
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
const hasReadPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id);
const canShowAnyAction =
(hasPermission) ||
(account_type === 'IMAP' && hasPermission) ||
(account_type === 'IMAP' && hasReadPermission);
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild disabled={!canShowAnyAction}>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
@@ -57,7 +68,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
if (account_type === "IMAP") {
@@ -72,8 +83,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuShortcut>
<IconEdit size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{account_type === "IMAP" && <DropdownMenuItem
</DropdownMenuItem>}
{account_type === "IMAP" && hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('sync-folders')
@@ -84,7 +95,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<Mailbox size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{account_type === "IMAP" && <DropdownMenuItem
{account_type === "IMAP" && hasReadPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('detail')
@@ -95,8 +106,20 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<MessageSquareMore size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
<DropdownMenuSeparator />
<DropdownMenuItem
{hasPermission && <DropdownMenuSeparator />}
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('access-assign')
}}
>
<span>{t('accounts.accessControl')}</span>
<DropdownMenuShortcut>
<IconShieldLock size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>}
{hasPermission && <DropdownMenuSeparator />}
{hasPermission && <DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
@@ -107,7 +130,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuShortcut>
<IconTrash size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuItem>}
</DropdownMenuContent>
</DropdownMenu>
</>

View File

@@ -24,11 +24,10 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { AccountModel } from '../data/schema'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ToastAction } from '@/components/ui/toast'
import { AxiosError } from 'axios'
import { remove_account } from '@/api/account/api'
import { AccountModel, remove_account } from '@/api/account/api'
import { useTranslation } from 'react-i18next'
interface Props {
@@ -54,7 +53,7 @@ export function AccountDeleteDialog({ open, onOpenChange, currentRow }: Props) {
}
function handleError(error: AxiosError) {
const errorMessage = error.response?.data ||
const errorMessage = (error.response?.data as { message?: string })?.message ||
error.message ||
t('dialogs.deleteFailed');

View File

@@ -18,16 +18,16 @@
import { Row } from '@tanstack/react-table'
import { AccountModel } from '../data/schema'
import { Switch } from '@/components/ui/switch'
import { useState } from 'react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { ToastAction } from '@/components/ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { update_account } from '@/api/account/api'
import { AccountModel, update_account } from '@/api/account/api'
import { toast } from '@/hooks/use-toast'
import { AxiosError } from 'axios'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -37,7 +37,10 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const { require_any_permission } = useCurrentUser()
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id);
const updateMutation = useMutation({
mutationFn: (enabled: boolean) =>
update_account(row.original.id, { enabled }),
@@ -74,7 +77,7 @@ export function EnableAction({ row }: DataTableRowActionsProps) {
<Switch
checked={row.original.enabled}
onCheckedChange={() => setOpen(true)}
disabled={updateMutation.isPending}
disabled={!hasPermission || updateMutation.isPending}
/>
<ConfirmDialog
open={open}

View File

@@ -28,12 +28,11 @@ import { AxiosError } from 'axios';
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { create_account, update_account } from '@/api/account/api';
import { AccountModel, create_account, update_account } from '@/api/account/api';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2 } from 'lucide-react';
import { AccountModel } from '../data/schema';
import { useTranslation } from 'react-i18next';

View File

@@ -20,8 +20,11 @@
import { Row } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { useAccountContext } from '../context'
import { AccountModel } from '../data/schema'
import { useTranslation } from 'react-i18next'
import { useCurrentUser } from '@/hooks/use-current-user'
import { toast } from '@/hooks/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { AccountModel } from '@/api/account/api'
interface DataTableRowActionsProps {
row: Row<AccountModel>
@@ -29,8 +32,10 @@ interface DataTableRowActionsProps {
export function OAuth2Action({ row }: DataTableRowActionsProps) {
const { t } = useTranslation()
const { setOpen, setCurrentRow } = useAccountContext()
const { require_any_permission } = useCurrentUser()
const mailer = row.original
const account_type = mailer.account_type;
const hasPermission = require_any_permission(['system:root', 'account:manage'], row.original.id)
if (account_type === "NoSync") {
return <Button variant={"ghost"} className="text-xs text-muted-foreground">n/a</Button>
@@ -45,8 +50,21 @@ export function OAuth2Action({ row }: DataTableRowActionsProps) {
size="sm"
className="text-xs text-blue-500 hover:text-blue-700 underline"
onClick={() => {
setCurrentRow(mailer)
setOpen("oauth2")
if (hasPermission) {
setCurrentRow(mailer)
setOpen("oauth2")
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view oauth2 tokens.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}
>
OAuth2

Some files were not shown because too many files have changed in this diff Show More