diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 84413d3..c62921b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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:
diff --git a/Cargo.lock b/Cargo.lock
index 6c7b3b2..4b6d98a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "bichon"
-version = "0.1.4"
+version = "0.2.1"
dependencies = [
"ahash",
"async-imap",
diff --git a/Cargo.toml b/Cargo.toml
index ca347e7..b84b7e2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "bichon"
-version = "0.1.4"
+version = "0.2.1"
edition = "2021"
[[bin]]
diff --git a/README.md b/README.md
index 3344cde..7e5c79b 100644
--- a/README.md
+++ b/README.md
@@ -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-token–based 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
diff --git a/src/main.rs b/src/main.rs
index 7a6da72..ef9765a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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();
diff --git a/src/modules/account/grant.rs b/src/modules/account/grant.rs
new file mode 100644
index 0000000..8452d66
--- /dev/null
+++ b/src/modules/account/grant.rs
@@ -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 .
+
+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,
+ pub user_ids: Vec,
+ 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,
+ user_ids: Vec,
+ 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::(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
+ }
+}
diff --git a/src/modules/account/migration.rs b/src/modules/account/migration.rs
index 64b4d37..e9089dd 100644
--- a/src/modules/account/migration.rs
+++ b/src/modules/account/migration.rs
@@ -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 {
+#[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,
+ pub enabled: bool,
+ #[oai(validator(custom = "crate::modules::common::validator::EmailValidator"))]
+ pub email: String,
+ pub name: Option,
+ pub capabilities: Option>,
+ pub date_since: Option,
+ pub date_before: Option,
+ pub folder_limit: Option,
+ pub sync_folders: Option>,
+ pub account_type: AccountType,
+ pub sync_interval_min: Option,
+ pub sync_batch_size: Option,
+ pub known_folders: Option>,
+ pub created_at: i64,
+ pub updated_at: i64,
+ pub created_by: u64, //user id
+ pub use_proxy: Option,
+ pub use_dangerous: bool,
+ pub pgp_key: Option,
+}
+
+impl AccountV3 {
+ fn pk(&self) -> String {
+ format!("{}_{}", self.created_at, self.id)
+ }
+
+ pub fn new(user_id: u64, request: AccountCreateRequest) -> BichonResult {
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 {
let account =
- secondary_find_impl::(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
+ secondary_find_impl::(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> {
- secondary_find_impl::(DB_MANAGER.meta_db(), AccountV2Key::id, account_id)
+ secondary_find_impl::(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 {
- 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 {
+ 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::(entity)
+ .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
+ let user = rw
+ .get()
+ .primary::(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::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
+ rw.get().secondary::(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,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
- rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
+ rw.get().secondary::(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,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
- rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
+ rw.get().secondary::(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,
) -> BichonResult<()> {
update_impl(DB_MANAGER.meta_db(), move |rw| {
- rw.get().secondary::(AccountV2Key::id, account_id).map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
+ rw.get().secondary::(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 {
- count_by_unique_secondary_key_impl::(DB_MANAGER.meta_db(), AccountV2Key::id)
+ count_by_unique_secondary_key_impl::(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 for AccountV1 {
}
}
}
+
+impl From 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 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,
+ }
+ }
+}
diff --git a/src/modules/account/mod.rs b/src/modules/account/mod.rs
index 5d879a4..c49ea1c 100644
--- a/src/modules/account/mod.rs
+++ b/src/modules/account/mod.rs
@@ -16,10 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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;
diff --git a/src/modules/account/payload.rs b/src/modules/account/payload.rs
index adc2f2c..5be9c83 100644
--- a/src/modules/account/payload.rs
+++ b/src/modules/account/payload.rs
@@ -16,14 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-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,
pub enabled: bool,
pub date_since: Option,
+ pub date_before: Option,
pub account_type: AccountType,
#[oai(validator(minimum(value = "100")))]
pub folder_limit: Option,
#[oai(validator(minimum(value = "10"), maximum(value = "480")))]
pub sync_interval_min: Option,
+ #[oai(validator(minimum(value = "30"), maximum(value = "200")))]
+ pub sync_batch_size: Option,
pub use_proxy: Option,
pub use_dangerous: bool,
pub pgp_key: Option,
}
impl AccountCreateRequest {
- pub fn create_entity(self) -> BichonResult {
+ pub fn create_entity(self, user_id: u64) -> 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()?;
+ }
+
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,
+ pub date_before: Option,
/// 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,
+ #[oai(validator(minimum(value = "30"), maximum(value = "200")))]
+ pub sync_batch_size: Option,
/// 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,
+ allowed: &Vec,
) -> Vec {
all_accounts
.iter()
- .filter(|acct| allowed.iter().any(|a| a.id == acct.id))
+ .filter(|acct| allowed.contains(&acct.id))
.cloned()
.collect()
}
diff --git a/src/modules/account/since.rs b/src/modules/account/since.rs
index 5ae340f..1336769 100644
--- a/src/modules/account/since.rs
+++ b/src/modules/account/since.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
use crate::{
modules::error::{code::ErrorCode, BichonResult},
raise_error,
diff --git a/src/modules/account/view.rs b/src/modules/account/view.rs
new file mode 100644
index 0000000..b967fb1
--- /dev/null
+++ b/src/modules/account/view.rs
@@ -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 .
+
+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,
+ pub enabled: bool,
+ pub email: String,
+ pub name: Option,
+ pub capabilities: Option>,
+ pub date_since: Option,
+ pub date_before: Option,
+ pub folder_limit: Option,
+ pub sync_folders: Option>,
+ pub account_type: AccountType,
+ pub sync_interval_min: Option,
+ pub sync_batch_size: Option,
+ pub known_folders: Option>,
+ 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,
+ pub use_dangerous: bool,
+ pub pgp_key: Option,
+}
+
+impl AccountResp {
+ pub fn from_model(account: AccountModel, user_map: &HashMap) -> 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,
+ }
+ }
+}
diff --git a/src/modules/cache/imap/sync/flow.rs b/src/modules/cache/imap/sync/flow.rs
index b52d6f7..fd16c69 100644
--- a/src/modules/cache/imap/sync/flow.rs
+++ b/src/modules/cache/imap/sync/flow.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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 {
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?;
}
diff --git a/src/modules/cache/imap/sync/mod.rs b/src/modules/cache/imap/sync/mod.rs
index 76473e8..d1873a7 100644
--- a/src/modules/cache/imap/sync/mod.rs
+++ b/src/modules/cache/imap/sync/mod.rs
@@ -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(_) => {
diff --git a/src/modules/cache/imap/sync/rebuild.rs b/src/modules/cache/imap/sync/rebuild.rs
index 77ec860..00a3c60 100644
--- a/src/modules/cache/imap/sync/rebuild.rs
+++ b/src/modules/cache/imap/sync/rebuild.rs
@@ -16,14 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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> =
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
diff --git a/src/modules/common/auth.rs b/src/modules/common/auth.rs
index 84eb091..66d2d2b 100644
--- a/src/modules/common/auth.rs
+++ b/src/modules/common/auth.rs
@@ -16,12 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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 Endpoint for ApiGuardEndpoint {
}
}
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Debug)]
pub struct ClientContext {
pub ip_addr: Option,
- pub access_token: Option,
- 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>> {
- 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, &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, 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, 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, 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,
+ 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 {
- 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::>()
- .map(|auth| auth.0.token().to_string())
- .or_else(|| req.params:: ().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::>()
+ .map(|auth| auth.0.token().to_string())
+ .or_else(|| req.params:: ().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 {
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)
diff --git a/src/modules/dashboard/mod.rs b/src/modules/dashboard/mod.rs
index 66b787b..63f9996 100644
--- a/src/modules/dashboard/mod.rs
+++ b/src/modules/dashboard/mod.rs
@@ -16,14 +16,17 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+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 {
- 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 {
+ let has_all_accounts = context
+ .has_permission(None, Permission::ACCOUNT_MANAGE_ALL)
+ .await;
+
+ let authorized_ids: Option> = 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)
}
}
diff --git a/src/modules/database/manager.rs b/src/modules/database/manager.rs
index af1bf9c..8fe15e0 100644
--- a/src/modules/database/manager.rs
+++ b/src/modules/database/manager.rs
@@ -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::()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
+ rw.migrate::()
+ .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
rw.commit()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
diff --git a/src/modules/database/mod.rs b/src/modules/database/mod.rs
index 3ed0ea9..02e2ed0 100644
--- a/src/modules/database/mod.rs
+++ b/src/modules/database/mod.rs
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-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::();
+ //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::();
self.register_model::();
self.register_model::();
self.register_model::();
self.register_model::();
+ self.register_model::();
self.register_model::();
self.register_model::();
self.register_model::();
self.register_model::();
+ self.register_model::();
+ self.register_model::();
+ self.register_model::();
+ self.register_model::();
}
}
@@ -170,11 +178,11 @@ pub async fn update_impl(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let current_item = current(&rw)?;
let updated_item = updated(¤t_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(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
-pub fn find_impl(
- database: &Arc>,
- key: &str,
-) -> BichonResult> {
- let db = database.clone();
- let r_transaction = db
- .r_transaction()
- .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
- let entity: Option = r_transaction
- .get()
- .primary(key)
- .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
- Ok(entity)
-}
+// pub fn find_impl(
+// database: &Arc>,
+// key: &str,
+// ) -> BichonResult> {
+// let db = database.clone();
+// let r_transaction = db
+// .r_transaction()
+// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
+// let entity: Option = r_transaction
+// .get()
+// .primary(key)
+// .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
+// Ok(entity)
+// }
pub async fn delete_impl(
database: &Arc>,
@@ -307,6 +315,25 @@ pub async fn list_all_impl(
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?
}
+pub async fn with_transaction(
+ database: &Arc>,
+ f: impl FnOnce(&RwTransaction) -> BichonResult<()> + Send + 'static,
+) -> BichonResult<()> {
+ let db: Arc> = 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.
diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs
index 23f1a1a..339ce9d 100644
--- a/src/modules/envelope/extractor.rs
+++ b/src/modules/envelope/extractor.rs
@@ -17,13 +17,14 @@
// along with this program. If not, see .
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 {
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> = 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> = 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)
}
diff --git a/src/modules/envelope/mod.rs b/src/modules/envelope/mod.rs
index 872957e..87f2ec9 100644
--- a/src/modules/envelope/mod.rs
+++ b/src/modules/envelope/mod.rs
@@ -16,5 +16,5 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
pub mod extractor;
+pub mod utils;
diff --git a/src/modules/envelope/utils.rs b/src/modules/envelope/utils.rs
new file mode 100644
index 0000000..1fe281d
--- /dev/null
+++ b/src/modules/envelope/utils.rs
@@ -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 = None;
+ let mut encoding: Option = 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?=");
+ }
+}
diff --git a/src/modules/error/code.rs b/src/modules/error/code.rs
index 38057af..662b84f 100644
--- a/src/modules/error/code.rs
+++ b/src/modules/error/code.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
use poem::http::StatusCode;
use poem_openapi::Enum;
@@ -34,12 +33,14 @@ pub enum ErrorCode {
// Authentication and authorization errors (20000–20999)
PermissionDenied = 20000,
AccountDisabled = 20010,
+ Forbidden = 20020,
OAuth2ItemDisabled = 20050,
MissingRefreshToken = 20060,
// Resource errors (30000–30999)
ResourceNotFound = 30000,
TooManyRequest = 30020,
+ AlreadyExists = 30030,
// Network connection errors (40000–40999)
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
diff --git a/src/modules/imap/executor.rs b/src/modules/imap/executor.rs
index 13f1320..95200d5 100644
--- a/src/modules/imap/executor.rs
+++ b/src/modules/imap/executor.rs
@@ -16,9 +16,10 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+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 = 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(())
diff --git a/src/modules/imap/manager.rs b/src/modules/imap/manager.rs
index cf92b01..4a5f158 100644
--- a/src/modules/imap/manager.rs
+++ b/src/modules/imap/manager.rs
@@ -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
+ })
}
}
}
diff --git a/src/modules/imap/pool.rs b/src/modules/imap/pool.rs
index f444b4a..218ee45 100644
--- a/src/modules/imap/pool.rs
+++ b/src/modules/imap/pool.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
use crate::modules::error::code::ErrorCode;
use crate::modules::error::{BichonError, BichonResult};
use crate::modules::imap::{manager::ImapConnectionManager, session::SessionStream};
diff --git a/src/modules/imap/tests.rs b/src/modules/imap/tests.rs
index f403cb7..67b390d 100644
--- a/src/modules/imap/tests.rs
+++ b/src/modules/imap/tests.rs
@@ -16,11 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-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 (Vandelay Industries)
+To: "Colleagues": "James Smythe" ; Friends:
+ jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?= ;
+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"
+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);
+ }
+}
diff --git a/src/modules/indexer/envelope.rs b/src/modules/indexer/envelope.rs
index 4007fbf..e89c65d 100644
--- a/src/modules/indexer/envelope.rs
+++ b/src/modules/indexer/envelope.rs
@@ -16,7 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
+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,
pub mailbox_id: u64,
+ pub mailbox_name: Option,
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)?,
diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs
index 1e648fe..c54cb52 100644
--- a/src/modules/indexer/manager.rs
+++ b/src/modules/indexer/manager.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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 {
+ pub fn total_emails(&self, accounts: &Option>) -> BichonResult {
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,
+ ));
+ }
+ let query = Box::new(BooleanQuery::new(subqueries)) as Box;
+ 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 {
@@ -223,12 +242,36 @@ impl EnvelopeIndexManager {
fn filter_query(
&self,
+ accounts: Option>,
filter: SearchFilter,
parser: QueryParser,
) -> BichonResult> {
let f = SchemaTools::envelope_fields();
let mut subqueries: Vec<(Occur, Box)> = 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,
+ ));
+ }
+ 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,
) -> 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> {
+ pub async fn get_all_tags(
+ &self,
+ accounts: Option>,
+ ) -> BichonResult> {
let searcher = self.reader.searcher();
+
+ let query: Box = 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,
+ ));
+ }
+ 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>,
filter: SearchFilter,
page: u64,
page_size: u64,
@@ -557,7 +624,7 @@ impl EnvelopeIndexManager {
) -> BichonResult> {
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> {
+
+ pub async fn top_10_largest_emails(
+ &self,
+ accounts: &Option>,
+ ) -> BichonResult> {
self.reader
.reload()
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
let searcher = self.reader.searcher();
+ let query: Box = 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,
+ ));
+ }
+ 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 {
+ pub async fn get_dashboard_stats(
+ &self,
+ accounts: &Option>,
+ ) -> BichonResult {
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 = 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,
+ ));
+ }
+ 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)
diff --git a/src/modules/mailbox/list.rs b/src/modules/mailbox/list.rs
index 7d2a218..3f0e420 100644
--- a/src/modules/mailbox/list.rs
+++ b/src/modules/mailbox/list.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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;
}
diff --git a/src/modules/message/search.rs b/src/modules/message/search.rs
index c762eec..7a78c19 100644
--- a/src/modules/message/search.rs
+++ b/src/modules/message/search.rs
@@ -16,6 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+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> {
+pub async fn search_messages_impl(
+ accounts: Option>,
+ request: SearchRequest,
+) -> BichonResult> {
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
}
diff --git a/src/modules/mod.rs b/src/modules/mod.rs
index 1264760..c551325 100644
--- a/src/modules/mod.rs
+++ b/src/modules/mod.rs
@@ -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;
diff --git a/src/modules/oauth2/entity.rs b/src/modules/oauth2/entity.rs
index bf23663..697573d 100644
--- a/src/modules/oauth2/entity.rs
+++ b/src/modules/oauth2/entity.rs
@@ -16,7 +16,6 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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(())
diff --git a/src/modules/rest/api/access_token.rs b/src/modules/rest/api/access_token.rs
index 231a430..0867f50 100644
--- a/src/modules/rest/api/access_token.rs
+++ b/src/modules/rest/api/access_token.rs
@@ -16,16 +16,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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>> {
- context.require_root()?;
- Ok(Json(AccessToken::list_all().await?))
+ ) -> ApiResult>> {
+ 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,
- context: ClientContext,
- ) -> ApiResult>> {
- 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,
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,
) -> ApiResult> {
- 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,
- /// The request payload.
- payload: Json,
- ) -> 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> {
- 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,
- 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))
}
}
diff --git a/src/modules/rest/api/account.rs b/src/modules/rest/api/account.rs
index 2ff1dc6..1b14711 100644
--- a/src/modules/rest/api/account.rs
+++ b/src/modules/rest/api/account.rs
@@ -16,23 +16,25 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-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> {
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,
context: ClientContext,
) -> ApiResult> {
- 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>,
context: ClientContext,
- ) -> ApiResult>> {
- let accessible_accounts = context.accessible_accounts()?;
+ ) -> ApiResult>> {
+ 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 =
- accessible_accounts.unwrap().iter().map(|a| a.id).collect();
-
- let mut filtered_accounts: Vec = all_accounts
+ let user_map: HashMap = UserModel::list_all()
+ .await?
.into_iter()
- .filter(|acct| allowed_ids.contains(&acct.id))
+ .map(|u| (u.id, u))
+ .collect();
+ let page_data: DataPage = if is_admin {
+ AccountModel::paginate_list(page.0, page_size.0, desc.0).await?
+ } else {
+ let authorized_ids: HashSet =
+ 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::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> {
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>> {
- 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 = 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,
+ context: ClientContext,
+ ) -> ApiResult<()> {
+ req.validate_existence().await?;
+ req.0.do_assign(&context).await?;
+ Ok(())
+ }
}
diff --git a/src/modules/rest/api/auto_config.rs b/src/modules/rest/api/auto_config.rs
index 299c703..f209181 100644
--- a/src/modules/rest/api/auto_config.rs
+++ b/src/modules/rest/api/auto_config.rs
@@ -16,14 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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
+ email_address: Path,
+ context: ClientContext,
) -> ApiResult> {
+ context
+ .require_permission(None, Permission::ACCOUNT_CREATE)
+ .await?;
+
let result = resolve_autoconfig(email_address.0.trim())
.await?
.ok_or_else(|| {
diff --git a/src/modules/rest/api/import.rs b/src/modules/rest/api/import.rs
index ede9650..94e07ae 100644
--- a/src/modules/rest/api/import.rs
+++ b/src/modules/rest/api/import.rs
@@ -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,
context: ClientContext,
) -> ApiResult> {
- context.require_root()?;
+ context
+ .require_permission(Some(payload.0.account_id), Permission::DATA_IMPORT_BATCH)
+ .await?;
Ok(Json(ImportEmls::do_import(payload.0).await?))
}
}
diff --git a/src/modules/rest/api/mailbox.rs b/src/modules/rest/api/mailbox.rs
index 155ec95..077f9e2 100644
--- a/src/modules/rest/api/mailbox.rs
+++ b/src/modules/rest/api/mailbox.rs
@@ -16,14 +16,13 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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>> {
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?))
}
diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs
index 97d3556..736b43d 100644
--- a/src/modules/rest/api/message.rs
+++ b/src/modules/rest/api/message.rs
@@ -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>> {
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,
context: ClientContext,
) -> ApiResult>> {
- context.require_root()?;
- Ok(Json(search_messages_impl(payload.0).await?))
+ let authorized_ids: Option> = 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>> {
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> {
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> {
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> {
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>> {
- Ok(Json(ENVELOPE_INDEX_MANAGER.get_all_tags().await?))
+ async fn get_all_tags(&self, context: ClientContext) -> ApiResult>> {
+ let authorized_ids: Option> = 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) -> ApiResult<()> {
+ async fn update_envelope_tags(
+ &self,
+ req: Json,
+ 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?;
diff --git a/src/modules/rest/api/mod.rs b/src/modules/rest/api/mod.rs
index 5248db4..087fffa 100644
--- a/src/modules/rest/api/mod.rs
+++ b/src/modules/rest/api/mod.rs
@@ -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 {
@@ -70,6 +76,7 @@ pub fn create_openapi_service() -> OpenApiService {
OAuth2Api,
MessageApi,
ImportApi,
+ UsersApi,
),
"BichonApi",
bichon_version!(),
diff --git a/src/modules/rest/api/oauth2.rs b/src/modules/rest/api/oauth2.rs
index 2606cfa..6bd66d0 100644
--- a/src/modules/rest/api/oauth2.rs
+++ b/src/modules/rest/api/oauth2.rs
@@ -16,7 +16,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
+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,
context: ClientContext,
) -> ApiResult> {
- 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,
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,
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,
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>,
context: ClientContext,
) -> ApiResult>> {
- 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,
context: ClientContext,
) -> ApiResult> {
- 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> {
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,
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(())
}
}
diff --git a/src/modules/rest/api/system.rs b/src/modules/rest/api/system.rs
index 3384372..84ebde6 100644
--- a/src/modules/rest/api/system.rs
+++ b/src/modules/rest/api/system.rs
@@ -16,13 +16,15 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-
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> {
- let stats = DashboardStats::get().await?;
+ async fn get_dashboard_stats(&self, context: ClientContext) -> ApiResult> {
+ 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>> {
+ async fn list_proxy(&self, context: ClientContext) -> ApiResult>> {
+ 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,
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,
context: ClientContext,
) -> ApiResult> {
- 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, 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,
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> {
+ context
+ .require_permission(None, Permission::ROOT)
+ .await?;
+ let config: SystemConfigurations = SystemConfigurations::from(&*SETTINGS);
+ Ok(Json(config))
+ }
}
diff --git a/src/modules/rest/api/users.rs b/src/modules/rest/api/users.rs
new file mode 100644
index 0000000..84a0397
--- /dev/null
+++ b/src/modules/rest/api/users.rs
@@ -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 .
+
+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>> {
+ 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,
+ 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,
+ context: ClientContext,
+ ) -> ApiResult> {
+ 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,
+ /// Role update request payload
+ payload: Json,
+ 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>> {
+ context
+ .require_permission(None, Permission::USER_MANAGE)
+ .await?;
+ let roles = UserRole::list_all().await?;
+ let role_lookup: BTreeMap = 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,
+ context: ClientContext,
+ ) -> ApiResult>> {
+ 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,
+ 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,
+ context: ClientContext,
+ ) -> ApiResult> {
+ 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 = 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,
+ payload: Json